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

Hello

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

Hello

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

Draft

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

Draft

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

Quoted

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

Quoted

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

Earlier

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

Earlier

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

Hi

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

Hi

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

Old

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

Hi

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

Hi

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

Hi

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

Hi

'); + expect(message.mml, 'Hi'); + expect(message.imageLabels, { + 'cat.png': ['cat', 'animal'], + }); + expect(message.deletedReplyCount, 2); + }); + + test( + 'Message.fromJson reads a message deleted only for the current user as deleted, even when it is soft-deleted', + () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'type': 'regular', + 'deleted_for_me': true, + 'deleted_at': '2026-01-03T00:00:00.000Z', + 'created_at': '2026-01-01T00:00:00.000Z', + 'updated_at': '2026-01-02T00:00:00.000Z', + }); + + expect((message.type, message.state), (MessageType.deleted, MessageState.deletedForMe)); + }, + ); + + test('Message.fromJson reads a deleted message as soft-deleted', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'type': 'deleted', + 'deleted_at': '2026-01-03T00:00:00.000Z', + 'created_at': '2026-01-01T00:00:00.000Z', + 'updated_at': '2026-01-02T00:00:00.000Z', + }); + + expect((message.type, message.state), (MessageType.deleted, MessageState.softDeleted)); + }); + + test('Message.fromJson reads a message updated after it was created as updated', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'type': 'regular', + 'created_at': '2026-01-01T00:00:00.000Z', + 'updated_at': '2026-01-02T00:00:00.000Z', + }); + + expect((message.type, message.state), (MessageType.regular, MessageState.updated)); + }); + + test('Message.fromJson reads a message never updated as sent', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'type': 'regular', + 'created_at': '2026-01-01T00:00:00.000Z', + 'updated_at': '2026-01-01T00:00:00.000Z', + }); + + expect((message.type, message.state), (MessageType.regular, MessageState.sent)); + }); + + test('Message reads no HTML, markup, image labels or deleted reply count when extraData has none', () { + final message = Message(); + + expect(message.html, isNull); + expect(message.mml, isNull); + expect(message.imageLabels, isNull); + expect(message.deletedReplyCount, isNull); + }); + group('MessageVisibility Extension Tests', () { group('hasRestrictedVisibility', () { test('should return false when restrictedVisibility is null', () { diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart index 10db0cc0d7..e93c8e62c7 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart @@ -105,58 +105,53 @@ class StreamMessageReminderListController extends PagedValueNotifier value = PagedValue.error(_chatException(error, 'Failed to load message reminders')), + ); } @override Future loadMore(String nextPageKey) async { final previousValue = value.asSuccess; - try { - final response = await client.queryReminders( - sort: _activeSort, - filter: _activeFilter, - pagination: PaginationParams(limit: limit, next: nextPageKey), - ); - - final results = response.reminders; - final previousItems = previousValue.items; - final newItems = previousItems + results; - final next = response.next; - final nextKey = next != null && next.isNotEmpty ? next : null; - value = PagedValue( - items: newItems, - nextPageKey: nextKey, - ); - } on StreamChatException catch (error) { - value = previousValue.copyWith(error: error); - } catch (error) { - final chatError = StreamClientException(message: 'Failed to load more message reminders', cause: error); - value = previousValue.copyWith(error: chatError); - } + final result = await client.queryReminders( + sort: _activeSort, + filter: _activeFilter, + limit: limit, + next: nextPageKey, + ); + + result.fold( + onSuccess: (response) { + final next = response.next; + value = PagedValue( + items: previousValue.items + response.reminders, + nextPageKey: next != null && next.isNotEmpty ? next : null, + ); + }, + onFailure: (error, _) { + value = previousValue.copyWith(error: _chatException(error, 'Failed to load more message reminders')); + }, + ); } + StreamChatException _chatException(Object error, String message) => switch (error) { + final StreamChatException error => error, + _ => StreamClientException(message: message, cause: error), + }; + /// Event listener, which can be set in order to listen /// [client] web-socket events. /// diff --git a/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart index e1c4967292..20715d8a8a 100644 --- a/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart @@ -66,10 +66,6 @@ List generateMessageReminders({ void main() { final client = MockClient(); - setUpAll(() { - registerFallbackValue(const PaginationParams()); - }); - setUp(() { when(client.on).thenAnswer((_) => const Stream.empty()); }); @@ -98,17 +94,18 @@ void main() { }); group('Initial loading', () { - test('successfully loads message reminders from API', () async { + test('StreamMessageReminderListController.doInitialLoad loads the first page and the cursor of the next', () async { final reminders = generateMessageReminders(); - final response = QueryRemindersResponse() - ..reminders = reminders - ..next = null; + final response = Result.success( + QueryRemindersResponse(duration: '0.01ms', reminders: reminders, next: 'next-cursor'), + ); when( () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer((_) async => response); @@ -121,12 +118,13 @@ void main() { () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: 30, ), ).called(1); expect(controller.value, isA>()); expect(controller.value.asSuccess.items, equals(reminders)); + expect(controller.value.asSuccess.nextPageKey, 'next-cursor'); }); test('an empty sort queries without a sort term', () async { @@ -136,13 +134,12 @@ void main() { () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer((invocation) async { sorts.add(invocation.namedArguments[const Symbol('sort')]); - return QueryRemindersResponse() - ..reminders = generateMessageReminders() - ..next = null; + return Result.success(QueryRemindersResponse(duration: '0.01ms', reminders: generateMessageReminders())); }); final controller = StreamMessageReminderListController( @@ -156,15 +153,16 @@ void main() { expect(sorts.single, isEmpty); }); - test('handles a Stream failure properly', () async { + test('StreamMessageReminderListController.doInitialLoad reports a failed query as an error', () async { const chatError = StreamNetworkException(message: 'Network error'); when( () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), - ).thenThrow(chatError); + ).thenAnswer((_) async => const Result.failure(chatError)); final controller = StreamMessageReminderListController(client: client); @@ -177,27 +175,28 @@ void main() { }); group('Pagination', () { - test('loadMore appends new reminders to existing items', () async { + test('StreamMessageReminderListController.loadMore appends the last page and stops paging', () async { const nextKey = 'next_page_token'; final existingReminders = generateMessageReminders(); - final additionalReminders = generateMessageReminders( - count: 1, - startId: 789, - texts: ['Reminder 3'], - channelCids: ['messaging:789'], - messageIds: ['message_789'], - userIds: ['user_789'], - ); + // Due after the existing ones, which come first in the default sort. + final additionalReminders = [ + generateMessageReminder( + channelCid: 'messaging:789', + messageId: 'message_789', + userId: 'user_789', + text: 'Reminder 3', + remindAt: DateTime.now().add(const Duration(hours: 3)), + ), + ]; - final response = QueryRemindersResponse() - ..reminders = additionalReminders - ..next = null; + final response = Result.success(QueryRemindersResponse(duration: '0.01ms', reminders: additionalReminders)); when( () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer((_) async => response); @@ -212,44 +211,79 @@ void main() { await controller.loadMore(nextKey); await pumpEventQueue(); + verify( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: 10, + next: nextKey, + ), + ).called(1); + final mergedReminders = [...existingReminders, ...additionalReminders]; - expect( - controller.value.asSuccess.items.length, - equals(mergedReminders.length), - ); + expect(controller.value.asSuccess.items, equals(mergedReminders)); expect(controller.value.asSuccess.nextPageKey, isNull); }); - test('loadMore handles a Stream failure properly', () async { + test('StreamMessageReminderListController.loadMore keeps the cursor of the following page', () async { const nextKey = 'next_page_token'; - final existingReminders = generateMessageReminders(); - const chatError = StreamNetworkException(message: 'Network error'); - + const page = Result.success( + QueryRemindersResponse(duration: '0.01ms', reminders: [], next: 'later-cursor'), + ); when( () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), - ).thenThrow(chatError); + ).thenAnswer((_) async => page); final controller = StreamMessageReminderListController.fromValue( - PagedValue( - items: existingReminders, - nextPageKey: nextKey, - ), + PagedValue(items: generateMessageReminders(), nextPageKey: nextKey), client: client, ); await controller.loadMore(nextKey); await pumpEventQueue(); - expect(controller.value.isSuccess, isTrue); - expect(controller.value.asSuccess.items, equals(existingReminders)); - expect(controller.value.asSuccess.error, equals(chatError)); + expect(controller.value.asSuccess.nextPageKey, 'later-cursor'); }); + + test( + 'StreamMessageReminderListController.loadMore keeps the loaded reminders and reports a failed query', + () async { + const nextKey = 'next_page_token'; + final existingReminders = generateMessageReminders(); + const chatError = StreamNetworkException(message: 'Network error'); + + when( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + ), + ).thenAnswer((_) async => const Result.failure(chatError)); + + final controller = StreamMessageReminderListController.fromValue( + PagedValue( + items: existingReminders, + nextPageKey: nextKey, + ), + client: client, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(existingReminders)); + expect(controller.value.asSuccess.error, equals(chatError)); + }, + ); }); group('Message Reminder CRUD operations', () { @@ -361,12 +395,11 @@ void main() { () => client.queryReminders( filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer( - (_) async => QueryRemindersResponse() - ..reminders = initialReminders - ..next = null, + (_) async => Result.success(QueryRemindersResponse(duration: '0.01ms', reminders: initialReminders)), ); }); From 514d3a77705b4b1f471861e6a40441fb722a8a61 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 11:42:03 +0200 Subject: [PATCH 07/14] feat(llc): add Attachment.giphy Co-Authored-By: Claude Opus 5.5 --- openapi-migration/10-messages.md | 5 +- openapi-migration/tool/generate_plan.py | 5 +- packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/core/models/attachment.dart | 11 ++++ .../repository/mapper/attachment_mapper.dart | 10 ++-- .../test/src/client/message_fixtures.dart | 39 +++++++------- .../test/src/core/models/attachment_test.dart | 53 +++++++++++++++++++ 7 files changed, 93 insertions(+), 31 deletions(-) diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 8181bf267f..9802abb5de 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -118,8 +118,9 @@ an endpoint, so the definition of done below stays open. - **Moderation actions go through `ModerationAction.fromJson`,** so legacy names read as current ones. - **Attachments map at parity with `Attachment.fromJson`:** each gets a new local `id`, and the id the attachment was sent with stays in `extraData`; `giphy` and `fields` are written in their v1 JSON - shape, which `GiphyInfo` and the UI read. Making `Attachment` plain, and promoting those fields, is - left to this group. + shape, which `GiphyInfo` and the UI read. The Giphy renditions are promoted like `Message.html`: a + `giphy` constructor argument stored in `extraData`, read back through `Attachment.giphy`, a map in + that shape. Making `Attachment` plain, and typing the renditions and `fields`, is left to this group. - **A reminder or location nests its message one level deep,** so the recursion ends there. - **`MessageWithChannelResponse` gets its mapper with its first consumer** (getMessage, search), not ahead of it. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 3b89f30c66..ee5b00ab5b 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -744,8 +744,9 @@ def match(verb, path): - **Moderation actions go through `ModerationAction.fromJson`,** so legacy names read as current ones. - **Attachments map at parity with `Attachment.fromJson`:** each gets a new local `id`, and the id the attachment was sent with stays in `extraData`; `giphy` and `fields` are written in their v1 JSON - shape, which `GiphyInfo` and the UI read. Making `Attachment` plain, and promoting those fields, is - left to this group. + shape, which `GiphyInfo` and the UI read. The Giphy renditions are promoted like `Message.html`: a + `giphy` constructor argument stored in `extraData`, read back through `Attachment.giphy`, a map in + that shape. Making `Attachment` plain, and typing the renditions and `fields`, is left to this group. - **A reminder or location nests its message one level deep,** so the recursion ends there. - **`MessageWithChannelResponse` gets its mapper with its first consumer** (getMessage, search), not ahead of it. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 8792697ff2..f8d6bad4a1 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -172,6 +172,7 @@ - Added `ChannelModel.truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires`, `deletedAt` and `topLevelFields`; the constructors accept the new fields. - Added `User.deactivatedAt`, `deletedAt` and `shadowBanned`; the constructors accept the new fields. - Added `Message.html`, `mml`, `imageLabels` and `deletedReplyCount`, read from the message's `extraData`; the constructor accepts them. +- Added `Attachment.giphy`, the Giphy renditions read from the attachment's `extraData`; the constructor accepts them. 🔒 Security diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index 0960af959b..876e577825 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -4,6 +4,7 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:uuid/uuid.dart'; +import '../util/extension.dart'; import '../util/serializer.dart'; import 'action.dart'; import 'attachment_file.dart'; @@ -16,6 +17,8 @@ part 'attachment.g.dart'; @JsonSerializable(includeIfNull: false) class Attachment extends Equatable { /// Constructor used for json serialization + /// + /// The [giphy] argument, when given, is stored in [extraData], replacing the entry it reads. Attachment({ String? id, String? type, @@ -41,6 +44,7 @@ class Attachment extends Equatable { Map extraData = const {}, this.file, this.uploadState = const UploadState.preparing(), + Map? giphy, }) : id = id ?? const Uuid().v4(), _type = switch (type) { String() => AttachmentType(type), @@ -54,6 +58,7 @@ class Attachment extends Equatable { ...extraData, if (file?.size != null) 'file_size': file?.size, if (file?.mediaType != null) 'mime_type': file?.mediaType?.mimeType, + 'giphy': ?giphy, }; /// Create a new instance from a json @@ -179,6 +184,12 @@ class Attachment extends Equatable { @JsonKey(includeToJson: false, includeFromJson: false) String? get mimeType => extraData['mime_type'] as String?; + /// The renditions of this Giphy attachment, keyed by name, such as `original` or `fixed_height`. + /// + /// Each rendition holds its `url`, `width`, `height`, `size` and `frames`, as strings. + @JsonKey(includeToJson: false, includeFromJson: false) + Map? get giphy => extraData['giphy'].safeCast>(); + /// Known top level fields. /// Useful for [Serializer] methods. static const topLevelFields = [ diff --git a/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart index 6fb3666e81..98df87a5fc 100644 --- a/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart @@ -19,8 +19,8 @@ extension AttachmentMapper on api.Attachment { /// Converts this attachment into an [Attachment]. /// /// The attachment gets a new local [Attachment.id]; an id it was sent with is kept in [Attachment.extraData], beside - /// the custom data and the Giphy renditions, with custom data named like one of the attachment's own fields left - /// out. + /// the custom data and the [Attachment.giphy] renditions, with custom data named like one of the attachment's own + /// fields left out. Attachment toModel() => Attachment( type: type, titleLink: titleLink, @@ -42,10 +42,8 @@ extension AttachmentMapper on api.Attachment { actions: actions?.map((action) => action.toModel()).toList() ?? const [], originalWidth: originalWidth, originalHeight: originalHeight, - extraData: { - ...{...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), - if (giphy case final giphy?) 'giphy': giphy.toJson(), - }, + extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + giphy: giphy?.toJson(), uploadState: const UploadState.success(), ); } diff --git a/packages/stream_chat/test/src/client/message_fixtures.dart b/packages/stream_chat/test/src/client/message_fixtures.dart index aa78d0d916..a3a5750aeb 100644 --- a/packages/stream_chat/test/src/client/message_fixtures.dart +++ b/packages/stream_chat/test/src/client/message_fixtures.dart @@ -314,27 +314,24 @@ Attachment _expectedAttachment(String id) => Attachment( actions: const [Action(name: 'answer', style: 'primary', text: 'Send', type: 'button', value: 'yes')], originalWidth: 400, originalHeight: 300, - extraData: { - 'id': 'attachment-id', - 'caption': 'A cat', - 'giphy': { - for (final rendition in const [ - 'fixed_height', - 'fixed_height_downsampled', - 'fixed_height_still', - 'fixed_width', - 'fixed_width_downsampled', - 'fixed_width_still', - 'original', - ]) - rendition: { - 'frames': '12', - 'height': '200', - 'size': '1024', - 'url': 'https://example.com/$rendition.gif', - 'width': '300', - }, - }, + extraData: const {'id': 'attachment-id', 'caption': 'A cat'}, + giphy: { + for (final rendition in const [ + 'fixed_height', + 'fixed_height_downsampled', + 'fixed_height_still', + 'fixed_width', + 'fixed_width_downsampled', + 'fixed_width_still', + 'original', + ]) + rendition: { + 'frames': '12', + 'height': '200', + 'size': '1024', + 'url': 'https://example.com/$rendition.gif', + 'width': '300', + }, }, uploadState: const UploadState.success(), ); diff --git a/packages/stream_chat/test/src/core/models/attachment_test.dart b/packages/stream_chat/test/src/core/models/attachment_test.dart index 4417ef1173..4b37bfad3d 100644 --- a/packages/stream_chat/test/src/core/models/attachment_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:stream_chat/src/core/models/action.dart'; import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; @@ -163,4 +165,55 @@ void main() { }); }); }); + + test('Attachment stores the Giphy renditions it is given in extraData', () { + final attachment = Attachment( + extraData: const {'caption': 'A cat'}, + giphy: const { + 'original': {'url': 'https://example.com/original.gif'}, + }, + ); + + expect(attachment.extraData, { + 'caption': 'A cat', + 'giphy': { + 'original': {'url': 'https://example.com/original.gif'}, + }, + }); + }); + + test('Attachment replaces the extraData entry of the Giphy renditions it is given', () { + final attachment = Attachment( + extraData: const { + 'giphy': { + 'original': {'url': 'https://example.com/old.gif'}, + }, + }, + giphy: const { + 'original': {'url': 'https://example.com/new.gif'}, + }, + ); + + expect(attachment.extraData, { + 'giphy': { + 'original': {'url': 'https://example.com/new.gif'}, + }, + }); + }); + + test('Attachment.fromJson reads the Giphy renditions from extraData', () { + // Decoded JSON, as events and the offline cache deliver it. + final attachment = Attachment.fromJson( + jsonDecode('{"type": "giphy", "giphy": {"original": {"url": "https://example.com/original.gif"}}}') + as Map, + ); + + expect(attachment.giphy, { + 'original': {'url': 'https://example.com/original.gif'}, + }); + }); + + test('Attachment reads no Giphy renditions when extraData has none', () { + expect(Attachment().giphy, isNull); + }); } From e627c92bad90a26f6a4c7bba95e4b23d9f7ca2c1 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 16:46:29 +0200 Subject: [PATCH 08/14] refactor(llc): name the message mapper files consistently and plan the custom data revisit Co-Authored-By: Claude Opus 5.5 --- openapi-migration/04-roles-guest-and-app.md | 2 +- openapi-migration/09-users.md | 4 +- openapi-migration/10-messages.md | 7 +-- openapi-migration/11-channels-and-members.md | 9 ++-- openapi-migration/15-partial-updates.md | 2 +- openapi-migration/16-channel-lifecycle.md | 2 +- openapi-migration/19-user-blocking.md | 2 +- openapi-migration/21-custom-data-rename.md | 13 +++++ openapi-migration/README.md | 12 ++--- openapi-migration/tool/generate_plan.py | 52 ++++++++++++++----- ...nt_mapper.dart => attachments_mapper.dart} | 0 .../src/repository/mapper/drafts_mapper.dart | 4 +- ...tion_mapper.dart => locations_mapper.dart} | 2 +- ...ssage_mapper.dart => messages_mapper.dart} | 22 ++++++-- .../repository/mapper/moderation_mapper.dart | 17 ------ ...tion_mapper.dart => reactions_mapper.dart} | 5 +- .../repository/mapper/reminders_mapper.dart | 2 +- 17 files changed, 99 insertions(+), 58 deletions(-) rename packages/stream_chat/lib/src/repository/mapper/{attachment_mapper.dart => attachments_mapper.dart} (100%) rename packages/stream_chat/lib/src/repository/mapper/{location_mapper.dart => locations_mapper.dart} (95%) rename packages/stream_chat/lib/src/repository/mapper/{message_mapper.dart => messages_mapper.dart} (82%) rename packages/stream_chat/lib/src/repository/mapper/{reaction_mapper.dart => reactions_mapper.dart} (84%) diff --git a/openapi-migration/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index 8753aa331e..d48028d7ec 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -100,7 +100,7 @@ non-date value). v10's flat response shadowed them, so the same guest connected; confirmed live. - **Not a regression: a custom `ban_expires` that isn't a date** fails the connect in v10 and now alike, because the socket's `me` lets it through into `OwnUser.fromJson`. -- **`User` is not restructured here.** `user_mapper.dart` maps the generated types onto today's `User`, +- **`User` is not restructured here.** `users_mapper.dart` maps the generated types onto today's `User`, following [01-foundation](01-foundation.md). ## Risks diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 48fb9c651b..a786359c14 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -27,7 +27,7 @@ ## Decisions to make -- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md). +- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `users_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md). - `PrivacySettings` and the push-preference sub-shapes — decide per type. - `UserResponse.toModel()` and `FullUserResponse.toModel()` drop custom data named like one of the user's own fields (`_shadowedCustomKeys`): `deactivated_at`, `deleted_at` and `shadow_banned` are refilled from the typed fields, and the `OwnUser`-only keys and `revoke_tokens_issued_before` are left out, where v1 kept them in a plain user's `extraData`. Revisit once the mapper serves plain users. - **Decide `User`, `FullUserResponse` and `OwnUser` together, here:** which private fields a plain user exposes, whether the caller's own entry maps to `OwnUser`, and what is kept in `extraData` once `User` drops `fromJson` and `toJson`. One input, verified live while migrating [06](06-reminders.md): `blocked_user_ids`, which v1 left in every nested user's `extraData`, arrives as `[]` on every user nested in a message or reminder, on v1 and v2 alike and even for the caller with real blocks; only connect and `queryUsers` return the caller's real list. Writing it back into `extraData` in `UserResponse.toModel()` restores v10 parity for nested users at no information cost, but changes every group's mapped users, so it was deferred to this decision. @@ -63,6 +63,6 @@ name states its subject and behaviour. - [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from the table in `README.md`. -- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is +- [ ] `users_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is gone. - [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal. diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 9802abb5de..307f4d3880 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -96,11 +96,11 @@ an endpoint, so the definition of done below stays open. keeps v10's request shape: coordinates, device and end date), `ChannelState.activeLiveLocations`, `GetActiveLiveLocationsResponse` and `updateLiveLocation`'s response. `stream_chat_persistence` stores locations as table rows, so no codec is needed. -- **`message_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through +- **`messages_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through its constructor, with the generated attachment, reaction, reaction group, moderation, shared location, reminder, draft and draft payload types, each mapped in - its own file (`attachment_mapper.dart`, `reaction_mapper.dart`, `moderation_mapper.dart`, - `location_mapper.dart`, `drafts_mapper.dart`, `reminders_mapper.dart`). Its first + its own file (`attachments_mapper.dart`, `reactions_mapper.dart`, `locations_mapper.dart`, + `drafts_mapper.dart`, `reminders_mapper.dart`), the moderation beside the message itself. Its first consumer is [06](06-reminders.md). `Message.fromJson` and the mapper share the type and state derivation and the reaction groups built from counts and scores when a payload has none (`lib/src/core/util/message_decoding.dart`, internal). @@ -129,6 +129,7 @@ an endpoint, so the definition of done below stays open. - `message_api.dart` also holds the four draft methods, which belong to group 07 — leave them alone here. - Attachment `custom`/`extraData` promotion is the known hard part of the whole migration. +- Requests that send custom data drop the keys named like one of the model's own fields, as every request mapper does until [21](21-custom-data-rename.md) revisits it. - Message send has offline and retry paths through `stream_chat_persistence` that must keep working. - `MessageDeleteScope` has to be reconciled with `DeleteType`, which [08](08-moderation-and-blocklists.md) added. It is named for the scope of a delete — `deleteForMe` vs `deleteForAll` — but carries a `hard` bool, which is the same axis `DeleteType` models, in the same words, minus `pruning`. `deleteMessage(hard: true)` therefore cannot express a pruning delete at all, and `softDeleteForAll` / `hardDeleteForAll` read as two spellings of `DeleteType.soft` / `DeleteType.hard`. Decide whether the scope keeps a `DeleteType` field or the two stay separate arguments; either way the public type changes, so it belongs in this group rather than a later fix. diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index d83dd13acc..f8ccd654ce 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -60,7 +60,7 @@ - **The generated channel and member types map onto today's classes.** `lib/src/repository/mapper/channel_mapper.dart` maps `ChannelResponse`, `ChannelConfigWithInfo`, `ChannelMemberResponse`, `Command` and `ChatPreferences` onto the json_serializable `ChannelModel`, - `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_mapper.dart` does for + `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `users_mapper.dart` does for `User`. [15](15-partial-updates.md) uses it first; groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours.** The @@ -75,10 +75,10 @@ `createdAt` / `updatedAt`, which ours do not model. - **The mapper writes the server fields where v1 JSON puts them,** into `extraData` under their wire keys. Custom data named like one of those fields or a top-level field is dropped, as - `user_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, + `users_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, all for group 09 or this group to settle: dates in `extraData` are formatted by the client rather than kept as the server sent them (see the risk below); `truncatedBy`, `createdBy` and each member's - user go through `user_mapper.dart`, which drops the user fields `User` does not model; and + user go through `users_mapper.dart`, which drops the user fields `User` does not model; and `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if the server sent them on the channel. - **The mapper is tested through the client,** by [15](15-partial-updates.md)'s @@ -89,9 +89,10 @@ - `sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches into that file. - `queryChannels` drives the channel list controllers and the offline cache; a shape change here is felt everywhere. - Channel `custom`/`extraData` promotion, same class of problem as messages. +- Requests that send custom data drop the keys named like one of the model's own fields, as every request mapper does until [21](21-custom-data-rename.md) revisits it. - **A zero timestamp decodes differently on v2.** v1 sends Go's zero time as `0001-01-01T00:00:00Z`; v2 encodes timestamps as epoch nanoseconds and sends `0`, which decodes as 1970-01-01. A truncated channel shows it: its `last_message_at` is zero time, so the same channel answers year 1 from a v1 query or WebSocket event and 1970 from `/api/v2/chat/channels`. Nothing in the SDK behaves differently today, since both precede `createdAt` (`lastUpdatedAt` falls back to it, and the read fallback and the `max` against new messages agree), but the two sources disagree on the value. Normalize it deliberately when `queryChannels` moves, and check the other timestamps the server can send as zero. - **Dates the mapper writes into `extraData` are formatted differently from v1.** v1 keeps the server's string (`2020-01-29T03:22:47.63613Z`); the mapper writes `toIso8601String()` (`2020-01-29T03:22:47.636130Z`). The getters return the same `DateTime`, but `Member` compares `extraData`, so the same member from a v1 payload and from the mapper compares unequal. Promoting the fields removes it. -- [08](08-moderation-and-blocklists.md)'s `muteChannel` drops `channelMute`, `channelMutes` and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. `channel_mapper.dart` covers the channel side now; it needs 09 as well. Decide the null case there too — ours are non-nullable, the generated ones are not, the same question [14](14-banned-users.md) records for `BanResponse.user`. +- [08](08-moderation-and-blocklists.md)'s `muteChannel` drops `channelMute`, `channelMutes` and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. `channels_mapper.dart` covers the channel side now; it needs 09 as well. Decide the null case there too — ours are non-nullable, the generated ones are not, the same question [14](14-banned-users.md) records for `BanResponse.user`. ## Definition of done diff --git a/openapi-migration/15-partial-updates.md b/openapi-migration/15-partial-updates.md index bd50877d33..279fa34f94 100644 --- a/openapi-migration/15-partial-updates.md +++ b/openapi-migration/15-partial-updates.md @@ -25,7 +25,7 @@ - **Split out of [11](11-channels-and-members.md), ahead of it.** The partial updates read nothing into client state — the `channel.updated` and `member.updated` events do that — so they can move - before the queries and prove `channel_mapper.dart` on real calls. + before the queries and prove `channels_mapper.dart` on real calls. - **The full update stays in 11, so the pair is split on purpose.** `updateChannel` takes and answers a `Message`, and the generated `UpdateChannelRequest.message` / `UpdateChannelResponse.message` need the `MessageRequest` and `MessageResponse` mappers [10](10-messages.md) writes; its `data` is diff --git a/openapi-migration/16-channel-lifecycle.md b/openapi-migration/16-channel-lifecycle.md index b15c5ff40b..2398840213 100644 --- a/openapi-migration/16-channel-lifecycle.md +++ b/openapi-migration/16-channel-lifecycle.md @@ -26,7 +26,7 @@ - **Split out of [11](11-channels-and-members.md), ahead of it.** None of the three reads anything into client state — the `channel.hidden`, `channel.visible` and `channel.deleted` events do that — - and the one channel they answer goes through `channel_mapper.dart`. + and the one channel they answer goes through `channels_mapper.dart`. - **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts hide, show and delete in the shared `coreRoutes`; none is gated, in beta or deprecated. - **Moved off `ChannelApi`:** `hideChannel`, `showChannel` and `deleteChannel`, each now a diff --git a/openapi-migration/19-user-blocking.md b/openapi-migration/19-user-blocking.md index b86badf2fe..1448edaa1f 100644 --- a/openapi-migration/19-user-blocking.md +++ b/openapi-migration/19-user-blocking.md @@ -1,6 +1,6 @@ # 19 — User Blocking -**Goal:** Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they embed `User` only through the existing `user_mapper.dart`, and nothing persists them. +**Goal:** Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they embed `User` only through the existing `users_mapper.dart`, and nothing persists them. **Size:** 0 hand-written method(s) across 0 file(s) → 3 generated operation(s). diff --git a/openapi-migration/21-custom-data-rename.md b/openapi-migration/21-custom-data-rename.md index 4e65cf4de4..32cff1e35c 100644 --- a/openapi-migration/21-custom-data-rename.md +++ b/openapi-migration/21-custom-data-rename.md @@ -23,6 +23,16 @@ - **Whether to rename at all.** Every model names its custom data `extraData` today; the generated client calls it `custom`. Renaming one model at a time would leave the SDK inconsistent, so the groups keep `extraData` and this step decides for every model together. - **How to stage it.** Add `custom` beside a deprecated `extraData`, or rename in one break. +- **How a request treats custom data named like one of the model's own fields.** Every request mapper drops it today, for consistency, not because it matches v10. v10 flattened `extraData` into the body after the fields, so a key named like a field set that field, failed with a 400 for a wrong type or a reserved name, was dropped, or was stored as custom data, depending on the key and the endpoint. v2 never rejects it: it stores it as custom data, drops it, or lets it override the field (a draft's `id`, `text`, `type`, `html` and `mml`, an attachment's single-word string fields, a user's `image`). Measured live on drafts, attachments, polls, poll options and users. The options: + 1. Drop it (today). Simple, but silently loses what v10 stored as custom data or used to set a field. + 2. Send everything as custom data. Nothing is dropped, but a key v10 set a field with becomes custom data, and v2 overrides some fields with it. + 3. Reproduce v10: a key named like a request field sets that field, over the model's value; the rest is sent as custom data. + 4. Like 3, but the model's value wins: the key only fills a field the model leaves unset. + 5. Drop it, and log the dropped keys in debug builds. + 6. Assert in debug builds that `extraData` holds no key named like a field. + 7. Keep such keys out of `extraData` when a model is built, so no mapper sees them. + 8. Ask the backend to reject or strip such custom keys consistently; v2 overriding a field with custom data looks like a server bug. + 9. Settle it as part of this rename's break. ## Risks @@ -31,6 +41,9 @@ ## Definition of done +- [ ] Re-run the live probe of custom data named like a model field for every request that sends custom + data: each key flattened over v1 and nested in `custom` over v2, comparing what the server stores. +- [ ] One rule for custom data named like a model field, applied by every request mapper. - [ ] Every public model names its custom data the same way. - [ ] The mappers and the v1 decoders read and write the renamed field. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 649d3ca83e..b53a732f26 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -165,9 +165,9 @@ includes a named response that carries only `duration` today, such as `HideChann | `DataSerializable` | `UserGroup`, `UserGroupMember`, `ReactionGroup`, `PollOption` (`fromData`, `toData`) | [10](10-messages.md) | | `PollV1JsonConverter` | `Message.poll`, `DraftMessage.poll`, `Event.poll` | WebSocket v2 (no group) | | `PollVoteV1JsonConverter` | `Event.pollVote` | WebSocket v2 (no group) | -| `user_mapper.dart` (kept, re-pointed) | today's `User`, which still reads and writes JSON | [09](09-users.md) | -| `channel_mapper.dart` (kept, re-pointed) | today's `ChannelModel`, `ChannelConfig` and `Member`, which still read and write JSON | [11](11-channels-and-members.md) | -| `message_mapper.dart`, `attachment_mapper.dart`, `reaction_mapper.dart`, `location_mapper.dart`, `drafts_mapper.dart` (kept, re-pointed) | today's `Message`, `Attachment`, `Reaction`, `Location`, `Draft` and `DraftMessage`, which still read and write JSON | [10](10-messages.md) | +| `users_mapper.dart` (kept, re-pointed) | today's `User`, which still reads and writes JSON | [09](09-users.md) | +| `channels_mapper.dart` (kept, re-pointed) | today's `ChannelModel`, `ChannelConfig` and `Member`, which still read and write JSON | [11](11-channels-and-members.md) | +| `messages_mapper.dart`, `attachments_mapper.dart`, `reactions_mapper.dart`, `locations_mapper.dart`, `drafts_mapper.dart` (kept, re-pointed) | today's `Message`, `Attachment`, `Reaction`, `Location`, `Draft` and `DraftMessage`, which still read and write JSON | [10](10-messages.md) | How v1 JSON decodes `User` once it becomes a plain model is decided in [01-foundation](01-foundation.md): until group 09 restructures it, v1 payloads keep decoding through `User.fromJson`. @@ -187,10 +187,10 @@ surfaces before it reaches `Message` and `ChannelState`: - **14** is `queryBannedUsers`, split out of 08 because it is the only moderation call that answers with a model. The `User` mappers it needs landed with group 04. - **10–11** are the core of the SDK, and carry the `custom` / `extraData` promotion problem. -- **15** is the partial channel and member updates, split out of 11 so `channel_mapper.dart` lands on calls that +- **15** is the partial channel and member updates, split out of 11 so `channels_mapper.dart` lands on calls that read nothing into `ChannelState`. It landed first; the full update waits in 11 for group 10's message mappers. - **16** is hiding, showing and deleting a channel, split out of 11 after 15 because they answer nothing - `channel_mapper.dart` cannot map. + `channels_mapper.dart` cannot map. - **17** is the read and delivery receipts, split out of 11 after 16. They answer only a `duration` and a read event, which waits for group 10's message mappers. - **18** is the current user's unread counts, split out of 09 because it embeds no `User` and nothing persists it. @@ -201,7 +201,7 @@ surfaces before it reaches `Message` and `ChannelState`: `User` as getters over `extraData`. - **12** comes late because it needs its own hand-written multipart client and is the highest-traffic path in the SDK. -- **09** is last. Every group before it maps users through `user_mapper.dart` onto today's `User`; 09 migrates the +- **09** is last. Every group before it maps users through `users_mapper.dart` onto today's `User`; 09 migrates the user endpoints and restructures `User` and `OwnUser` themselves, when every parent that embeds them has moved. - **21** renames `extraData` to `custom` on every model at once. The groups keep `extraData`, so the SDK stays consistent until then; it runs after 09. diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 90351727fc..89f6dec520 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -313,7 +313,7 @@ def match(verb, path): non-date value). v10's flat response shadowed them, so the same guest connected; confirmed live. - **Not a regression: a custom `ban_expires` that isn't a date** fails the connect in v10 and now alike, because the socket's `me` lets it through into `OwnUser.fromJson`. - - **`User` is not restructured here.** `user_mapper.dart` maps the generated types onto today's `User`, + - **`User` is not restructured here.** `users_mapper.dart` maps the generated types onto today's `User`, following [01-foundation](01-foundation.md). """), done=DONE.replace('- [ ]', '- [x]'), @@ -600,7 +600,7 @@ def match(verb, path): '[19](19-user-blocking.md) blocking users, and [20](20-user-updates.md) updating users.', decisions=[ '`User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group ' - 'restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the ' + 'restructures them, last: the mappers in `users_mapper.dart` already map the generated types onto the ' 'current class for every group before it, and stay. See [01-foundation](01-foundation.md).', '`PrivacySettings` and the push-preference sub-shapes — decide per type.', '`UserResponse.toModel()` and `FullUserResponse.toModel()` drop custom data named like one of the user\'s ' @@ -651,7 +651,7 @@ def match(verb, path): done=DONE + ( '- [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from\n' ' the table in `README.md`.\n' - '- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' + '- [ ] `users_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' ' gone.\n' '- [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal.\n' ), @@ -678,6 +678,8 @@ def match(verb, path): '`message_api.dart` also holds the four draft methods, which belong to group 07 — leave them alone ' 'here.', 'Attachment `custom`/`extraData` promotion is the known hard part of the whole migration.', + 'Requests that send custom data drop the keys named like one of the model\'s own fields, as every ' + 'request mapper does until [21](21-custom-data-rename.md) revisits it.', 'Message send has offline and retry paths through `stream_chat_persistence` that must keep working.', '`MessageDeleteScope` has to be reconciled with `DeleteType`, which [08](08-moderation-and-blocklists.md) added. It is named for the scope of a delete — `deleteForMe` vs `deleteForAll` — but carries a `hard` bool, which is the same axis `DeleteType` models, in the same words, minus `pruning`. `deleteMessage(hard: true)` therefore cannot express a pruning delete at all, and `softDeleteForAll` / `hardDeleteForAll` read as two spellings of `DeleteType.soft` / `DeleteType.hard`. Decide whether the scope keeps a `DeleteType` field or the two stay separate arguments; either way the public type changes, so it belongs in this group rather than a later fix.', ], @@ -724,11 +726,11 @@ def match(verb, path): keeps v10's request shape: coordinates, device and end date), `ChannelState.activeLiveLocations`, `GetActiveLiveLocationsResponse` and `updateLiveLocation`'s response. `stream_chat_persistence` stores locations as table rows, so no codec is needed. - - **`message_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through + - **`messages_mapper.dart` maps `MessageResponse` onto today's json_serializable `Message`** through its constructor, with the generated attachment, reaction, reaction group, moderation, shared location, reminder, draft and draft payload types, each mapped in - its own file (`attachment_mapper.dart`, `reaction_mapper.dart`, `moderation_mapper.dart`, - `location_mapper.dart`, `drafts_mapper.dart`, `reminders_mapper.dart`). Its first + its own file (`attachments_mapper.dart`, `reactions_mapper.dart`, `locations_mapper.dart`, + `drafts_mapper.dart`, `reminders_mapper.dart`), the moderation beside the message itself. Its first consumer is [06](06-reminders.md). `Message.fromJson` and the mapper share the type and state derivation and the reaction groups built from counts and scores when a payload has none (`lib/src/core/util/message_decoding.dart`, internal). @@ -811,6 +813,8 @@ def match(verb, path): '`queryChannels` drives the channel list controllers and the offline cache; a shape change here is ' 'felt everywhere.', 'Channel `custom`/`extraData` promotion, same class of problem as messages.', + 'Requests that send custom data drop the keys named like one of the model\'s own fields, as every ' + 'request mapper does until [21](21-custom-data-rename.md) revisits it.', '**A zero timestamp decodes differently on v2.** v1 sends Go\'s zero time as ' '`0001-01-01T00:00:00Z`; v2 encodes timestamps as epoch nanoseconds and sends `0`, which decodes as ' '1970-01-01. A truncated channel shows it: its `last_message_at` is zero time, so the same channel ' @@ -826,7 +830,7 @@ def match(verb, path): 'the fields removes it.', '[08](08-moderation-and-blocklists.md)\'s `muteChannel` drops `channelMute`, `channelMutes` and ' '`ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` ' - 'where ours needs a non-nullable `ChannelModel` and `User`. `channel_mapper.dart` covers the ' + 'where ours needs a non-nullable `ChannelModel` and `User`. `channels_mapper.dart` covers the ' 'channel side now; it needs 09 as well. ' 'Decide the null case there too — ours are non-nullable, the generated ones are not, the same ' 'question [14](14-banned-users.md) records for `BanResponse.user`.', @@ -835,7 +839,7 @@ def match(verb, path): - **The generated channel and member types map onto today's classes.** `lib/src/repository/mapper/channel_mapper.dart` maps `ChannelResponse`, `ChannelConfigWithInfo`, `ChannelMemberResponse`, `Command` and `ChatPreferences` onto the json_serializable `ChannelModel`, - `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_mapper.dart` does for + `ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `users_mapper.dart` does for `User`. [15](15-partial-updates.md) uses it first; groups 06, 07 and 14 will reuse it. This group restructures those classes and re-points the mapper. - **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours.** The @@ -850,10 +854,10 @@ def match(verb, path): `createdAt` / `updatedAt`, which ours do not model. - **The mapper writes the server fields where v1 JSON puts them,** into `extraData` under their wire keys. Custom data named like one of those fields or a top-level field is dropped, as - `user_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, + `users_mapper.dart` does. A mapped channel or member still differs from a v1 decode in three ways, all for group 09 or this group to settle: dates in `extraData` are formatted by the client rather than kept as the server sent them (see the risk below); `truncatedBy`, `createdBy` and each member's - user go through `user_mapper.dart`, which drops the user fields `User` does not model; and + user go through `users_mapper.dart`, which drops the user fields `User` does not model; and `mute_expires_at` and `hide_messages_before` are left out, where v1 would keep them in `extraData` if the server sent them on the channel. - **The mapper is tested through the client,** by [15](15-partial-updates.md)'s @@ -971,7 +975,7 @@ def match(verb, path): taken=textwrap.dedent("""\ - **Split out of [11](11-channels-and-members.md), ahead of it.** The partial updates read nothing into client state — the `channel.updated` and `member.updated` events do that — so they can move - before the queries and prove `channel_mapper.dart` on real calls. + before the queries and prove `channels_mapper.dart` on real calls. - **The full update stays in 11, so the pair is split on purpose.** `updateChannel` takes and answers a `Message`, and the generated `UpdateChannelRequest.message` / `UpdateChannelResponse.message` need the `MessageRequest` and `MessageResponse` mappers [10](10-messages.md) writes; its `data` is @@ -1029,7 +1033,7 @@ def match(verb, path): taken=textwrap.dedent("""\ - **Split out of [11](11-channels-and-members.md), ahead of it.** None of the three reads anything into client state — the `channel.hidden`, `channel.visible` and `channel.deleted` events do that — - and the one channel they answer goes through `channel_mapper.dart`. + and the one channel they answer goes through `channels_mapper.dart`. - **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts hide, show and delete in the shared `coreRoutes`; none is gated, in beta or deprecated. - **Moved off `ChannelApi`:** `hideChannel`, `showChannel` and `deleteChannel`, each now a @@ -1122,7 +1126,7 @@ def match(verb, path): hand=[], match=only_ops('POST /api/v2/users/block', 'GET /api/v2/users/block', 'POST /api/v2/users/unblock'), goal='Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they ' - 'embed `User` only through the existing `user_mapper.dart`, and nothing persists them.', + 'embed `User` only through the existing `users_mapper.dart`, and nothing persists them.', decisions=[], taken=textwrap.dedent("""\ - **Split out of [09](09-users.md), ahead of it, as one slice.** Blocking and unblocking are a pair, @@ -1201,8 +1205,30 @@ def match(verb, path): 'client calls it `custom`. Renaming one model at a time would leave the SDK inconsistent, so the ' 'groups keep `extraData` and this step decides for every model together.', '**How to stage it.** Add `custom` beside a deprecated `extraData`, or rename in one break.', + '**How a request treats custom data named like one of the model\'s own fields.** Every request mapper ' + 'drops it today, for consistency, not because it matches v10. v10 flattened `extraData` into the body ' + 'after the fields, so a key named like a field set that field, failed with a 400 for a wrong type or a ' + 'reserved name, was dropped, or was stored as custom data, depending on the key and the endpoint. v2 ' + 'never rejects it: it stores it as custom data, drops it, or lets it override the field (a draft\'s ' + '`id`, `text`, `type`, `html` and `mml`, an attachment\'s single-word string fields, a user\'s `image`). ' + 'Measured live on drafts, attachments, polls, poll options and users. The options:\n' + ' 1. Drop it (today). Simple, but silently loses what v10 stored as custom data or used to set a field.\n' + ' 2. Send everything as custom data. Nothing is dropped, but a key v10 set a field with becomes custom ' + 'data, and v2 overrides some fields with it.\n' + ' 3. Reproduce v10: a key named like a request field sets that field, over the model\'s value; the rest ' + 'is sent as custom data.\n' + ' 4. Like 3, but the model\'s value wins: the key only fills a field the model leaves unset.\n' + ' 5. Drop it, and log the dropped keys in debug builds.\n' + ' 6. Assert in debug builds that `extraData` holds no key named like a field.\n' + ' 7. Keep such keys out of `extraData` when a model is built, so no mapper sees them.\n' + ' 8. Ask the backend to reject or strip such custom keys consistently; v2 overriding a field with ' + 'custom data looks like a server bug.\n' + ' 9. Settle it as part of this rename\'s break.', ], done=textwrap.dedent("""\ + - [ ] Re-run the live probe of custom data named like a model field for every request that sends custom + data: each key flattened over v1 and nested in `custom` over v2, comparing what the server stores. + - [ ] One rule for custom data named like a model field, applied by every request mapper. - [ ] Every public model names its custom data the same way. - [ ] The mappers and the v1 decoders read and write the renamed field. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section. diff --git a/packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart similarity index 100% rename from packages/stream_chat/lib/src/repository/mapper/attachment_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart diff --git a/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart index 93bbf4d33e..79afbc1ff2 100644 --- a/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart @@ -2,9 +2,9 @@ import '../../../open_api/api.dart' as api; import '../../core/models/draft.dart'; import '../../core/models/draft_message.dart'; import '../../core/models/message.dart'; -import 'attachment_mapper.dart'; +import 'attachments_mapper.dart'; import 'channels_mapper.dart'; -import 'message_mapper.dart'; +import 'messages_mapper.dart'; import 'users_mapper.dart'; // TODO(openapi-migration): re-point these mappers in group 10. diff --git a/packages/stream_chat/lib/src/repository/mapper/location_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/locations_mapper.dart similarity index 95% rename from packages/stream_chat/lib/src/repository/mapper/location_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/locations_mapper.dart index f2e1764708..2cf708ad38 100644 --- a/packages/stream_chat/lib/src/repository/mapper/location_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/locations_mapper.dart @@ -1,7 +1,7 @@ import '../../../open_api/api.dart' as api; import '../../core/models/location.dart'; import 'channels_mapper.dart'; -import 'message_mapper.dart'; +import 'messages_mapper.dart'; // TODO(openapi-migration): re-point this mapper in group 10. diff --git a/packages/stream_chat/lib/src/repository/mapper/message_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart similarity index 82% rename from packages/stream_chat/lib/src/repository/mapper/message_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart index 92c8eeb096..811d4f2054 100644 --- a/packages/stream_chat/lib/src/repository/mapper/message_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart @@ -1,12 +1,12 @@ import '../../../open_api/api.dart' as api; import '../../core/models/message.dart'; +import '../../core/models/moderation.dart'; import '../../core/util/message_decoding.dart'; -import 'attachment_mapper.dart'; +import 'attachments_mapper.dart'; import 'drafts_mapper.dart'; -import 'location_mapper.dart'; -import 'moderation_mapper.dart'; +import 'locations_mapper.dart'; import 'polls_mapper.dart'; -import 'reaction_mapper.dart'; +import 'reactions_mapper.dart'; import 'reminders_mapper.dart'; import 'user_groups_mapper.dart'; import 'users_mapper.dart'; @@ -87,3 +87,17 @@ extension MessageResponseMapper on api.MessageResponse { deletedReplyCount: deletedReplyCount, ).withDerivedState(); } + +/// Maps a generated [api.ModerationV2Response] to a [Moderation]. +extension ModerationV2ResponseMapper on api.ModerationV2Response { + /// Converts this response into a [Moderation]. + Moderation toModel() => Moderation( + action: ModerationAction.fromJson(action), + originalText: originalText, + textHarms: textHarms, + imageHarms: imageHarms, + blocklistMatched: blocklistMatched, + semanticFilterMatched: semanticFilterMatched, + platformCircumvented: platformCircumvented ?? false, + ); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart index f5180cae26..fd756d53a0 100644 --- a/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/moderation_mapper.dart @@ -1,6 +1,5 @@ import '../../../open_api/api.dart' as api; import '../../core/models/delete_type.dart'; -import '../../core/models/moderation.dart'; import '../../core/models/response/flag_response.dart'; import '../../core/models/response/mute_users_response.dart'; import '../../core/models/response/unmute_users_response.dart'; @@ -37,19 +36,3 @@ extension DeleteTypeRequestMapper on DeleteType { /// Converts this delete type into an [api.BanRequestDeleteMessages]. api.BanRequestDeleteMessages toRequest() => api.BanRequestDeleteMessages.fromJson(rawType); } - -// TODO(openapi-migration): re-point this mapper in group 10. - -/// Maps a generated [api.ModerationV2Response] to a [Moderation]. -extension ModerationV2ResponseMapper on api.ModerationV2Response { - /// Converts this response into a [Moderation]. - Moderation toModel() => Moderation( - action: ModerationAction.fromJson(action), - originalText: originalText, - textHarms: textHarms, - imageHarms: imageHarms, - blocklistMatched: blocklistMatched, - semanticFilterMatched: semanticFilterMatched, - platformCircumvented: platformCircumvented ?? false, - ); -} diff --git a/packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart similarity index 84% rename from packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart rename to packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart index 19c4e8ba2b..d70c70da75 100644 --- a/packages/stream_chat/lib/src/repository/mapper/reaction_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart @@ -8,6 +8,9 @@ import 'users_mapper.dart'; /// Maps a generated [api.ReactionResponse] to a [Reaction]. extension ReactionResponseMapper on api.ReactionResponse { + // Custom keys named like one of the reaction's own fields. + static const _shadowedCustomKeys = {...Reaction.topLevelFields}; + /// Converts this response into a [Reaction]. /// /// Custom data named like one of the reaction's own fields is left out of [Reaction.extraData]. @@ -20,7 +23,7 @@ extension ReactionResponseMapper on api.ReactionResponse { emojiCode: custom['emoji_code'].safeCast(), createdAt: createdAt, updatedAt: updatedAt, - extraData: {...custom}..removeWhere((key, _) => Reaction.topLevelFields.contains(key)), + extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), ); } diff --git a/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart index 2d5e51126f..518e0825f0 100644 --- a/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart @@ -5,7 +5,7 @@ import '../../core/models/response/delete_reminder_response.dart'; import '../../core/models/response/query_reminders_response.dart'; import '../../core/models/response/update_reminder_response.dart'; import 'channels_mapper.dart'; -import 'message_mapper.dart'; +import 'messages_mapper.dart'; import 'users_mapper.dart'; /// Maps a generated [api.CreateReminderResponse] to a [CreateReminderResponse]. From a579078fc1b5903d588ce90357031621ebadbbe3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 19 Oct 2026 10:46:29 +0200 Subject: [PATCH 09/14] refactor(llc, persistence, ui)!: name message custom data custom and give its typed values fields of their own Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 47 + openapi-migration/07-threads-and-drafts.md | 2 +- openapi-migration/10-messages.md | 34 +- openapi-migration/README.md | 10 +- openapi-migration/tool/generate_plan.py | 39 +- packages/stream_chat/CHANGELOG.md | 10 +- .../lib/src/client/channel/channel.dart | 24 +- .../lib/src/core/api/responses.dart | 2 +- .../lib/src/core/models/attachment.dart | 61 +- .../lib/src/core/models/attachment.g.dart | 6 +- .../core/models/attachment_giphy_info.dart | 2 +- .../models/converters/v1_json_converters.dart | 36 +- .../lib/src/core/models/draft_message.dart | 32 +- .../lib/src/core/models/draft_message.g.dart | 4 +- .../lib/src/core/models/message.dart | 112 ++- .../lib/src/core/models/message.g.dart | 8 +- .../lib/src/core/models/reaction.dart | 27 +- .../lib/src/core/models/reaction.freezed.dart | 8 +- .../repository/mapper/attachments_mapper.dart | 16 +- .../src/repository/mapper/drafts_mapper.dart | 16 +- .../repository/mapper/messages_mapper.dart | 26 +- .../repository/mapper/reactions_mapper.dart | 8 +- .../test/src/client/channel/channel_test.dart | 12 +- .../client/client_create_reminder_test.dart | 11 +- .../test/src/client/message_fixtures.dart | 79 +- .../models/attachment_giphy_info_test.dart | 24 +- .../test/src/core/models/attachment_test.dart | 103 +- .../converters/v1_json_converters_test.dart | 37 +- .../src/core/models/draft_message_test.dart | 56 +- .../test/src/core/models/message_test.dart | 142 +-- .../test/src/core/models/reaction_test.dart | 6 +- .../lib/src/attachment/file_attachment.dart | 2 +- .../handler/stream_attachment_handler_io.dart | 2 +- .../message_composer_input_header.dart | 2 +- .../stream_media_gallery_item.dart | 4 +- .../options/stream_gallery_picker.dart | 10 +- .../audio_recorder_controller.dart | 2 +- .../stream_message_composer_attachment.dart | 4 +- .../lib/src/utils/extensions.dart | 12 +- .../attachment/attachment_handler_test.dart | 4 +- .../src/attachment/file_attachment_test.dart | 2 +- .../attachment/gallery_attachment_test.dart | 4 +- .../src/attachment/giphy_attachment_test.dart | 2 +- .../src/attachment/image_attachment_test.dart | 2 +- ...ce_recording_attachment_playlist_test.dart | 4 +- .../stream_message_preview_text_test.dart | 12 +- .../audio_recorder_controller_test.dart | 4 +- ...ssage_composer_content_insertion_test.dart | 6 +- .../test/message_list_core_test.dart | 4 +- .../lib/src/db/drift_chat_database.dart | 2 +- .../lib/src/db/drift_chat_database.g.dart | 908 +++++++++++++----- .../lib/src/entity/draft_messages.dart | 4 +- .../lib/src/entity/messages.dart | 13 +- .../lib/src/entity/reactions.dart | 4 +- .../lib/src/mapper/draft_message_mapper.dart | 4 +- .../lib/src/mapper/message_mapper.dart | 11 +- .../lib/src/mapper/pinned_message_mapper.dart | 11 +- .../pinned_message_reaction_mapper.dart | 4 +- .../lib/src/mapper/reaction_mapper.dart | 4 +- .../test/src/dao/draft_message_dao_test.dart | 2 +- .../test/src/dao/message_dao_test.dart | 8 +- .../test/src/dao/pinned_message_dao_test.dart | 8 +- .../dao/pinned_message_reaction_dao_test.dart | 6 +- .../test/src/dao/reaction_dao_test.dart | 6 +- .../src/mapper/draft_message_mapper_test.dart | 8 +- .../test/src/mapper/message_mapper_test.dart | 21 +- .../mapper/pinned_message_mapper_test.dart | 21 +- .../pinned_message_reaction_mapper_test.dart | 8 +- .../test/src/mapper/reaction_mapper_test.dart | 8 +- 69 files changed, 1319 insertions(+), 824 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index c8bf404da4..8a4e3bbee2 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -44,6 +44,7 @@ onto Stream's OpenAPI-generated API client. - [User Updates](#user-updates) - [Channel and Member Custom Data](#channel-and-member-custom-data) - [User Custom Data](#user-custom-data) + - [Message Custom Data](#message-custom-data) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) - [Contributing to this guide](#contributing-to-this-guide) @@ -100,6 +101,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**User Updates**](#user-updates) | `updateUser` and `updateUsers` return a `Result` instead of throwing; `partialUpdateUser(s)` becomes `updateUserPartial` / `updateUsersPartial` and `PartialUpdateUserRequest` becomes `UpdateUserPartialRequest`; `updateUser` no longer sends `role`, `teams` or `teamsRole`, and the returned users no longer carry their private fields in `extraData` | | [**Channel and Member Custom Data**](#channel-and-member-custom-data) | `extraData` is `custom` on `ChannelModel`, `Member` and `Channel`, and holds the custom data verbatim; the channel and member flags it carried are fields | | [**User Custom Data**](#user-custom-data) | `extraData` is `custom` on `User` and `OwnUser`, and holds the custom data verbatim; `name` is nullable and `nameOrId` falls back to the id; the user fields it carried are fields | +| [**Message Custom Data**](#message-custom-data) | `extraData` is `custom` on `Message`, `Attachment`, `Reaction` and `DraftMessage`, and holds the custom data verbatim; a message's channel id, HTML, markup and deleted reply count, and an attachment's Giphy renditions, are fields | | _(filled in per feature as PRs land)_ | | --- @@ -358,6 +360,13 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `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 | +| `Message.extraData` / `Attachment.extraData` / `Reaction.extraData` / `DraftMessage.extraData` | `Message.custom` / `Attachment.custom` / `Reaction.custom` / `DraftMessage.custom` | `renamed` | The wire's name for it; also the constructor and `copyWith` arguments | +| `Channel.sendStaticLocation(extraData: …)` / `startLiveLocationSharing(extraData: …)` | `sendStaticLocation(custom: …)` / `startLiveLocationSharing(custom: …)` | `renamed` | The message's custom data | +| `message.extraData['cid']` / `['html']` / `['mml']` / `['deleted_reply_count']` | `Message.cid` / `html` / `mml` / `deletedReplyCount` | `retyped` | Fields now, not custom data; `toJson` does not write them, so a message can no longer be sent with markup | +| `attachment.extraData['giphy']` | `Attachment.giphy` | `retyped` | A field now, not custom data | +| `message.extraData['image_labels']` / `['mentioned_channel_members']`, `draftMessage.extraData['html']` / `['mml']` | — | `removed` | No longer kept | +| `Message.custom` / `Attachment.custom` / `Reaction.custom` / `DraftMessage.custom` entry named like one of the model's own fields | the same entry, as custom data | `retyped` | Kept as the API returns it; left out when the model is sent, so it cannot overwrite the field | +| `Reaction.topLevelFields` | — | `removed` | It listed the JSON keys of a model that no longer has JSON | | _(more added per feature as PRs land)_ | | | | --- @@ -1118,6 +1127,44 @@ stays in `custom` and leaves the field alone; `toJson` leaves it out, so it cann > **Why:** the API calls it `custom`, and every migrated model takes that name. A name that answered the id could > not tell an unnamed user from a named one, so initials and sorts read the id as a name. +### Message Custom Data + +**`extraData` is renamed `custom` on `Message`, `Attachment`, `Reaction` and `DraftMessage`**, along with the +constructor and `copyWith` arguments and the `extraData` argument of `Channel.sendStaticLocation` and +`startLiveLocationSharing`. + +```dart +// v10 +final priority = message.extraData['priority']; +final duration = attachment.extraData['duration']; + +// v11 +final priority = message.custom['priority']; +final duration = attachment.custom['duration']; +``` + +**A message's channel id, HTML, markup and deleted reply count are fields**, `Message.cid`, `html`, `mml` and +`deletedReplyCount`, and so are an attachment's Giphy renditions, `Attachment.giphy`. `custom` no longer holds +their keys, and an entry with one of those names in `custom` no longer sets the field. `Message.toJson` does not +write any of them, so a message can no longer be sent with markup. + +```dart +// v10 +final html = message.extraData['html'] as String?; + +// v11 +final html = message.html; +``` + +**A message's image labels and mentioned channel members, and a draft message's HTML and markup, are no longer +kept.** v10 left them in `extraData`. + +**`custom` is the custom data exactly as the API returns it.** An entry named like one of the model's own fields +stays in `custom` and leaves the field alone; it is left out when the model is sent, so it cannot overwrite the +field. A reaction's emoji code read from the API stays in its `custom` as well as in `Reaction.emojiCode`. + +> **Why:** the API calls it `custom`, and every migrated model takes that name. + ### Moderation **Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient` diff --git a/openapi-migration/07-threads-and-drafts.md b/openapi-migration/07-threads-and-drafts.md index eb757b9e15..0819f46a5c 100644 --- a/openapi-migration/07-threads-and-drafts.md +++ b/openapi-migration/07-threads-and-drafts.md @@ -39,7 +39,7 @@ ## Risks -- `DraftMessage.poll` is never decoded from JSON: `poll` is not in `DraftMessage.topLevelFields`, so the key would land in `extraData`, and the draft payload carries `poll_id` only. Its `PollV1JsonConverter` exists so the generated code compiles. Decide here whether the field stays constructor-only or goes. +- `DraftMessage.poll` is never decoded from JSON: `poll` is not in `DraftMessage.topLevelFields`, so the key would land in `custom`, and the draft payload carries `poll_id` only. Its `PollV1JsonConverter` exists so the generated code compiles. Decide here whether the field stays constructor-only or goes. - The four draft methods live in `message_api.dart`, not `threads_api.dart` — this group reaches into that file, and group 10 must leave those four alone. - `Draft` and `DraftMessage` are public, persisted, and read by `StreamDraftListController` in `stream_chat_flutter_core`. diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 7fcca391d6..ab793032a3 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -48,7 +48,6 @@ ## Decisions to make - `Message` is public, persisted, WebSocket-delivered and the most customised type in the SDK. Keep ours; treat the generated `MessageResponse` as a mapping source only. -- `Attachment`: the generated model defines fields our `extraData` currently absorbs. Decide the promotion rules before writing the mapper. - Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`, `ReactionGroup`): decide between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and `Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates. - Expose `MarkReadResponse.event`. [17](17-read-receipts.md) dropped it because its thread carries a `MessageResponse`; the mapper marks it with a TODO. @@ -104,23 +103,24 @@ an endpoint, so the definition of done below stays open. 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`. +- **`Message`, `Attachment`, `Reaction` and `DraftMessage` rename `extraData` to `custom`** (README + rule 9), since these mappers are the first to map them. The response mappers copy the generated + `custom` as is; the v1 decoders keep splitting flat JSON by `topLevelFields` (the reaction converter + owns its key list, as `Reaction` has no JSON), and the v1 writers leave out a custom entry named like + a key. Persistence renames the `extra_data` columns `custom`. +- **The values v10 parked in `extraData` that the spec types are fields:** `Message.cid`, `html`, `mml` + and `deletedReplyCount`, with persistence columns (`cid` reads the message's channel), and + `Attachment.giphy`, stored through the attachment's `toData`. `Message.toJson` writes none of the + message's; `Attachment.toJson` writes `giphy`, so an edited Giphy message keeps its renditions. +- **What the SDK has no feature for stays out:** `image_labels` and `mentioned_channel_members` on a + message, and `html` and `mml` on a draft message, which iOS and Android drop too. The v1 decoders + list them in `topLevelFields`, so they no longer land in `custom` on either path. +- **A reaction's emoji code arrives in `custom`,** which keeps it, and also 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. + attachment was sent with stays in `custom`; `giphy` and `fields` are written in their v1 JSON shape, + which `GiphyInfo` and the UI read. 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. @@ -128,8 +128,6 @@ an endpoint, so the definition of done below stays open. ## 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. -- The message-family request mappers still drop custom data named like one of the model's own fields; [README rule 9](README.md#domain-models) has them send it verbatim once `Message` and its leaf models name it `custom`. - Message send has offline and retry paths through `stream_chat_persistence` that must keep working. - `MessageDeleteScope` has to be reconciled with `DeleteType`, which [08](08-moderation-and-blocklists.md) added. It is named for the scope of a delete — `deleteForMe` vs `deleteForAll` — but carries a `hard` bool, which is the same axis `DeleteType` models, in the same words, minus `pruning`. `deleteMessage(hard: true)` therefore cannot express a pruning delete at all, and `softDeleteForAll` / `hardDeleteForAll` read as two spellings of `DeleteType.soft` / `DeleteType.hard`. Decide whether the scope keeps a `DeleteType` field or the two stay separate arguments; either way the public type changes, so it belongs in this group rather than a later fix. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index e1df9cfa94..644a3437e4 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -162,11 +162,11 @@ includes a named response that carries only `duration` today, such as `HideChann directions.** A response mapper copies the generated `custom` map into it and a request mapper sends it back as `custom`; neither filters it. A model whose group has not run still says `extraData`. A field the generated response types is never parked in `extraData` behind a getter: it becomes a field on the model, as - the owning group's break, or stays out. Group 15 promoted `ChannelModel`'s and `Member`'s, and group 20 - `User`'s and `OwnUser`'s. `topLevelFields` and `Serializer` serve only the v1 decoders, which - still split flat JSON by a key list, and go with the WebSocket v2 move; a model that loses its JSON drops - `topLevelFields` and its converter owns the list. A v1 writer leaves a custom entry named like a key out so it - cannot overwrite the key. + the owning group's break, or stays out. Group 15 promoted `ChannelModel`'s and `Member`'s, group 20 + `User`'s and `OwnUser`'s, and group 06 `Message`'s, `Attachment`'s, `Reaction`'s and `DraftMessage`'s. + `topLevelFields` and `Serializer` serve only the v1 decoders, which still split flat JSON by a key list, and go + with the WebSocket v2 move; a model that loses its JSON drops `topLevelFields` and its converter owns the list. A + v1 writer leaves a custom entry named like a key out so it cannot overwrite the key. ### Temporary adapters diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 102a6db133..501cef1e63 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -457,7 +457,7 @@ def match(verb, path): ], risks=[ '`DraftMessage.poll` is never decoded from JSON: `poll` is not in `DraftMessage.topLevelFields`, so the key ' - 'would land in `extraData`, and the draft payload carries `poll_id` only. Its `PollV1JsonConverter` exists so ' + 'would land in `custom`, and the draft payload carries `poll_id` only. Its `PollV1JsonConverter` exists so ' 'the generated code compiles. Decide here whether the field stays constructor-only or goes.', 'The four draft methods live in `message_api.dart`, not `threads_api.dart` — this group reaches into ' 'that file, and group 10 must leave those four alone.', @@ -658,8 +658,6 @@ def match(verb, path): decisions=[ '`Message` is public, persisted, WebSocket-delivered and the most customised type in the SDK. Keep ' 'ours; treat the generated `MessageResponse` as a mapping source only.', - '`Attachment`: the generated model defines fields our `extraData` currently absorbs. Decide the ' - 'promotion rules before writing the mapper.', 'Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`, `ReactionGroup`): decide ' 'between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and ' '`Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates.', @@ -669,10 +667,6 @@ def match(verb, path): 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.', - 'The message-family request mappers still drop custom data named like one of the model\'s own ' - 'fields; [README rule 9](README.md#domain-models) has them send it verbatim once `Message` and its ' - 'leaf models name it `custom`.', '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.', ], @@ -727,23 +721,24 @@ def match(verb, path): 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`. + - **`Message`, `Attachment`, `Reaction` and `DraftMessage` rename `extraData` to `custom`** (README + rule 9), since these mappers are the first to map them. The response mappers copy the generated + `custom` as is; the v1 decoders keep splitting flat JSON by `topLevelFields` (the reaction converter + owns its key list, as `Reaction` has no JSON), and the v1 writers leave out a custom entry named like + a key. Persistence renames the `extra_data` columns `custom`. + - **The values v10 parked in `extraData` that the spec types are fields:** `Message.cid`, `html`, `mml` + and `deletedReplyCount`, with persistence columns (`cid` reads the message's channel), and + `Attachment.giphy`, stored through the attachment's `toData`. `Message.toJson` writes none of the + message's; `Attachment.toJson` writes `giphy`, so an edited Giphy message keeps its renditions. + - **What the SDK has no feature for stays out:** `image_labels` and `mentioned_channel_members` on a + message, and `html` and `mml` on a draft message, which iOS and Android drop too. The v1 decoders + list them in `topLevelFields`, so they no longer land in `custom` on either path. + - **A reaction's emoji code arrives in `custom`,** which keeps it, and also 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. + attachment was sent with stays in `custom`; `giphy` and `fields` are written in their v1 JSON shape, + which `GiphyInfo` and the UI read. Making `Attachment` plain, and typing the renditions and + `fields`, is left to this group. - **A reminder or location nests its message one level deep,** so the recursion ends there. - **`MessageWithChannelResponse` gets its mapper with its first consumer** (getMessage, search), not ahead of it. diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 304b8f88b5..ca1023b528 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -148,6 +148,13 @@ - Several user values the custom data used to hold, such as `User.name`, `User.image` and `User.deactivatedAt`, are fields instead: `custom` no longer holds them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. - `User.custom` holds the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the user's own fields. - `OwnUser.fromUser` no longer reads devices, mutes, unread counts, push preferences or privacy settings out of the user's custom data; they are left at their defaults. +- `Message.extraData`, `Attachment.extraData`, `Reaction.extraData` and `DraftMessage.extraData` are renamed `custom`, and so is the `extraData` argument of their constructors and `copyWith` and of `Channel.sendStaticLocation` and `startLiveLocationSharing`. +- `Message.custom`, `Attachment.custom`, `Reaction.custom` and `DraftMessage.custom` hold the custom data exactly as the API returns it, and an entry named like one of the model's own fields is left out when the model is sent. +- `Reaction.topLevelFields` is removed. +- A message's channel id, HTML, markup and deleted reply count, and an attachment's Giphy renditions, are fields instead of custom data: `custom` no longer holds them, and an entry with one of their names in `custom` no longer sets the field. +- `Message.toJson` no longer writes the message's channel id, HTML, markup or deleted reply count, so a message can no longer be sent with markup. +- A message's image labels and mentioned channel members, and a draft message's HTML and markup, are no longer kept in their custom data. +- A reaction's emoji code read from the API stays in its custom data, as well as in `Reaction.emojiCode`. 🐞 Fixed @@ -181,8 +188,7 @@ - Added `StreamChatClient.moderation.muteUsers` and `unmuteUsers`, which mute and unmute several users in one call and report the ids that matched no user. - Added `User.deactivatedAt`, `deletedAt`, `shadowBanned`, `blockedUserIds` and `nameOrId`; the constructors and `copyWith` accept the new fields. - Added `ChannelModel.muted`, `blocked`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`; the constructors and `copyWith` accept the new fields. -- Added `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. +- Added `Message.cid`, `html`, `mml` and `deletedReplyCount`, and `Attachment.giphy`; the constructors and `copyWith` accept them. - Added a `prev` argument to `queryPolls` and `queryPollVotes`, and a `prev` cursor on their responses, for paging backwards. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 8442f77962..7607f626f2 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -694,7 +694,7 @@ class Channel { it.file!, onSendProgress: onSendProgress, cancelToken: cancelToken, - extraData: it.extraData, + extraData: it.custom, ); throttledUpdateAttachment.cancel(); @@ -1270,19 +1270,19 @@ class Channel { /// Sends a static location to this channel. /// - /// Optionally, provide a [messageText] and [extraData] to send along with - /// the location. + /// A `messageText` and `custom` data, when given, are sent in the message + /// with the location. Future sendStaticLocation({ String? id, String? messageText, String? createdByDeviceId, required LocationCoordinate location, - Map extraData = const {}, + Map custom = const {}, }) { final message = Message( id: id, text: messageText, - extraData: extraData, + custom: custom, ); final currentUserId = _client.state.currentUser?.id; @@ -1302,20 +1302,20 @@ class Channel { /// Sends a live location sharing message to this channel. /// - /// Optionally, provide a [messageText] and [extraData] to send along with - /// the location. + /// A `messageText` and `custom` data, when given, are sent in the message + /// with the location. Future startLiveLocationSharing({ String? id, String? messageText, String? createdByDeviceId, required DateTime endSharingAt, required LocationCoordinate location, - Map extraData = const {}, + Map custom = const {}, }) { final message = Message( id: id, text: messageText, - extraData: extraData, + custom: custom, ); final currentUserId = _client.state.currentUser?.id; @@ -1594,7 +1594,8 @@ class Channel { /// 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. + /// The reminder is due at [remindAt]. Without it, the reminder is a bookmark, + /// which is never due. /// /// Throws a [StateError] if this channel is not initialized. Future> createReminder( @@ -1610,7 +1611,8 @@ class Channel { /// Updates the reminder on the message with the id [messageId]. /// - /// The reminder becomes due at [remindAt]. Without it, the reminder becomes a bookmark, which is never due. + /// 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( diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 632b2fc8be..df18b1c25a 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -188,7 +188,7 @@ class GetMessageResponse extends MessageResponse { /// Create a new instance from a json static GetMessageResponse fromJson(Map json) { final res = _$GetMessageResponseFromJson(json); - final jsonChannel = res.message.extraData.remove('channel'); + final jsonChannel = res.message.custom.remove('channel'); if (jsonChannel != null) { res.channel = ChannelModel.fromJson(jsonChannel as Map); } diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index 876e577825..a6b1d9678f 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -4,7 +4,6 @@ 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'; @@ -17,8 +16,6 @@ part 'attachment.g.dart'; @JsonSerializable(includeIfNull: false) class Attachment extends Equatable { /// Constructor used for json serialization - /// - /// The [giphy] argument, when given, is stored in [extraData], replacing the entry it reads. Attachment({ String? id, String? type, @@ -41,10 +38,10 @@ class Attachment extends Equatable { this.actions = const [], this.originalWidth, this.originalHeight, - Map extraData = const {}, + Map custom = const {}, this.file, this.uploadState = const UploadState.preparing(), - Map? giphy, + this.giphy, }) : id = id ?? const Uuid().v4(), _type = switch (type) { String() => AttachmentType(type), @@ -52,13 +49,11 @@ class Attachment extends Equatable { }, title = title ?? file?.name, localUri = file?.path != null ? Uri.parse(file!.path!) : null, - // For backwards compatibility, - // set 'file_size', 'mime_type' in [extraData]. - extraData = { - ...extraData, + // For backwards compatibility, set 'file_size', 'mime_type' in [custom]. + custom = { + ...custom, 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 @@ -164,8 +159,12 @@ class Attachment extends Equatable { @JsonKey(defaultValue: UploadState.success) final UploadState uploadState; - /// Map of custom channel extraData - final Map extraData; + /// This attachment's custom data. + /// + /// It may hold a key named like one of the attachment's own fields; [toJson] + /// leaves such an entry out so it never overwrites the field. + @JsonKey(name: 'extra_data') + final Map custom; /// The attachment ID. /// @@ -176,19 +175,20 @@ class Attachment extends Equatable { /// /// {@macro fileSize} @JsonKey(includeToJson: false, includeFromJson: false) - int? get fileSize => extraData['file_size'] as int?; + int? get fileSize => custom['file_size'] as int?; /// Shortcut for file mimeType. /// /// {@macro mimeType} @JsonKey(includeToJson: false, includeFromJson: false) - String? get mimeType => extraData['mime_type'] as String?; + String? get mimeType => custom['mime_type'] as String?; - /// The renditions of this Giphy attachment, keyed by name, such as `original` or `fixed_height`. + /// 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>(); + /// Each rendition holds its `url`, `width`, `height`, `size` and `frames`, as + /// strings. + final Map? giphy; /// Known top level fields. /// Useful for [Serializer] methods. @@ -213,6 +213,7 @@ class Attachment extends Equatable { 'actions', 'original_width', 'original_height', + 'giphy', ]; /// Known db specific top level fields. @@ -229,12 +230,20 @@ class Attachment extends Equatable { ]; /// Serialize to json + /// + /// Writes the entries of [custom] at the root, leaving out any named like one + /// of [topLevelFields]. Map toJson() => - Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this)) + Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this), topLevelFields: topLevelFields) ..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key)); + // The fields toData leaves custom data out of. A custom `id`, the id the + // attachment was sent with, still replaces the local one, as it did before. + static const _storedFields = [...topLevelFields, 'file', 'upload_state']; + /// Serialize to db data - Map toData() => Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this)); + Map toData() => + Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this), topLevelFields: _storedFields); Attachment copyWith({ String? id, @@ -260,7 +269,8 @@ class Attachment extends Equatable { int? originalHeight, AttachmentFile? file, UploadState? uploadState, - Map? extraData, + Map? custom, + Map? giphy, }) => Attachment( id: id ?? this.id, type: type ?? this.type, @@ -285,7 +295,8 @@ class Attachment extends Equatable { originalHeight: originalHeight ?? this.originalHeight, file: file ?? this.file, uploadState: uploadState ?? this.uploadState, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, + giphy: giphy ?? this.giphy, ); Attachment merge(Attachment? other) { @@ -313,7 +324,8 @@ class Attachment extends Equatable { originalHeight: other.originalHeight, file: other.file, uploadState: other.uploadState, - extraData: other.extraData, + custom: other.custom, + giphy: other.giphy, ); } @@ -342,7 +354,8 @@ class Attachment extends Equatable { originalHeight, file, uploadState, - extraData, + custom, + giphy, ]; } 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 fbb4f2ab68..946c4ade50 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.g.dart @@ -32,11 +32,12 @@ Attachment _$AttachmentFromJson(Map json) => Attachment( const [], originalWidth: (json['original_width'] as num?)?.toInt(), originalHeight: (json['original_height'] as num?)?.toInt(), - extraData: json['extra_data'] as Map? ?? const {}, + custom: json['extra_data'] as Map? ?? const {}, file: json['file'] == null ? null : AttachmentFile.fromJson(json['file'] as Map), uploadState: json['upload_state'] == null ? const UploadState.success() : UploadState.fromJson(json['upload_state'] as Map), + giphy: json['giphy'] as Map?, ); Map _$AttachmentToJson(Attachment instance) => { @@ -62,6 +63,7 @@ Map _$AttachmentToJson(Attachment instance) => ?; + final giphy = this.giphy; if (giphy == null) return null; final info = giphy[type.value] as Map?; 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 38d1611161..5d1bb5bf8e 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 @@ -40,7 +40,8 @@ class ActionV1JsonConverter implements JsonConverter toJson(Action action) => { 'name': action.name, @@ -53,8 +54,8 @@ class ActionV1JsonConverter implements JsonConverter> { @@ -93,9 +94,10 @@ class LocationV1JsonConverter implements JsonConverter> { /// Creates a new [ReactionV1JsonConverter]. @@ -103,7 +105,7 @@ class ReactionV1JsonConverter implements JsonConverter json) { - final data = Serializer.moveToExtraDataFromRoot(json, Reaction.topLevelFields); + final data = Serializer.moveToExtraDataFromRoot(json, _reactionV1Keys); return Reaction( messageId: data['message_id'] as String?, type: data['type'] as String, @@ -122,7 +124,7 @@ class ReactionV1JsonConverter implements JsonConverter _dateTime.fromJson(it), null => null, }, - extraData: data['extra_data'] as Map, + custom: data['extra_data'] as Map, ); } @@ -131,14 +133,26 @@ class ReactionV1JsonConverter implements JsonConverter> { /// Creates a new [MessageReminderV1JsonConverter]. 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 f818cd3d03..e43bfbbfcd 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.dart @@ -29,7 +29,7 @@ class DraftMessage extends Equatable { this.command, this.poll, this._pollId, - this.extraData = const {}, + this.custom = const {}, }) : id = id ?? const Uuid().v4(), type = MessageType(type); @@ -92,12 +92,15 @@ class DraftMessage extends Equatable { String? get pollId => _pollId ?? poll?.id; final String? _pollId; - /// Message custom extraData. - final Map extraData; - - /// Known top level fields. + /// This draft message's custom data. /// - /// Useful for [Serializer] methods. + /// It may hold a key named like one of the draft message's own fields; + /// [toJson] leaves such an entry out so it never overwrites the field. + @JsonKey(name: 'extra_data') + final Map custom; + + /// The JSON keys [DraftMessage.fromJson] reads into named fields, or leaves + /// out; every other key is kept in [custom]. static const topLevelFields = [ 'id', 'text', @@ -112,13 +115,20 @@ class DraftMessage extends Equatable { 'command', 'poll_id', 'extra_data', + // Not modelled on a draft message; listed so they never land in [custom]. + 'html', + 'mml', ]; /// Serialize to json. + /// + /// Writes the entries of [custom] at the root, leaving out any named like one + /// of [topLevelFields]. Map toJson() { final message = removeMentionsIfNotIncluded(); final json = Serializer.moveFromExtraDataToRoot( _$DraftMessageToJson(message), + topLevelFields: topLevelFields, ); // If the message contains command we should append it to the text @@ -145,7 +155,7 @@ class DraftMessage extends Equatable { String? command, Poll? poll, String? pollId, - Map? extraData, + Map? custom, }) { return DraftMessage( id: id ?? this.id, @@ -161,7 +171,7 @@ class DraftMessage extends Equatable { command: command ?? this.command, poll: poll ?? this.poll, pollId: pollId ?? this.pollId, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, ); } @@ -178,7 +188,7 @@ class DraftMessage extends Equatable { silent, command, pollId, - extraData, + custom, ]; } @@ -205,7 +215,7 @@ extension MessageToDraftMessage on Message { command: command, poll: poll, pollId: pollId, - extraData: extraData, + custom: custom, ); } } @@ -234,7 +244,7 @@ extension DraftMessageToMessage on DraftMessage { command: command, poll: poll, pollId: pollId, - extraData: extraData, + custom: custom, ); } } 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 27e1e0a332..a512e6dedc 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 @@ -26,7 +26,7 @@ DraftMessage _$DraftMessageFromJson(Map json) => DraftMessage( command: json['command'] as String?, poll: _$JsonConverterFromJson, Poll>(json['poll'], const PollV1JsonConverter().fromJson), pollId: json['poll_id'] as String?, - extraData: json['extra_data'] as Map? ?? const {}, + custom: json['extra_data'] as Map? ?? const {}, ); Map _$DraftMessageToJson(DraftMessage instance) => { @@ -40,7 +40,7 @@ Map _$DraftMessageToJson(DraftMessage instance) => (Object? json, Value? Function(Json json) fromJson) => diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 0e0db149ba..cb538321b4 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -32,9 +32,6 @@ 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, @@ -73,7 +70,7 @@ class Message extends Equatable { this.pinnedBy, this.poll, this._pollId, - Map extraData = const {}, + this.custom = const {}, this.state = const MessageState.initial(), this.i18n, this.restrictedVisibility, @@ -82,27 +79,16 @@ class Message extends Equatable { this.reminder, this.channelRole, this.sharedLocation, - String? html, - String? mml, - Map>? imageLabels, - int? deletedReplyCount, + this.cid, + this.html, + this.mml, + this.deletedReplyCount, }) : id = id ?? const Uuid().v4(), type = MessageType(type), pinExpires = pinExpires?.toUtc(), remoteCreatedAt = createdAt, remoteUpdatedAt = updatedAt, - 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, - }; + remoteDeletedAt = deletedAt; /// Create a new instance from JSON. factory Message.fromJson(Map json) { @@ -121,7 +107,8 @@ class Message extends Equatable { .withDerivedState(); } - // Reads a map of reaction type to count or score, whatever kind of number each value is. + // 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) @@ -359,38 +346,36 @@ class Message extends Equatable { @JsonKey(includeToJson: false) final bool? deletedForMe; - /// Message custom extraData. - final Map extraData; + /// This message's custom data. + /// + /// It may hold a key named like one of the message's own fields; [toJson] + /// leaves such an entry out so it never overwrites the field. + @JsonKey(name: 'extra_data') + final Map custom; + + /// The id of the channel this message belongs to, such as + /// `messaging:general`. + @JsonKey(includeToJson: false) + final String? cid; /// The text of this message rendered as HTML. - @JsonKey(includeToJson: false, includeFromJson: false) - String? get html => extraData['html'].safeCast(); + @JsonKey(includeToJson: false) + final String? html; /// 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, - }; + @JsonKey(includeToJson: false) + final String? mml; /// The number of replies to this message that were deleted. - @JsonKey(includeToJson: false, includeFromJson: false) - int? get deletedReplyCount => extraData['deleted_reply_count'].safeCast(); + @JsonKey(includeToJson: false) + final int? deletedReplyCount; /// A Map of translations. @JsonKey(includeToJson: false) final Map? i18n; - /// Known top level fields. - /// - /// Useful for [Serializer] methods. + /// The JSON keys [Message.fromJson] reads into named fields, or leaves out; + /// every other key is kept in [custom]. static const topLevelFields = [ 'id', 'text', @@ -437,13 +422,24 @@ class Message extends Equatable { 'member', 'shared_location', 'deleted_for_me', + 'cid', + 'html', + 'mml', + 'deleted_reply_count', + // Not modelled on a message; listed so they never land in [custom]. + 'image_labels', + 'mentioned_channel_members', ]; /// Serialize to json. + /// + /// Writes the entries of [custom] at the root, leaving out any named like one + /// of [topLevelFields]. Map toJson() { final message = removeMentionsIfNotIncluded(); final json = Serializer.moveFromExtraDataToRoot( _$MessageToJson(message), + topLevelFields: topLevelFields, ); // If the message contains command we should append it to the text @@ -493,7 +489,7 @@ class Message extends Equatable { User? pinnedBy, Poll? poll, String? pollId, - Map? extraData, + Map? custom, MessageState? state, Map? i18n, List? restrictedVisibility, @@ -503,6 +499,10 @@ class Message extends Equatable { String? channelRole, Location? sharedLocation, bool? deletedForMe, + String? cid, + String? html, + String? mml, + int? deletedReplyCount, }) { assert(() { if (pinExpires is! DateTime && pinExpires != null && pinExpires is! _NullConst) { @@ -566,7 +566,7 @@ class Message extends Equatable { pinnedBy: pinnedBy ?? this.pinnedBy, poll: poll ?? this.poll, pollId: pollId ?? _pollId, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, state: state ?? this.state, i18n: i18n ?? this.i18n, restrictedVisibility: restrictedVisibility ?? this.restrictedVisibility, @@ -576,6 +576,10 @@ class Message extends Equatable { channelRole: channelRole ?? this.channelRole, sharedLocation: sharedLocation ?? this.sharedLocation, deletedForMe: deletedForMe ?? this.deletedForMe, + cid: cid ?? this.cid, + html: html ?? this.html, + mml: mml ?? this.mml, + deletedReplyCount: deletedReplyCount ?? this.deletedReplyCount, ); } @@ -619,7 +623,7 @@ class Message extends Equatable { pinnedBy: other.pinnedBy, poll: other.poll, pollId: other.pollId, - extraData: other.extraData, + custom: other.custom, state: other.state, i18n: other.i18n, restrictedVisibility: other.restrictedVisibility, @@ -629,6 +633,10 @@ class Message extends Equatable { channelRole: other.channelRole, sharedLocation: other.sharedLocation, deletedForMe: other.deletedForMe, + cid: other.cid, + html: other.html, + mml: other.mml, + deletedReplyCount: other.deletedReplyCount, ); } @@ -732,7 +740,7 @@ class Message extends Equatable { pinnedBy, poll, pollId, - extraData, + custom, state, i18n, restrictedVisibility, @@ -742,6 +750,10 @@ class Message extends Equatable { channelRole, sharedLocation, deletedForMe, + cid, + html, + mml, + deletedReplyCount, ]; } @@ -763,11 +775,11 @@ class MessageSearchFilterField extends FilterField { /// value off an instance with [value]. MessageSearchFilterField(super.remote, super.value); - /// Creates a field the SDK does not model, read from [Message.extraData]. + /// Creates a field the SDK does not model, read from [Message.custom]. /// /// **Supported operators:** `$eq`, `$in`, `$gt`, `$gte`, `$lt`, `$lte` factory MessageSearchFilterField.custom(String remote) { - return MessageSearchFilterField(remote, (it) => it.extraData[remote]); + return MessageSearchFilterField(remote, (it) => it.custom[remote]); } /// Filters messages by their id. @@ -906,11 +918,11 @@ class MessageSearchSortField extends SortField { /// For a name the SDK has not modelled; prefer the fields declared here. MessageSearchSortField(super.remote, super.localValue); - /// Creates a field the SDK does not model, read from [Message.extraData]. + /// Creates a field the SDK does not model, read from [Message.custom]. /// /// Declared only where the API accepts a custom sort field. factory MessageSearchSortField.custom(String remote) { - return MessageSearchSortField(remote, (it) => it.extraData[remote]); + return MessageSearchSortField(remote, (it) => it.custom[remote]); } /// Sorts messages by their unique ID. 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 4e861a0cc8..d266efbb65 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -55,7 +55,7 @@ Message _$MessageFromJson(Map json) => Message( pinnedBy: json['pinned_by'] == null ? null : User.fromJson(json['pinned_by'] as Map), poll: _$JsonConverterFromJson, Poll>(json['poll'], const PollV1JsonConverter().fromJson), pollId: json['poll_id'] as String?, - extraData: json['extra_data'] as Map? ?? const {}, + custom: json['extra_data'] as Map? ?? const {}, 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: moderationFromV1Json(Message._moderationReadValue(json, 'moderation') as Map?), @@ -69,6 +69,10 @@ Message _$MessageFromJson(Map json) => Message( json['shared_location'], const LocationV1JsonConverter().fromJson, ), + cid: json['cid'] as String?, + html: json['html'] as String?, + mml: json['mml'] as String?, + deletedReplyCount: (json['deleted_reply_count'] as num?)?.toInt(), ); Map _$MessageToJson(Message instance) => { @@ -93,7 +97,7 @@ Map _$MessageToJson(Message instance) => { instance.sharedLocation, const LocationV1JsonConverter().toJson, ), - 'extra_data': instance.extraData, + 'extra_data': instance.custom, }; Value? _$JsonConverterFromJson(Object? json, Value? Function(Json json) fromJson) => diff --git a/packages/stream_chat/lib/src/core/models/reaction.dart b/packages/stream_chat/lib/src/core/models/reaction.dart index 66131417be..fc7cce2a35 100644 --- a/packages/stream_chat/lib/src/core/models/reaction.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.dart @@ -10,7 +10,8 @@ part 'reaction.freezed.dart'; class Reaction with _$Reaction { /// Creates a new [Reaction]. /// - /// [userId] defaults to the id of [user], and [createdAt] and [updatedAt] default to now. + /// [userId] defaults to the id of [user], and [createdAt] and [updatedAt] + /// default to now. Reaction({ this.messageId, required this.type, @@ -20,7 +21,7 @@ class Reaction with _$Reaction { this.emojiCode, DateTime? createdAt, DateTime? updatedAt, - this.extraData = const {}, + this.custom = const {}, }) : userId = userId ?? user?.id, createdAt = createdAt ?? DateTime.timestamp(), updatedAt = updatedAt ?? DateTime.timestamp(); @@ -57,21 +58,9 @@ class Reaction with _$Reaction { @override final DateTime updatedAt; - /// Reaction custom extraData + /// This reaction's custom data. @override - final Map extraData; - - /// Map of custom user extraData - static const topLevelFields = [ - 'message_id', - 'type', - 'user', - 'user_id', - 'score', - 'emoji_code', - 'created_at', - 'updated_at', - ]; + final Map custom; /// Creates a copy of [Reaction] with specified attributes overridden. Reaction copyWith({ @@ -83,7 +72,7 @@ class Reaction with _$Reaction { String? emojiCode, DateTime? createdAt, DateTime? updatedAt, - Map? extraData, + Map? custom, }) => Reaction( messageId: messageId ?? this.messageId, type: type ?? this.type, @@ -93,7 +82,7 @@ class Reaction with _$Reaction { emojiCode: emojiCode ?? this.emojiCode, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, ); /// Returns a new [Reaction] that is a combination of this reaction and the @@ -107,7 +96,7 @@ class Reaction with _$Reaction { emojiCode: other.emojiCode, createdAt: other.createdAt, updatedAt: other.updatedAt, - extraData: other.extraData, + custom: other.custom, ); } diff --git a/packages/stream_chat/lib/src/core/models/reaction.freezed.dart b/packages/stream_chat/lib/src/core/models/reaction.freezed.dart index bebce4b81d..2dbb19c5f7 100644 --- a/packages/stream_chat/lib/src/core/models/reaction.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.freezed.dart @@ -21,7 +21,7 @@ mixin _$Reaction { String? get userId; DateTime get createdAt; DateTime get updatedAt; - Map get extraData; + Map get custom; @override bool operator ==(Object other) { @@ -36,7 +36,7 @@ mixin _$Reaction { (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)); + const DeepCollectionEquality().equals(other.custom, custom)); } @override @@ -50,11 +50,11 @@ mixin _$Reaction { userId, createdAt, updatedAt, - const DeepCollectionEquality().hash(extraData), + const DeepCollectionEquality().hash(custom), ); @override String toString() { - return 'Reaction(messageId: $messageId, type: $type, score: $score, emojiCode: $emojiCode, user: $user, userId: $userId, createdAt: $createdAt, updatedAt: $updatedAt, extraData: $extraData)'; + return 'Reaction(messageId: $messageId, type: $type, score: $score, emojiCode: $emojiCode, user: $user, userId: $userId, createdAt: $createdAt, updatedAt: $updatedAt, custom: $custom)'; } } diff --git a/packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart index 98df87a5fc..ecd06c799c 100644 --- a/packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/attachments_mapper.dart @@ -7,20 +7,10 @@ import '../../core/models/attachment_file.dart'; /// 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. + /// The attachment gets a new local [Attachment.id]; an id it was sent with + /// stays in [Attachment.custom]. Attachment toModel() => Attachment( type: type, titleLink: titleLink, @@ -42,7 +32,7 @@ extension AttachmentMapper on api.Attachment { actions: actions?.map((action) => action.toModel()).toList() ?? const [], originalWidth: originalWidth, originalHeight: originalHeight, - extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + custom: custom, giphy: giphy?.toJson(), uploadState: const UploadState.success(), ); diff --git a/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart index 79afbc1ff2..947f56b1d5 100644 --- a/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/drafts_mapper.dart @@ -25,17 +25,7 @@ extension DraftResponseMapper on api.DraftResponse { /// 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, @@ -47,10 +37,6 @@ extension DraftPayloadResponseMapper on api.DraftPayloadResponse { quotedMessageId: quotedMessageId, silent: silent ?? false, pollId: pollId, - extraData: { - ...{...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), - 'html': ?html, - 'mml': ?mml, - }, + custom: custom, ); } diff --git a/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart index 811d4f2054..9d384e0a77 100644 --- a/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/messages_mapper.dart @@ -15,24 +15,11 @@ import 'users_mapper.dart'; /// 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. + /// 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. Message toModel() => Message( id: id, text: text, @@ -70,10 +57,7 @@ extension MessageResponseMapper on api.MessageResponse { pinnedBy: pinnedBy?.toModel(), poll: poll?.toModel(), pollId: pollId, - extraData: { - ...{...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), - 'cid': cid, - }, + custom: custom, i18n: i18n, restrictedVisibility: restrictedVisibility, moderation: moderation?.toModel(), @@ -81,9 +65,9 @@ extension MessageResponseMapper on api.MessageResponse { reminder: reminder?.toModel(), channelRole: member?.channelRole, sharedLocation: sharedLocation?.toModel(), + cid: cid, html: html, mml: mml, - imageLabels: imageLabels, deletedReplyCount: deletedReplyCount, ).withDerivedState(); } diff --git a/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart index d70c70da75..7ba22cecb2 100644 --- a/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/reactions_mapper.dart @@ -8,12 +8,10 @@ import 'users_mapper.dart'; /// Maps a generated [api.ReactionResponse] to a [Reaction]. extension ReactionResponseMapper on api.ReactionResponse { - // Custom keys named like one of the reaction's own fields. - static const _shadowedCustomKeys = {...Reaction.topLevelFields}; - /// Converts this response into a [Reaction]. /// - /// Custom data named like one of the reaction's own fields is left out of [Reaction.extraData]. + /// [Reaction.emojiCode] is read from [custom], and [Reaction.custom] still + /// holds it. Reaction toModel() => Reaction( messageId: messageId, type: type, @@ -23,7 +21,7 @@ extension ReactionResponseMapper on api.ReactionResponse { emojiCode: custom['emoji_code'].safeCast(), createdAt: createdAt, updatedAt: updatedAt, - extraData: {...custom}..removeWhere((key, _) => _shadowedCustomKeys.contains(key)), + custom: custom, ); } 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 315600c8fa..fbfe12fe64 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -1256,7 +1256,7 @@ void main() { ..message = Message( id: locationId, text: 'Location shared', - extraData: const {'custom': 'data'}, + custom: const {'custom': 'data'}, sharedLocation: Location( channelCid: channel.cid, messageId: locationId, @@ -1273,13 +1273,13 @@ void main() { messageText: 'Location shared', createdByDeviceId: deviceId, location: coordinates, - extraData: {'custom': 'data'}, + custom: {'custom': 'data'}, ); expect(response, isNotNull); expect(response.message.id, locationId); expect(response.message.text, 'Location shared'); - expect(response.message.extraData['custom'], 'data'); + expect(response.message.custom['custom'], 'data'); expect(response.message.sharedLocation, isNotNull); verify( @@ -1307,7 +1307,7 @@ void main() { ..message = Message( id: locationId, text: 'Location shared', - extraData: const {'custom': 'data'}, + custom: const {'custom': 'data'}, sharedLocation: Location( channelCid: channel.cid, messageId: locationId, @@ -1326,13 +1326,13 @@ void main() { createdByDeviceId: deviceId, location: coordinates, endSharingAt: endSharingAt, - extraData: {'custom': 'data'}, + custom: {'custom': 'data'}, ); expect(response, isNotNull); expect(response.message.id, locationId); expect(response.message.text, 'Location shared'); - expect(response.message.extraData['custom'], 'data'); + expect(response.message.custom['custom'], 'data'); expect(response.message.sharedLocation, isNotNull); expect(response.message.sharedLocation?.endAt, endSharingAt); 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 index a48b2bb97e..acce8e165d 100644 --- a/packages/stream_chat/test/src/client/client_create_reminder_test.dart +++ b/packages/stream_chat/test/src/client/client_create_reminder_test.dart @@ -121,7 +121,8 @@ void main() { }); } -// A reminder as a write answers it: with the message and the user, but no channel. +// 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), @@ -134,7 +135,8 @@ api.ReminderResponseData _generatedReminder({required DateTime? remindAt}) => ap userId: 'sender', ); -// A message without reaction groups, only the counts and scores they are built from. +// A message without reaction groups, only the counts and scores they are built +// from. final _generatedMessageWithCounts = api.MessageResponse( attachments: const [], cid: 'messaging:general', @@ -161,7 +163,8 @@ final _generatedMessageWithCounts = api.MessageResponse( user: fakeUserResponse('sender'), ); -// The message [_generatedMessageWithCounts] maps to, with the date its reaction groups share. +// The message [_generatedMessageWithCounts] maps to, with the date its reaction +// groups share. Message _expectedMessageWithCounts({required DateTime reactionDate}) => Message( id: 'message-id', text: 'Hello', @@ -176,9 +179,9 @@ Message _expectedMessageWithCounts({required DateTime reactionDate}) => Message( createdAt: DateTime.utc(2026), updatedAt: DateTime.utc(2026), user: fakeUser('sender'), - extraData: const {'cid': 'messaging:general'}, state: MessageState.sent, restrictedVisibility: const [], + cid: 'messaging:general', html: '', deletedReplyCount: 0, ); diff --git a/packages/stream_chat/test/src/client/message_fixtures.dart b/packages/stream_chat/test/src/client/message_fixtures.dart index a3a5750aeb..e6435c4b23 100644 --- a/packages/stream_chat/test/src/client/message_fixtures.dart +++ b/packages/stream_chat/test/src/client/message_fixtures.dart @@ -4,12 +4,14 @@ 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. +// 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. /// A generated message with every field set, nesting one level of messages. final generatedMessage = api.MessageResponse( @@ -17,7 +19,7 @@ final generatedMessage = api.MessageResponse( cid: 'messaging:general', command: 'giphy', createdAt: DateTime.utc(2026, 1, 1), - custom: const {'priority': 'high', 'text': 'shadowed', 'html': 'shadowed'}, + custom: const {'priority': 'high'}, deletedAt: DateTime.utc(2026, 1, 3), deletedForMe: false, deletedReplyCount: 2, @@ -84,7 +86,8 @@ final generatedMessage = api.MessageResponse( pollId: 'poll-id', quotedMessage: _generatedQuotedMessage, quotedMessageId: 'quoted-id', - // The counts and scores the groups were built from; the groups take precedence. + // The counts and scores the groups were built from; the groups take + // precedence. reactionCounts: const {'love': 1}, reactionGroups: { 'love': api.ReactionGroupResponse( @@ -132,10 +135,12 @@ final generatedMessage = api.MessageResponse( user: fakeUserResponse('sender'), ); -/// The [Message] that [generatedMessage] maps to, with the values the mapping creates read off [actual]. +/// 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. +/// 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', @@ -194,10 +199,7 @@ Message expectedMessageLike(Message actual) => Message( pinnedBy: fakeUser('pinner'), poll: poll, pollId: 'poll-id', - extraData: const { - 'priority': 'high', - 'cid': 'messaging:general', - }, + custom: const {'priority': 'high'}, // Deleted, but not only for the current user. state: MessageState.softDeleted, i18n: const {'fr_text': 'Bonjour', 'language': 'en'}, @@ -237,11 +239,9 @@ Message expectedMessageLike(Message actual) => Message( createdAt: DateTime.utc(2026, 1, 9), updatedAt: DateTime.utc(2026, 1, 9, 12), ), + cid: 'messaging:general', html: '

Hello

', mml: 'Hello', - imageLabels: const { - 'cat.png': ['cat'], - }, deletedReplyCount: 2, ); @@ -264,7 +264,7 @@ final _generatedAttachment = api.Attachment( authorLink: 'https://example.com/author', authorName: 'Author', color: '#ff0000', - custom: const {'id': 'attachment-id', 'caption': 'A cat', 'title': 'shadowed'}, + custom: const {'id': 'attachment-id', 'caption': 'A cat'}, fallback: 'A cat gif', fields: const [api.Field(short: true, title: 'Size', value: 'L')], footer: 'Footer', @@ -314,7 +314,7 @@ Attachment _expectedAttachment(String id) => Attachment( 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'}, + custom: const {'id': 'attachment-id', 'caption': 'A cat'}, giphy: { for (final rendition in const [ 'fixed_height', @@ -359,7 +359,7 @@ Attachment _expectedImageAttachment(String id) => Attachment( final _generatedReaction = api.ReactionResponse( createdAt: DateTime.utc(2026, 1, 5), - custom: const {'emoji_code': '❤️', 'mood': 'happy', 'type': 'shadowed'}, + custom: const {'emoji_code': '❤️', 'mood': 'happy'}, messageId: 'message-id', score: 2, type: 'love', @@ -377,7 +377,7 @@ final _expectedReaction = Reaction( emojiCode: '❤️', createdAt: DateTime.utc(2026, 1, 5), updatedAt: DateTime.utc(2026, 1, 6), - extraData: const {'mood': 'happy'}, + custom: const {'emoji_code': '❤️', 'mood': 'happy'}, ); final _generatedOwnReaction = api.ReactionResponse( @@ -409,7 +409,7 @@ final _generatedDraft = api.DraftResponse( attachments: const [ api.Attachment(custom: {'caption': 'Notes'}, title: 'notes.pdf', type: 'file'), ], - custom: const {'mood': 'busy', 'text': 'shadowed'}, + custom: const {'mood': 'busy'}, html: '

Draft

', id: 'draft-id', mentionedUsers: [fakeUserResponse('mentioned')], @@ -440,7 +440,7 @@ Draft _expectedDraft({required String attachmentId, required ChannelModel? chann id: attachmentId, type: 'file', title: 'notes.pdf', - extraData: const {'caption': 'Notes'}, + custom: const {'caption': 'Notes'}, uploadState: const UploadState.success(), ), ], @@ -450,38 +450,33 @@ Draft _expectedDraft({required String attachmentId, required ChannelModel? chann quotedMessageId: 'draft-quoted-id', silent: false, pollId: 'draft-poll-id', - extraData: const {'mood': 'busy', 'html': '

Draft

', 'mml': 'Draft'}, + custom: const {'mood': 'busy'}, ), 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. +// 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. 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'}, + custom: {'note': 'kept'}, type: 'file', ), ], cid: 'messaging:general', createdAt: DateTime.utc(2025, 12, 2), - custom: const { - 'tag': 'kept', - 'mml': 'shadowed', - 'image_labels': 'shadowed', - 'mentioned_channel_members': 'shadowed', - }, + custom: const {'tag': 'kept'}, 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'}, + custom: {'mood': 'calm'}, id: 'quoted-draft-id', text: 'Later', ), @@ -527,7 +522,7 @@ Message _expectedQuotedMessage(String attachmentId) => Message( id: attachmentId, type: 'file', actions: const [Action(name: 'reply', style: 'default', text: 'Reply', type: 'button')], - extraData: const {'note': 'kept'}, + custom: const {'note': 'kept'}, uploadState: const UploadState.success(), ), ], @@ -542,7 +537,7 @@ Message _expectedQuotedMessage(String attachmentId) => Message( updatedAt: DateTime.utc(2025, 12, 2), user: fakeUser('sender'), pollId: 'quoted-poll-id', - extraData: const {'tag': 'kept', 'cid': 'messaging:general'}, + custom: const {'tag': 'kept'}, state: MessageState.sent, restrictedVisibility: const [], moderation: const Moderation(action: ModerationAction.shadow, originalText: 'Quote'), @@ -559,13 +554,15 @@ Message _expectedQuotedMessage(String attachmentId) => Message( draft: Draft( channelCid: 'messaging:general', createdAt: DateTime.utc(2025, 12, 3), - message: DraftMessage(id: 'quoted-draft-id', text: 'Later', extraData: const {'mood': 'calm'}), + message: DraftMessage(id: 'quoted-draft-id', text: 'Later', custom: const {'mood': 'calm'}), ), + cid: 'messaging:general', html: '

Quoted

', deletedReplyCount: 1, ); -/// A generated message with the id [id] and only the fields the API always sends. +/// 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', @@ -605,9 +602,9 @@ Message expectedLeafMessage(String id) => Message( 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 [], + cid: 'messaging:general', html: '

Earlier

', deletedReplyCount: 0, ); diff --git a/packages/stream_chat/test/src/core/models/attachment_giphy_info_test.dart b/packages/stream_chat/test/src/core/models/attachment_giphy_info_test.dart index 7d044ea81e..3dbfd0f46e 100644 --- a/packages/stream_chat/test/src/core/models/attachment_giphy_info_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_giphy_info_test.dart @@ -18,13 +18,11 @@ void main() { group('GiphyInfoX', () { test('giphyInfo returns valid GiphyInfo object when data is valid', () { final attachment = Attachment( - extraData: const { - 'giphy': { - 'original': { - 'url': 'https://example.com/original.gif', - 'width': '200', - 'height': '150', - }, + giphy: const { + 'original': { + 'url': 'https://example.com/original.gif', + 'width': '200', + 'height': '150', }, }, ); @@ -47,13 +45,11 @@ void main() { test('giphyInfo returns null when the specific GiphyInfoType is missing', () { final attachment = Attachment( - extraData: const { - 'giphy': { - 'fixed_height': { - 'url': 'https://example.com/fixed_height.gif', - 'width': '100', - 'height': '100', - }, + giphy: const { + 'fixed_height': { + 'url': 'https://example.com/fixed_height.gif', + 'width': '100', + 'height': '100', }, }, ); 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 4b37bfad3d..70c17e4846 100644 --- a/packages/stream_chat/test/src/core/models/attachment_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_test.dart @@ -45,7 +45,7 @@ void main() { ); }); - test('fileName, mimeType property and extraData manipulation', () { + test('fileName, mimeType property and custom data manipulation', () { final file = AttachmentFile(size: 3, path: 'myfolder/myfile.txt'); final attachment = Attachment(file: file); @@ -59,16 +59,16 @@ void main() { 'mime_type': 'text/plain', }); - // Setting the size and mimeType using extraData should work fine + // Setting the size and mimeType using custom data should work fine var newAttachment = Attachment( - extraData: const { + custom: const { 'file_size': 6, 'mime_type': 'application/pdf', }, ); - expect(newAttachment.extraData['file_size'], 6); - expect(newAttachment.extraData['mime_type'], 'application/pdf'); + expect(newAttachment.custom['file_size'], 6); + expect(newAttachment.custom['mime_type'], 'application/pdf'); expect(newAttachment.fileSize, 6); expect(newAttachment.mimeType, 'application/pdf'); @@ -76,8 +76,8 @@ void main() { final fileTwo = AttachmentFile(size: 12, path: 'myfolder/fileTwo.pdf'); newAttachment = attachment.copyWith(file: fileTwo); - expect(newAttachment.extraData['file_size'], 12); - expect(newAttachment.extraData['mime_type'], 'application/pdf'); + expect(newAttachment.custom['file_size'], 12); + expect(newAttachment.custom['mime_type'], 'application/pdf'); expect(newAttachment.fileSize, 12); expect(newAttachment.mimeType, 'application/pdf'); @@ -85,14 +85,14 @@ void main() { final fileThree = AttachmentFile(size: 9, path: 'myfolder/fileThree.png'); newAttachment = attachment.copyWith( file: fileThree, - extraData: { + custom: { 'file_size': 88, 'mime_type': 'application/pdf', }, ); - expect(newAttachment.extraData['file_size'], 9); - expect(newAttachment.extraData['mime_type'], 'image/png'); + expect(newAttachment.custom['file_size'], 9); + expect(newAttachment.custom['mime_type'], 'image/png'); expect(newAttachment.fileSize, 9); expect(newAttachment.mimeType, 'image/png'); }); @@ -166,54 +166,81 @@ void main() { }); }); - test('Attachment stores the Giphy renditions it is given in extraData', () { + test('Attachment.fromJson reads the Giphy renditions into giphy, not custom data', () { + // 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'}, + }); + expect(attachment.custom, isEmpty); + }); + + test('Attachment.toJson writes the Giphy renditions, so an edited message keeps them', () { final attachment = Attachment( - extraData: const {'caption': 'A cat'}, + type: 'giphy', giphy: const { 'original': {'url': 'https://example.com/original.gif'}, }, ); - expect(attachment.extraData, { - 'caption': 'A cat', - 'giphy': { - 'original': {'url': 'https://example.com/original.gif'}, - }, + expect(attachment.toJson()['giphy'], { + 'original': {'url': 'https://example.com/original.gif'}, }); }); - test('Attachment replaces the extraData entry of the Giphy renditions it is given', () { + test('Attachment.toJson leaves out custom data named like a field', () { final attachment = Attachment( - extraData: const { - 'giphy': { - 'original': {'url': 'https://example.com/old.gif'}, - }, - }, + title: 'Cat', giphy: const { - 'original': {'url': 'https://example.com/new.gif'}, + 'original': {'url': 'https://example.com/original.gif'}, }, + custom: const {'title': 'custom', 'giphy': 'custom', 'caption': 'A cat'}, ); - expect(attachment.extraData, { - 'giphy': { - 'original': {'url': 'https://example.com/new.gif'}, - }, + final json = attachment.toJson(); + + expect(json['title'], 'Cat'); + expect(json['giphy'], { + 'original': {'url': 'https://example.com/original.gif'}, }); + expect(json['caption'], 'A cat'); }); - 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, + test('Attachment.fromData reads back the upload state written by toData, whatever the custom data holds', () { + final attachment = Attachment( + type: 'file', + uploadState: const UploadState.success(), + custom: const {'upload_state': 'custom', 'caption': 'A file'}, ); - expect(attachment.giphy, { - 'original': {'url': 'https://example.com/original.gif'}, - }); + final stored = Attachment.fromData(attachment.toData()); + + expect(stored.uploadState, const UploadState.success()); + expect(stored.custom['caption'], 'A file'); }); - test('Attachment reads no Giphy renditions when extraData has none', () { - expect(Attachment().giphy, isNull); + test('Attachment.copyWith keeps the Giphy renditions', () { + final attachment = Attachment( + giphy: const { + 'original': {'url': 'https://example.com/original.gif'}, + }, + ); + + expect(attachment.copyWith(title: 'Cat').giphy, attachment.giphy); + }); + + test('Attachment.fromData reads back the Giphy renditions written by toData', () { + final attachment = Attachment( + type: 'giphy', + giphy: const { + 'original': {'url': 'https://example.com/original.gif'}, + }, + ); + + expect(Attachment.fromData(attachment.toData()), attachment); }); } 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 b0a4f77c2f..335d2877e8 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 @@ -271,7 +271,7 @@ void main() { expect(Attachment.fromJson(attachment.toJson()).actions, attachment.actions); }); - test('Event.fromJson reads every reaction field from its v1 keys, with custom data in extraData', () { + test('Event.fromJson reads every reaction field from its v1 keys, with the rest in custom data', () { final json = jsonFixture('reaction.json'); final event = Event.fromJson({ @@ -289,7 +289,7 @@ void main() { emojiCode: '😮', createdAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), updatedAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), - extraData: const {'bananas': 'yes'}, + custom: const {'bananas': 'yes'}, ), ); }); @@ -307,21 +307,24 @@ void main() { 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( + 'Event.toJson writes the reaction in the request shape, with custom data not named like a field at the root', + () { + final event = Event( + type: 'reaction.new', + reaction: Reaction( + messageId: 'message-id', + type: 'wow', + user: User(id: 'user-id'), + score: 2, + emojiCode: '😮', + custom: const {'bananas': 'yes', 'emoji_code': 'custom', 'type': 'custom'}, + ), + ); + + 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 { diff --git a/packages/stream_chat/test/src/core/models/draft_message_test.dart b/packages/stream_chat/test/src/core/models/draft_message_test.dart index 0557c58759..0f870effe2 100644 --- a/packages/stream_chat/test/src/core/models/draft_message_test.dart +++ b/packages/stream_chat/test/src/core/models/draft_message_test.dart @@ -32,7 +32,7 @@ void main() { expect(draftMessage.silent, false); expect(draftMessage.poll, isNull); expect(draftMessage.pollId, isNull); - expect(draftMessage.extraData, isEmpty); + expect(draftMessage.custom, isEmpty); }); test('should create a valid instance with UUID when id is not provided', () { @@ -52,7 +52,7 @@ void main() { ]; const parentId = 'parent-123'; final quotedMessage = Message(id: 'quote-123'); - const extraData = {'key1': 'value1', 'key2': 42}; + const custom = {'key1': 'value1', 'key2': 42}; final poll = Poll( id: 'poll-123', @@ -73,7 +73,7 @@ void main() { quotedMessage: quotedMessage, silent: true, poll: poll, - extraData: extraData, + custom: custom, ); expect(fullDraftMessage.id, equals(id)); @@ -88,7 +88,7 @@ void main() { expect(fullDraftMessage.silent, isTrue); expect(fullDraftMessage.poll, equals(poll)); expect(fullDraftMessage.pollId, equals(poll.id)); - expect(fullDraftMessage.extraData, equals(extraData)); + expect(fullDraftMessage.custom, equals(custom)); }); test('should handle quotedMessageId parameter', () { @@ -125,15 +125,15 @@ void main() { expect(json['silent'], equals(false)); }); - test('should serialize extraData correctly', () { - const extraData = {'custom_field': 'custom_value', 'priority': 5}; - final messageWithExtraData = DraftMessage( + test('should serialize custom data correctly', () { + const custom = {'custom_field': 'custom_value', 'priority': 5}; + final messageWithCustom = DraftMessage( id: id, text: text, - extraData: extraData, + custom: custom, ); - final json = messageWithExtraData.toJson(); + final json = messageWithCustom.toJson(); expect(json['id'], equals(id)); expect(json['text'], equals(text)); @@ -230,7 +230,7 @@ void main() { expect(deserializedMessage.silent, isFalse); }); - test('should deserialize extraData correctly', () { + test('should deserialize custom data correctly', () { final json = { 'id': id, 'text': text, @@ -242,8 +242,8 @@ void main() { expect(deserializedMessage.id, equals(id)); expect(deserializedMessage.text, equals(text)); - expect(deserializedMessage.extraData['custom_field'], equals('custom_value')); - expect(deserializedMessage.extraData['priority'], equals(5)); + expect(deserializedMessage.custom['custom_field'], equals('custom_value')); + expect(deserializedMessage.custom['priority'], equals(5)); }); test('should deserialize poll_id correctly', () { @@ -310,7 +310,7 @@ void main() { ]; const parentId = 'parent-123'; final quotedMessage = Message(id: 'quote-123'); - const extraData = {'key1': 'value1', 'key2': 42}; + const custom = {'key1': 'value1', 'key2': 42}; final message = Message( id: 'message-123', @@ -331,7 +331,7 @@ void main() { PollOption(id: 'option-2', text: 'Option 2'), ], ), - extraData: extraData, + custom: custom, ); final draftMessage = message.toDraftMessage(); @@ -350,7 +350,7 @@ void main() { expect(draftMessage.command, equals(message.command)); expect(draftMessage.poll, equals(message.poll)); expect(draftMessage.pollId, equals(message.pollId)); - expect(draftMessage.extraData, equals(message.extraData)); + expect(draftMessage.custom, equals(message.custom)); }); test('should only include successfully uploaded attachments', () { @@ -405,7 +405,7 @@ void main() { ]; const parentId = 'parent-123'; final quotedMessage = Message(id: 'quote-123'); - const extraData = {'key1': 'value1', 'key2': 42}; + const custom = {'key1': 'value1', 'key2': 42}; final draftMessage = DraftMessage( id: 'draft-123', @@ -426,7 +426,7 @@ void main() { PollOption(id: 'option-2', text: 'Option 2'), ], ), - extraData: extraData, + custom: custom, ); final message = draftMessage.toMessage(); @@ -445,8 +445,28 @@ void main() { expect(message.command, equals(draftMessage.command)); expect(message.poll, equals(draftMessage.poll)); expect(message.pollId, equals(draftMessage.pollId)); - expect(message.extraData, equals(draftMessage.extraData)); + expect(message.custom, equals(draftMessage.custom)); }); }); + + test('DraftMessage.fromJson leaves the HTML and markup out of custom data', () { + final draft = DraftMessage.fromJson(const { + 'id': 'draft-id', + 'html': '

Hi

', + 'mml': 'Hi', + 'mood': 'busy', + }); + + expect(draft.custom, {'mood': 'busy'}); + }); + + test('DraftMessage.toJson leaves out custom data named like a field', () { + final draft = DraftMessage(text: 'Hi', custom: const {'text': 'custom', 'mood': 'busy'}); + + final json = draft.toJson(); + + expect(json['text'], 'Hi'); + expect(json['mood'], 'busy'); + }); }); } 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 391b3c3413..9292c09f33 100644 --- a/packages/stream_chat/test/src/core/models/message_test.dart +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -1,7 +1,5 @@ // 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'; @@ -84,7 +82,7 @@ void main() { showInChannel: true, parentId: 'parentId', restrictedVisibility: const ['user-id-3'], - extraData: const {'hey': 'test'}, + custom: const {'hey': 'test'}, ); expect( @@ -379,11 +377,11 @@ void main() { ); }); - test('a custom field orders by the message extra data', () { + test('a custom field orders by the message custom data', () { expectOrders( MessageSearchSortField.custom('priority'), - createTestMessage(id: 'a', extraData: const {'priority': 1}), - createTestMessage(id: 'b', extraData: const {'priority': 10}), + createTestMessage(id: 'a', custom: const {'priority': 1}), + createTestMessage(id: 'b', custom: const {'priority': 10}), ); }); @@ -506,88 +504,59 @@ void main() { }); }); - test('Message stores in extraData only the HTML, markup, image labels and deleted reply count it is given', () { - const extraData = {'priority': 'high'}; + test('Message.fromJson reads the channel id, HTML, markup and deleted reply count into their fields', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'cid': 'messaging:general', + 'html': '

Hi

', + 'mml': 'Hi', + 'deleted_reply_count': 2, + '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'], - }, + expect(message.cid, 'messaging:general'); + expect(message.html, '

Hi

'); + expect(message.mml, 'Hi'); + expect(message.deletedReplyCount, 2); + expect(message.custom, {'priority': 'high'}); + }); + + test('Message.fromJson leaves image labels and mentioned channel members out of custom data', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'image_labels': { + 'cat.png': ['cat'], }, - {'priority': 'high', 'deleted_reply_count': 2}, - ]); + 'mentioned_channel_members': {}, + 'priority': 'high', + }); + + expect(message.custom, {'priority': 'high'}); }); - 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, - }, + test('Message.toJson leaves out custom data named like a field', () { + final message = Message(text: 'Hi', custom: const {'text': 'custom', 'priority': 'high'}); + + final json = message.toJson(); + + expect(json['text'], 'Hi'); + expect(json['priority'], 'high'); + }); + + test('Message.copyWith keeps the channel id, HTML, markup and deleted reply count', () { + final message = Message(cid: 'messaging:general', html: '

Hi

', mml: 'Hi', deletedReplyCount: 2); + + expect( + message.copyWith(text: 'Hi'), + Message( + id: message.id, + text: 'Hi', + cid: 'messaging:general', 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( @@ -640,15 +609,6 @@ void main() { 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', () { @@ -1197,7 +1157,7 @@ Message createTestMessage({ DateTime? createdAt, DateTime? updatedAt, User? user, - Map? extraData, + Map? custom, List? restrictedVisibility, Poll? poll, String? pollId, @@ -1215,7 +1175,7 @@ Message createTestMessage({ localCreatedAt: createdAt, localUpdatedAt: updatedAt, user: user, - extraData: extraData ?? {}, + custom: custom ?? {}, restrictedVisibility: restrictedVisibility, poll: poll, pollId: pollId, 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 2e9c1ffb05..26adcb5d21 100644 --- a/packages/stream_chat/test/src/core/models/reaction_test.dart +++ b/packages/stream_chat/test/src/core/models/reaction_test.dart @@ -54,7 +54,7 @@ void main() { emojiCode: '😂', createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), updatedAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), - extraData: {}, + custom: {}, messageId: 'test', score: 2, user: User( @@ -75,7 +75,7 @@ void main() { newReaction.updatedAt, DateTime.parse('2021-01-28T22:17:31.108742Z'), ); - expect(newReaction.extraData, {}); + expect(newReaction.custom, {}); expect(newReaction.messageId, 'test'); expect(newReaction.score, 2); expect( @@ -130,7 +130,7 @@ void main() { newReaction.updatedAt, DateTime.parse('2021-01-28T22:17:31.108742Z'), ); - expect(newReaction.extraData, {}); + expect(newReaction.custom, {}); expect(newReaction.messageId, 'test'); expect(newReaction.score, 2); expect( diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart index 832bc63686..f93f90ac97 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -177,7 +177,7 @@ class _FileAttachmentSubtitle extends StatelessWidget { final colorScheme = context.streamColorScheme; - final attachmentSize = attachment.file?.size ?? attachment.extraData['file_size']; + final attachmentSize = attachment.file?.size ?? attachment.custom['file_size']; return attachment.uploadState.when( success: () => Text(fileSize(attachmentSize)), diff --git a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_io.dart b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_io.dart index 814216648a..eb9ad31afb 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_io.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_io.dart @@ -111,7 +111,7 @@ class StreamAttachmentHandler extends StreamAttachmentHandlerBase { final duration = videoController.value.duration; if (duration.inSeconds > 0) { return attachment.copyWith( - extraData: {...attachment.extraData, 'duration': duration.inSeconds}, + custom: {...attachment.custom, 'duration': duration.inSeconds}, ); } } catch (_) { diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart index bb8d865535..3e0d212948 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart @@ -103,7 +103,7 @@ class _DefaultStreamMessageComposerInputHeader extends StatelessWidget { final attachment = voiceRecordings.elementAtOrNull(index); if (attachment == null) return child; - final durationSecs = attachment.extraData['duration'] as num?; + final durationSecs = attachment.custom['duration'] as num?; final duration = durationSecs != null ? Duration(seconds: durationSecs.round()) : null; final a11y = context.translations.accessibility; diff --git a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart index b38ce4c690..645af70bef 100644 --- a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart +++ b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart @@ -10,7 +10,7 @@ import '../../stream_chat_flutter.dart'; /// white outside-aligned border so it reads against any thumbnail. /// - A "video" [StreamMediaBadge] pinned to the bottom-leading corner when /// [attachment] is a video; the badge surfaces the video's duration in -/// `M:SS` format when present on `extraData['duration']`. +/// `M:SS` format when present on `custom['duration']`. /// /// The tile is tappable when [onTap] / [onLongPress] is set; the ripple is /// drawn over the media via a transparent [Material]. @@ -67,7 +67,7 @@ class StreamMediaGalleryItem extends StatelessWidget { Duration? videoDuration; if (isVideo) { - final secs = attachment.extraData['duration'] as num?; + final secs = attachment.custom['duration'] as num?; if (secs != null) videoDuration = Duration(seconds: secs.round()); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart index 8e27ac104b..c378e0bee8 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart @@ -297,25 +297,25 @@ extension StreamImagePickerX on StreamAttachmentPickerController { bytes: cachedFile.readAsBytesSync(), ); - final extraDataMap = {}; + final customMap = {}; final mimeType = file.mediaType?.mimeType; if (mimeType != null) { - extraDataMap['mime_type'] = mimeType; + customMap['mime_type'] = mimeType; } - extraDataMap['file_size'] = file.size!; + customMap['file_size'] = file.size!; if (type == AssetType.video) { - extraDataMap['duration'] = asset.videoDuration.inSeconds; + customMap['duration'] = asset.videoDuration.inSeconds; } final attachment = Attachment( id: asset.id, file: file, type: asset.type.toAttachmentType(), - extraData: extraDataMap, + custom: customMap, ); return addAttachment(attachment); diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart index ca23ecd0f6..d4e1081962 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart @@ -299,7 +299,7 @@ extension on RecordStateRecording { final attachment = Attachment( file: attachmentFile, type: AttachmentType.voiceRecording, - extraData: { + custom: { 'duration': duration.inMilliseconds / 1000, 'waveform_data': sampling.resampleWaveformData(waveform, 100), }, diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart index 48d7acef99..55ed2cf2d7 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart @@ -88,7 +88,7 @@ class DefaultMessageComposerAttachment extends StatelessWidget { } Widget _buildFileAttachment(BuildContext context) { - final fileSizeBytes = attachment.file?.size ?? attachment.extraData['file_size']; + final fileSizeBytes = attachment.file?.size ?? attachment.custom['file_size']; final mimeType = attachment.file?.mediaType?.mimeType; return StreamMessageComposerFileAttachment( @@ -223,7 +223,7 @@ class StreamMediaAttachmentBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - final durationSecs = attachment.extraData['duration'] as num?; + final durationSecs = attachment.custom['duration'] as num?; final videoDuration = durationSecs != null ? Duration(seconds: durationSecs.round()) : null; final a11y = context.translations.accessibility; diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 1deaab0667..96ad12966c 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -140,20 +140,20 @@ extension XFileX on XFile { Future toAttachment({required String type}) async { final file = await toAttachmentFile; - final extraDataMap = {}; + final customMap = {}; final mimeType = this.mimeType ?? file.mediaType?.mimeType; if (mimeType != null) { - extraDataMap['mime_type'] = mimeType; + customMap['mime_type'] = mimeType; } - extraDataMap['file_size'] = file.size!; + customMap['file_size'] = file.size!; final attachment = Attachment( file: file, type: type, - extraData: extraDataMap, + custom: customMap, ); return attachment; @@ -718,7 +718,7 @@ extension VoiceRecordingAttachmentExtension on Attachment { /// Returns the duration of the voice recording attachment if available else /// returns [Duration.zero]. Duration get duration { - final duration = extraData['duration'] as num?; + final duration = custom['duration'] as num?; if (duration == null) return Duration.zero; return Duration(milliseconds: (duration * 1000).round()); @@ -727,7 +727,7 @@ extension VoiceRecordingAttachmentExtension on Attachment { /// Returns the waveform data of the voice recording attachment if available /// else returns an empty list. List get waveform { - final waveform = extraData['waveform_data'] as List?; + final waveform = custom['waveform_data'] as List?; if (waveform == null) return []; return [...waveform.map((e) => double.tryParse(e.toString())).nonNulls]; diff --git a/packages/stream_chat_flutter/test/src/attachment/attachment_handler_test.dart b/packages/stream_chat_flutter/test/src/attachment/attachment_handler_test.dart index b7f449e271..7fde6ebfe4 100644 --- a/packages/stream_chat_flutter/test/src/attachment/attachment_handler_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/attachment_handler_test.dart @@ -10,7 +10,7 @@ void main() { final attachment = Attachment( title: 'test image attachment', type: 'image', - extraData: const { + custom: const { 'mime_type': 'png', }, ); @@ -29,7 +29,7 @@ void main() { final attachment = Attachment( title: 'test giphy attachment', type: 'giphy', - extraData: const { + custom: const { 'original': 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', }, ); diff --git a/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart index 07aa6c1991..9f9a3127a3 100644 --- a/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart @@ -32,7 +32,7 @@ void main() { file: Attachment( type: 'file', title: 'example.pdf', - extraData: const { + custom: const { 'mime_type': 'pdf', }, ), diff --git a/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart index c7bde80ec0..9c855c273d 100644 --- a/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart @@ -19,7 +19,7 @@ void main() { type: 'image', title: 'example.png', imageUrl: 'https://logowik.com/content/uploads/images/flutter5786.jpg', - extraData: const { + custom: const { 'mime_type': 'png', }, ), @@ -27,7 +27,7 @@ void main() { type: 'image', title: 'example.png', imageUrl: 'https://logowik.com/content/uploads/images/flutter5786.jpg', - extraData: const { + custom: const { 'mime_type': 'png', }, ), diff --git a/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart index 6035125414..525f1a28f5 100644 --- a/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart @@ -33,7 +33,7 @@ void main() { type: 'giphy', title: 'example.gif', imageUrl: 'https://media.giphy.com/media/35H0pwQNaO2iLTnnBf/giphy.gif', - extraData: const { + custom: const { 'mime_type': 'gif', }, uploadState: const UploadState.success(), diff --git a/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart index 0aaca1f2e2..fd70afab5c 100644 --- a/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart @@ -33,7 +33,7 @@ void main() { type: 'image', title: 'example.png', imageUrl: 'https://logowik.com/content/uploads/images/flutter5786.jpg', - extraData: const { + custom: const { 'mime_type': 'png', }, ), diff --git a/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart index 4d9edc66dc..0389c10e7e 100644 --- a/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart @@ -15,7 +15,7 @@ void main() { size: 10000, path: 'voice_recordings/test1.m4a', ), - extraData: { + custom: { 'duration': 300, 'waveform_data': List.filled(50, 0.5), }, @@ -28,7 +28,7 @@ void main() { size: 30000, path: 'voice_recordings/test2.m4a', ), - extraData: { + custom: { 'duration': 900, 'waveform_data': List.filled(50, 0.5), }, diff --git a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart index b431ac94d2..4510271bb5 100644 --- a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart @@ -326,7 +326,7 @@ void main() { attachments: [ Attachment( type: AttachmentType.voiceRecording, - extraData: const {'duration': 125}, + custom: const {'duration': 125}, ), ], ); @@ -1033,7 +1033,7 @@ void main() { attachments: [ Attachment( type: 'product', - extraData: const {'title': 'iPhone'}, + custom: const {'title': 'iPhone'}, ), ], ); @@ -1354,7 +1354,7 @@ void main() { attachments: [ Attachment( type: 'voiceRecording', - extraData: const {'duration': 83.0}, + custom: const {'duration': 83.0}, ), ], ); @@ -1584,7 +1584,7 @@ void main() { attachments: [ Attachment( type: AttachmentType.voiceRecording, - extraData: const {'duration': 83.0}, + custom: const {'duration': 83.0}, ), ], ); @@ -1680,7 +1680,7 @@ void main() { attachments: [ Attachment( type: AttachmentType.voiceRecording, - extraData: const {'duration': 83.0}, + custom: const {'duration': 83.0}, ), ], ); @@ -1879,7 +1879,7 @@ class _CustomMessagePreviewFormatter extends StreamMessagePreviewFormatter { final attachment = attachments.firstOrNull; if (attachment?.type == 'product') { - final title = attachment?.extraData['title'] as String?; + final title = attachment?.custom['title'] as String?; return TextSpan(text: '🛍️ ${title ?? "Product"}'); } diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart index f4dc96144f..f622a533c6 100644 --- a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart @@ -167,8 +167,8 @@ void main() { await controller.stopRecord(); final stoppedState = controller.value as RecordStateStopped; - expect(stoppedState.audioRecording.extraData['duration'], isNotNull); - expect(stoppedState.audioRecording.extraData['waveform_data'], isNotNull); + expect(stoppedState.audioRecording.custom['duration'], isNotNull); + expect(stoppedState.audioRecording.custom['waveform_data'], isNotNull); }); }); diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart index 05a5defd7b..26c34ae8eb 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_content_insertion_test.dart @@ -126,7 +126,7 @@ void main() { expect(attachment.type, AttachmentType.image); expect(attachment.file?.name, 'cat.gif'); expect(attachment.file?.size, _gifBytes.length); - expect(attachment.extraData['mime_type'], 'image/gif'); + expect(attachment.custom['mime_type'], 'image/gif'); }); testWidgets('names the file from the MIME type when the URI has no extension', (tester) async { @@ -137,7 +137,7 @@ void main() { final attachment = controller.attachments.single; expect(attachment.file?.name, endsWith('.webp')); - expect(attachment.extraData['mime_type'], 'image/webp'); + expect(attachment.custom['mime_type'], 'image/webp'); }); testWidgets('ignores content the keyboard handed over without data', (tester) async { @@ -214,7 +214,7 @@ void main() { final attachment = controller.attachments.single; expect(attachment.file?.name, endsWith('.gif')); - expect(attachment.extraData['mime_type'], 'image/gif'); + expect(attachment.custom['mime_type'], 'image/gif'); }); }); } diff --git a/packages/stream_chat_flutter_core/test/message_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_list_core_test.dart index acfed462e5..ffdded80d9 100644 --- a/packages/stream_chat_flutter_core/test/message_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/message_list_core_test.dart @@ -29,7 +29,7 @@ void main() { createdAt: DateTime.now(), replyCount: i, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$i', pinned: true, pinnedAt: DateTime.now(), @@ -49,7 +49,7 @@ void main() { createdAt: DateTime.now(), replyCount: i, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$i', pinned: true, pinnedAt: DateTime.now(), diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart index d98ecdfcea..00246adfd6 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart @@ -59,7 +59,7 @@ class DriftChatDatabase extends _$DriftChatDatabase { // Band the version by SDK major, and bump the offset whenever you change or // add a table definition. @override - int get schemaVersion => 1100 + 7; + int get schemaVersion => 1100 + 8; // Store DateTime as ISO-8601 text to preserve sub-second precision. @override diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart index 7bc61450e7..98b52db30d 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart @@ -1719,14 +1719,43 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, MessageEnti ).withConverter?>( $MessagesTable.$converterrestrictedVisibilityn, ); + static const VerificationMeta _htmlMeta = const VerificationMeta('html'); @override - late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( - 'extra_data', + late final GeneratedColumn html = GeneratedColumn( + 'html', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, - ).withConverter?>($MessagesTable.$converterextraDatan); + ); + static const VerificationMeta _mmlMeta = const VerificationMeta('mml'); + @override + late final GeneratedColumn mml = GeneratedColumn( + 'mml', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _deletedReplyCountMeta = const VerificationMeta( + 'deletedReplyCount', + ); + @override + late final GeneratedColumn deletedReplyCount = GeneratedColumn( + 'deleted_reply_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter?, String> custom = GeneratedColumn( + 'custom', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($MessagesTable.$convertercustomn); @override List get $columns => [ id, @@ -1765,7 +1794,10 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, MessageEnti channelCid, i18n, restrictedVisibility, - extraData, + html, + mml, + deletedReplyCount, + custom, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1995,6 +2027,27 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, MessageEnti } else if (isInserting) { context.missing(_channelCidMeta); } + if (data.containsKey('html')) { + context.handle( + _htmlMeta, + html.isAcceptableOrUnknown(data['html']!, _htmlMeta), + ); + } + if (data.containsKey('mml')) { + context.handle( + _mmlMeta, + mml.isAcceptableOrUnknown(data['mml']!, _mmlMeta), + ); + } + if (data.containsKey('deleted_reply_count')) { + context.handle( + _deletedReplyCountMeta, + deletedReplyCount.isAcceptableOrUnknown( + data['deleted_reply_count']!, + _deletedReplyCountMeta, + ), + ); + } return context; } @@ -2164,10 +2217,22 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, MessageEnti data['${effectivePrefix}restricted_visibility'], ), ), - extraData: $MessagesTable.$converterextraDatan.fromSql( + html: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}html'], + ), + mml: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mml'], + ), + deletedReplyCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}deleted_reply_count'], + ), + custom: $MessagesTable.$convertercustomn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}custom'], ), ), ); @@ -2201,9 +2266,9 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, MessageEnti static TypeConverter?, String?> $converterrestrictedVisibilityn = NullAwareTypeConverter.wrap( $converterrestrictedVisibility, ); - static TypeConverter, String> $converterextraData = MapConverter(); - static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( - $converterextraData, + static TypeConverter, String> $convertercustom = MapConverter(); + static TypeConverter?, String?> $convertercustomn = NullAwareTypeConverter.wrap( + $convertercustom, ); } @@ -2318,8 +2383,17 @@ class MessageEntity extends DataClass implements Insertable { /// The list of user ids that should be able to see the message. final List? restrictedVisibility; - /// Message custom extraData - final Map? extraData; + /// The message rendered as HTML. + final String? html; + + /// The message markup language (MML) source of the message. + final String? mml; + + /// The number of replies to the message that were deleted. + final int? deletedReplyCount; + + /// Custom data attached to the message. + final Map? custom; const MessageEntity({ required this.id, this.messageText, @@ -2357,7 +2431,10 @@ class MessageEntity extends DataClass implements Insertable { required this.channelCid, this.i18n, this.restrictedVisibility, - this.extraData, + this.html, + this.mml, + this.deletedReplyCount, + this.custom, }); @override Map toColumns(bool nullToAbsent) { @@ -2474,9 +2551,18 @@ class MessageEntity extends DataClass implements Insertable { ), ); } - if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $MessagesTable.$converterextraDatan.toSql(extraData), + if (!nullToAbsent || html != null) { + map['html'] = Variable(html); + } + if (!nullToAbsent || mml != null) { + map['mml'] = Variable(mml); + } + if (!nullToAbsent || deletedReplyCount != null) { + map['deleted_reply_count'] = Variable(deletedReplyCount); + } + if (!nullToAbsent || custom != null) { + map['custom'] = Variable( + $MessagesTable.$convertercustomn.toSql(custom), ); } return map; @@ -2536,7 +2622,10 @@ class MessageEntity extends DataClass implements Insertable { restrictedVisibility: serializer.fromJson?>( json['restrictedVisibility'], ), - extraData: serializer.fromJson?>(json['extraData']), + html: serializer.fromJson(json['html']), + mml: serializer.fromJson(json['mml']), + deletedReplyCount: serializer.fromJson(json['deletedReplyCount']), + custom: serializer.fromJson?>(json['custom']), ); } @override @@ -2585,7 +2674,10 @@ class MessageEntity extends DataClass implements Insertable { 'restrictedVisibility': serializer.toJson?>( restrictedVisibility, ), - 'extraData': serializer.toJson?>(extraData), + 'html': serializer.toJson(html), + 'mml': serializer.toJson(mml), + 'deletedReplyCount': serializer.toJson(deletedReplyCount), + 'custom': serializer.toJson?>(custom), }; } @@ -2626,7 +2718,10 @@ class MessageEntity extends DataClass implements Insertable { String? channelCid, Value?> i18n = const Value.absent(), Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), + Value html = const Value.absent(), + Value mml = const Value.absent(), + Value deletedReplyCount = const Value.absent(), + Value?> custom = const Value.absent(), }) => MessageEntity( id: id ?? this.id, messageText: messageText.present ? messageText.value : this.messageText, @@ -2664,7 +2759,10 @@ class MessageEntity extends DataClass implements Insertable { channelCid: channelCid ?? this.channelCid, i18n: i18n.present ? i18n.value : this.i18n, restrictedVisibility: restrictedVisibility.present ? restrictedVisibility.value : this.restrictedVisibility, - extraData: extraData.present ? extraData.value : this.extraData, + html: html.present ? html.value : this.html, + mml: mml.present ? mml.value : this.mml, + deletedReplyCount: deletedReplyCount.present ? deletedReplyCount.value : this.deletedReplyCount, + custom: custom.present ? custom.value : this.custom, ); MessageEntity copyWithCompanion(MessagesCompanion data) { return MessageEntity( @@ -2708,7 +2806,10 @@ class MessageEntity extends DataClass implements Insertable { restrictedVisibility: data.restrictedVisibility.present ? data.restrictedVisibility.value : this.restrictedVisibility, - extraData: data.extraData.present ? data.extraData.value : this.extraData, + html: data.html.present ? data.html.value : this.html, + mml: data.mml.present ? data.mml.value : this.mml, + deletedReplyCount: data.deletedReplyCount.present ? data.deletedReplyCount.value : this.deletedReplyCount, + custom: data.custom.present ? data.custom.value : this.custom, ); } @@ -2751,7 +2852,10 @@ class MessageEntity extends DataClass implements Insertable { ..write('channelCid: $channelCid, ') ..write('i18n: $i18n, ') ..write('restrictedVisibility: $restrictedVisibility, ') - ..write('extraData: $extraData') + ..write('html: $html, ') + ..write('mml: $mml, ') + ..write('deletedReplyCount: $deletedReplyCount, ') + ..write('custom: $custom') ..write(')')) .toString(); } @@ -2794,7 +2898,10 @@ class MessageEntity extends DataClass implements Insertable { channelCid, i18n, restrictedVisibility, - extraData, + html, + mml, + deletedReplyCount, + custom, ]); @override bool operator ==(Object other) => @@ -2836,7 +2943,10 @@ class MessageEntity extends DataClass implements Insertable { other.channelCid == this.channelCid && other.i18n == this.i18n && other.restrictedVisibility == this.restrictedVisibility && - other.extraData == this.extraData); + other.html == this.html && + other.mml == this.mml && + other.deletedReplyCount == this.deletedReplyCount && + other.custom == this.custom); } class MessagesCompanion extends UpdateCompanion { @@ -2876,7 +2986,10 @@ class MessagesCompanion extends UpdateCompanion { final Value channelCid; final Value?> i18n; final Value?> restrictedVisibility; - final Value?> extraData; + final Value html; + final Value mml; + final Value deletedReplyCount; + final Value?> custom; final Value rowid; const MessagesCompanion({ this.id = const Value.absent(), @@ -2915,7 +3028,10 @@ class MessagesCompanion extends UpdateCompanion { this.channelCid = const Value.absent(), this.i18n = const Value.absent(), this.restrictedVisibility = const Value.absent(), - this.extraData = const Value.absent(), + this.html = const Value.absent(), + this.mml = const Value.absent(), + this.deletedReplyCount = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }); MessagesCompanion.insert({ @@ -2955,14 +3071,17 @@ class MessagesCompanion extends UpdateCompanion { required String channelCid, this.i18n = const Value.absent(), this.restrictedVisibility = const Value.absent(), - this.extraData = const Value.absent(), + this.html = const Value.absent(), + this.mml = const Value.absent(), + this.deletedReplyCount = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), attachments = Value(attachments), state = Value(state), mentionedUsers = Value(mentionedUsers), channelCid = Value(channelCid); - static Insertable custom({ + static Insertable createCustom({ Expression? id, Expression? messageText, Expression? attachments, @@ -2999,7 +3118,10 @@ class MessagesCompanion extends UpdateCompanion { Expression? channelCid, Expression? i18n, Expression? restrictedVisibility, - Expression? extraData, + Expression? html, + Expression? mml, + Expression? deletedReplyCount, + Expression? custom, Expression? rowid, }) { return RawValuesInsertable({ @@ -3039,7 +3161,10 @@ class MessagesCompanion extends UpdateCompanion { if (channelCid != null) 'channel_cid': channelCid, if (i18n != null) 'i18n': i18n, if (restrictedVisibility != null) 'restricted_visibility': restrictedVisibility, - if (extraData != null) 'extra_data': extraData, + if (html != null) 'html': html, + if (mml != null) 'mml': mml, + if (deletedReplyCount != null) 'deleted_reply_count': deletedReplyCount, + if (custom != null) 'custom': custom, if (rowid != null) 'rowid': rowid, }); } @@ -3081,7 +3206,10 @@ class MessagesCompanion extends UpdateCompanion { Value? channelCid, Value?>? i18n, Value?>? restrictedVisibility, - Value?>? extraData, + Value? html, + Value? mml, + Value? deletedReplyCount, + Value?>? custom, Value? rowid, }) { return MessagesCompanion( @@ -3121,7 +3249,10 @@ class MessagesCompanion extends UpdateCompanion { channelCid: channelCid ?? this.channelCid, i18n: i18n ?? this.i18n, restrictedVisibility: restrictedVisibility ?? this.restrictedVisibility, - extraData: extraData ?? this.extraData, + html: html ?? this.html, + mml: mml ?? this.mml, + deletedReplyCount: deletedReplyCount ?? this.deletedReplyCount, + custom: custom ?? this.custom, rowid: rowid ?? this.rowid, ); } @@ -3259,9 +3390,18 @@ class MessagesCompanion extends UpdateCompanion { ), ); } - if (extraData.present) { - map['extra_data'] = Variable( - $MessagesTable.$converterextraDatan.toSql(extraData.value), + if (html.present) { + map['html'] = Variable(html.value); + } + if (mml.present) { + map['mml'] = Variable(mml.value); + } + if (deletedReplyCount.present) { + map['deleted_reply_count'] = Variable(deletedReplyCount.value); + } + if (custom.present) { + map['custom'] = Variable( + $MessagesTable.$convertercustomn.toSql(custom.value), ); } if (rowid.present) { @@ -3309,7 +3449,10 @@ class MessagesCompanion extends UpdateCompanion { ..write('channelCid: $channelCid, ') ..write('i18n: $i18n, ') ..write('restrictedVisibility: $restrictedVisibility, ') - ..write('extraData: $extraData, ') + ..write('html: $html, ') + ..write('mml: $mml, ') + ..write('deletedReplyCount: $deletedReplyCount, ') + ..write('custom: $custom, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -3466,16 +3609,13 @@ class $DraftMessagesTable extends DraftMessages with TableInfo<$DraftMessagesTab ), ); @override - late final GeneratedColumnWithTypeConverter?, String> extraData = - GeneratedColumn( - 'extra_data', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ).withConverter?>( - $DraftMessagesTable.$converterextraDatan, - ); + late final GeneratedColumnWithTypeConverter?, String> custom = GeneratedColumn( + 'custom', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($DraftMessagesTable.$convertercustomn); @override List get $columns => [ id, @@ -3491,7 +3631,7 @@ class $DraftMessagesTable extends DraftMessages with TableInfo<$DraftMessagesTab silent, createdAt, channelCid, - extraData, + custom, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -3646,10 +3786,10 @@ class $DraftMessagesTable extends DraftMessages with TableInfo<$DraftMessagesTab DriftSqlType.string, data['${effectivePrefix}channel_cid'], )!, - extraData: $DraftMessagesTable.$converterextraDatan.fromSql( + custom: $DraftMessagesTable.$convertercustomn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}custom'], ), ), ); @@ -3662,9 +3802,9 @@ class $DraftMessagesTable extends DraftMessages with TableInfo<$DraftMessagesTab static TypeConverter, String> $converterattachments = ListConverter(); static TypeConverter, String> $convertermentionedUsers = ListConverter(); - static TypeConverter, String> $converterextraData = MapConverter(); - static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( - $converterextraData, + static TypeConverter, String> $convertercustom = MapConverter(); + static TypeConverter?, String?> $convertercustomn = NullAwareTypeConverter.wrap( + $convertercustom, ); } @@ -3709,8 +3849,8 @@ class DraftMessageEntity extends DataClass implements Insertable? extraData; + /// Custom data attached to the draft message. + final Map? custom; const DraftMessageEntity({ required this.id, this.messageText, @@ -3725,7 +3865,7 @@ class DraftMessageEntity extends DataClass implements Insertable toColumns(bool nullToAbsent) { @@ -3763,9 +3903,9 @@ class DraftMessageEntity extends DataClass implements Insertable(silent); map['created_at'] = Variable(createdAt); map['channel_cid'] = Variable(channelCid); - if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $DraftMessagesTable.$converterextraDatan.toSql(extraData), + if (!nullToAbsent || custom != null) { + map['custom'] = Variable( + $DraftMessagesTable.$convertercustomn.toSql(custom), ); } return map; @@ -3790,7 +3930,7 @@ class DraftMessageEntity extends DataClass implements Insertable(json['silent']), createdAt: serializer.fromJson(json['createdAt']), channelCid: serializer.fromJson(json['channelCid']), - extraData: serializer.fromJson?>(json['extraData']), + custom: serializer.fromJson?>(json['custom']), ); } @override @@ -3810,7 +3950,7 @@ class DraftMessageEntity extends DataClass implements Insertable(silent), 'createdAt': serializer.toJson(createdAt), 'channelCid': serializer.toJson(channelCid), - 'extraData': serializer.toJson?>(extraData), + 'custom': serializer.toJson?>(custom), }; } @@ -3828,7 +3968,7 @@ class DraftMessageEntity extends DataClass implements Insertable?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), }) => DraftMessageEntity( id: id ?? this.id, messageText: messageText.present ? messageText.value : this.messageText, @@ -3843,7 +3983,7 @@ class DraftMessageEntity extends DataClass implements Insertable @@ -3919,7 +4059,7 @@ class DraftMessageEntity extends DataClass implements Insertable { @@ -3936,7 +4076,7 @@ class DraftMessagesCompanion extends UpdateCompanion { final Value silent; final Value createdAt; final Value channelCid; - final Value?> extraData; + final Value?> custom; final Value rowid; const DraftMessagesCompanion({ this.id = const Value.absent(), @@ -3952,7 +4092,7 @@ class DraftMessagesCompanion extends UpdateCompanion { this.silent = const Value.absent(), this.createdAt = const Value.absent(), this.channelCid = const Value.absent(), - this.extraData = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }); DraftMessagesCompanion.insert({ @@ -3969,13 +4109,13 @@ class DraftMessagesCompanion extends UpdateCompanion { this.silent = const Value.absent(), this.createdAt = const Value.absent(), required String channelCid, - this.extraData = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), attachments = Value(attachments), mentionedUsers = Value(mentionedUsers), channelCid = Value(channelCid); - static Insertable custom({ + static Insertable createCustom({ Expression? id, Expression? messageText, Expression? attachments, @@ -3989,7 +4129,7 @@ class DraftMessagesCompanion extends UpdateCompanion { Expression? silent, Expression? createdAt, Expression? channelCid, - Expression? extraData, + Expression? custom, Expression? rowid, }) { return RawValuesInsertable({ @@ -4006,7 +4146,7 @@ class DraftMessagesCompanion extends UpdateCompanion { if (silent != null) 'silent': silent, if (createdAt != null) 'created_at': createdAt, if (channelCid != null) 'channel_cid': channelCid, - if (extraData != null) 'extra_data': extraData, + if (custom != null) 'custom': custom, if (rowid != null) 'rowid': rowid, }); } @@ -4025,7 +4165,7 @@ class DraftMessagesCompanion extends UpdateCompanion { Value? silent, Value? createdAt, Value? channelCid, - Value?>? extraData, + Value?>? custom, Value? rowid, }) { return DraftMessagesCompanion( @@ -4042,7 +4182,7 @@ class DraftMessagesCompanion extends UpdateCompanion { silent: silent ?? this.silent, createdAt: createdAt ?? this.createdAt, channelCid: channelCid ?? this.channelCid, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, rowid: rowid ?? this.rowid, ); } @@ -4095,9 +4235,9 @@ class DraftMessagesCompanion extends UpdateCompanion { if (channelCid.present) { map['channel_cid'] = Variable(channelCid.value); } - if (extraData.present) { - map['extra_data'] = Variable( - $DraftMessagesTable.$converterextraDatan.toSql(extraData.value), + if (custom.present) { + map['custom'] = Variable( + $DraftMessagesTable.$convertercustomn.toSql(custom.value), ); } if (rowid.present) { @@ -4122,7 +4262,7 @@ class DraftMessagesCompanion extends UpdateCompanion { ..write('silent: $silent, ') ..write('createdAt: $createdAt, ') ..write('channelCid: $channelCid, ') - ..write('extraData: $extraData, ') + ..write('custom: $custom, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -5094,16 +5234,45 @@ class $PinnedMessagesTable extends PinnedMessages with TableInfo<$PinnedMessages ).withConverter?>( $PinnedMessagesTable.$converterrestrictedVisibilityn, ); + static const VerificationMeta _htmlMeta = const VerificationMeta('html'); @override - late final GeneratedColumnWithTypeConverter?, String> extraData = + late final GeneratedColumn html = GeneratedColumn( + 'html', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _mmlMeta = const VerificationMeta('mml'); + @override + late final GeneratedColumn mml = GeneratedColumn( + 'mml', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _deletedReplyCountMeta = const VerificationMeta( + 'deletedReplyCount', + ); + @override + late final GeneratedColumn deletedReplyCount = GeneratedColumn( + 'deleted_reply_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter?, String> custom = GeneratedColumn( - 'extra_data', + 'custom', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ).withConverter?>( - $PinnedMessagesTable.$converterextraDatan, + $PinnedMessagesTable.$convertercustomn, ); @override List get $columns => [ @@ -5143,7 +5312,10 @@ class $PinnedMessagesTable extends PinnedMessages with TableInfo<$PinnedMessages channelCid, i18n, restrictedVisibility, - extraData, + html, + mml, + deletedReplyCount, + custom, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -5373,6 +5545,27 @@ class $PinnedMessagesTable extends PinnedMessages with TableInfo<$PinnedMessages } else if (isInserting) { context.missing(_channelCidMeta); } + if (data.containsKey('html')) { + context.handle( + _htmlMeta, + html.isAcceptableOrUnknown(data['html']!, _htmlMeta), + ); + } + if (data.containsKey('mml')) { + context.handle( + _mmlMeta, + mml.isAcceptableOrUnknown(data['mml']!, _mmlMeta), + ); + } + if (data.containsKey('deleted_reply_count')) { + context.handle( + _deletedReplyCountMeta, + deletedReplyCount.isAcceptableOrUnknown( + data['deleted_reply_count']!, + _deletedReplyCountMeta, + ), + ); + } return context; } @@ -5542,10 +5735,22 @@ class $PinnedMessagesTable extends PinnedMessages with TableInfo<$PinnedMessages data['${effectivePrefix}restricted_visibility'], ), ), - extraData: $PinnedMessagesTable.$converterextraDatan.fromSql( + html: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}html'], + ), + mml: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mml'], + ), + deletedReplyCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}deleted_reply_count'], + ), + custom: $PinnedMessagesTable.$convertercustomn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}custom'], ), ), ); @@ -5579,9 +5784,9 @@ class $PinnedMessagesTable extends PinnedMessages with TableInfo<$PinnedMessages static TypeConverter?, String?> $converterrestrictedVisibilityn = NullAwareTypeConverter.wrap( $converterrestrictedVisibility, ); - static TypeConverter, String> $converterextraData = MapConverter(); - static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( - $converterextraData, + static TypeConverter, String> $convertercustom = MapConverter(); + static TypeConverter?, String?> $convertercustomn = NullAwareTypeConverter.wrap( + $convertercustom, ); } @@ -5696,8 +5901,17 @@ class PinnedMessageEntity extends DataClass implements Insertable? restrictedVisibility; - /// Message custom extraData - final Map? extraData; + /// The message rendered as HTML. + final String? html; + + /// The message markup language (MML) source of the message. + final String? mml; + + /// The number of replies to the message that were deleted. + final int? deletedReplyCount; + + /// Custom data attached to the message. + final Map? custom; const PinnedMessageEntity({ required this.id, this.messageText, @@ -5735,7 +5949,10 @@ class PinnedMessageEntity extends DataClass implements Insertable toColumns(bool nullToAbsent) { @@ -5856,9 +6073,18 @@ class PinnedMessageEntity extends DataClass implements Insertable( - $PinnedMessagesTable.$converterextraDatan.toSql(extraData), + if (!nullToAbsent || html != null) { + map['html'] = Variable(html); + } + if (!nullToAbsent || mml != null) { + map['mml'] = Variable(mml); + } + if (!nullToAbsent || deletedReplyCount != null) { + map['deleted_reply_count'] = Variable(deletedReplyCount); + } + if (!nullToAbsent || custom != null) { + map['custom'] = Variable( + $PinnedMessagesTable.$convertercustomn.toSql(custom), ); } return map; @@ -5918,7 +6144,10 @@ class PinnedMessageEntity extends DataClass implements Insertable?>( json['restrictedVisibility'], ), - extraData: serializer.fromJson?>(json['extraData']), + html: serializer.fromJson(json['html']), + mml: serializer.fromJson(json['mml']), + deletedReplyCount: serializer.fromJson(json['deletedReplyCount']), + custom: serializer.fromJson?>(json['custom']), ); } @override @@ -5967,7 +6196,10 @@ class PinnedMessageEntity extends DataClass implements Insertable?>( restrictedVisibility, ), - 'extraData': serializer.toJson?>(extraData), + 'html': serializer.toJson(html), + 'mml': serializer.toJson(mml), + 'deletedReplyCount': serializer.toJson(deletedReplyCount), + 'custom': serializer.toJson?>(custom), }; } @@ -6008,7 +6240,10 @@ class PinnedMessageEntity extends DataClass implements Insertable?> i18n = const Value.absent(), Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), + Value html = const Value.absent(), + Value mml = const Value.absent(), + Value deletedReplyCount = const Value.absent(), + Value?> custom = const Value.absent(), }) => PinnedMessageEntity( id: id ?? this.id, messageText: messageText.present ? messageText.value : this.messageText, @@ -6046,7 +6281,10 @@ class PinnedMessageEntity extends DataClass implements Insertable @@ -6218,7 +6465,10 @@ class PinnedMessageEntity extends DataClass implements Insertable { @@ -6258,7 +6508,10 @@ class PinnedMessagesCompanion extends UpdateCompanion { final Value channelCid; final Value?> i18n; final Value?> restrictedVisibility; - final Value?> extraData; + final Value html; + final Value mml; + final Value deletedReplyCount; + final Value?> custom; final Value rowid; const PinnedMessagesCompanion({ this.id = const Value.absent(), @@ -6297,7 +6550,10 @@ class PinnedMessagesCompanion extends UpdateCompanion { this.channelCid = const Value.absent(), this.i18n = const Value.absent(), this.restrictedVisibility = const Value.absent(), - this.extraData = const Value.absent(), + this.html = const Value.absent(), + this.mml = const Value.absent(), + this.deletedReplyCount = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }); PinnedMessagesCompanion.insert({ @@ -6337,14 +6593,17 @@ class PinnedMessagesCompanion extends UpdateCompanion { required String channelCid, this.i18n = const Value.absent(), this.restrictedVisibility = const Value.absent(), - this.extraData = const Value.absent(), + this.html = const Value.absent(), + this.mml = const Value.absent(), + this.deletedReplyCount = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), attachments = Value(attachments), state = Value(state), mentionedUsers = Value(mentionedUsers), channelCid = Value(channelCid); - static Insertable custom({ + static Insertable createCustom({ Expression? id, Expression? messageText, Expression? attachments, @@ -6381,7 +6640,10 @@ class PinnedMessagesCompanion extends UpdateCompanion { Expression? channelCid, Expression? i18n, Expression? restrictedVisibility, - Expression? extraData, + Expression? html, + Expression? mml, + Expression? deletedReplyCount, + Expression? custom, Expression? rowid, }) { return RawValuesInsertable({ @@ -6421,7 +6683,10 @@ class PinnedMessagesCompanion extends UpdateCompanion { if (channelCid != null) 'channel_cid': channelCid, if (i18n != null) 'i18n': i18n, if (restrictedVisibility != null) 'restricted_visibility': restrictedVisibility, - if (extraData != null) 'extra_data': extraData, + if (html != null) 'html': html, + if (mml != null) 'mml': mml, + if (deletedReplyCount != null) 'deleted_reply_count': deletedReplyCount, + if (custom != null) 'custom': custom, if (rowid != null) 'rowid': rowid, }); } @@ -6463,7 +6728,10 @@ class PinnedMessagesCompanion extends UpdateCompanion { Value? channelCid, Value?>? i18n, Value?>? restrictedVisibility, - Value?>? extraData, + Value? html, + Value? mml, + Value? deletedReplyCount, + Value?>? custom, Value? rowid, }) { return PinnedMessagesCompanion( @@ -6503,7 +6771,10 @@ class PinnedMessagesCompanion extends UpdateCompanion { channelCid: channelCid ?? this.channelCid, i18n: i18n ?? this.i18n, restrictedVisibility: restrictedVisibility ?? this.restrictedVisibility, - extraData: extraData ?? this.extraData, + html: html ?? this.html, + mml: mml ?? this.mml, + deletedReplyCount: deletedReplyCount ?? this.deletedReplyCount, + custom: custom ?? this.custom, rowid: rowid ?? this.rowid, ); } @@ -6649,9 +6920,18 @@ class PinnedMessagesCompanion extends UpdateCompanion { ), ); } - if (extraData.present) { - map['extra_data'] = Variable( - $PinnedMessagesTable.$converterextraDatan.toSql(extraData.value), + if (html.present) { + map['html'] = Variable(html.value); + } + if (mml.present) { + map['mml'] = Variable(mml.value); + } + if (deletedReplyCount.present) { + map['deleted_reply_count'] = Variable(deletedReplyCount.value); + } + if (custom.present) { + map['custom'] = Variable( + $PinnedMessagesTable.$convertercustomn.toSql(custom.value), ); } if (rowid.present) { @@ -6699,7 +6979,10 @@ class PinnedMessagesCompanion extends UpdateCompanion { ..write('channelCid: $channelCid, ') ..write('i18n: $i18n, ') ..write('restrictedVisibility: $restrictedVisibility, ') - ..write('extraData: $extraData, ') + ..write('html: $html, ') + ..write('mml: $mml, ') + ..write('deletedReplyCount: $deletedReplyCount, ') + ..write('custom: $custom, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -8386,15 +8669,15 @@ class $PinnedMessageReactionsTable extends PinnedMessageReactions defaultValue: const Constant(0), ); @override - late final GeneratedColumnWithTypeConverter?, String> extraData = + late final GeneratedColumnWithTypeConverter?, String> custom = GeneratedColumn( - 'extra_data', + 'custom', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ).withConverter?>( - $PinnedMessageReactionsTable.$converterextraDatan, + $PinnedMessageReactionsTable.$convertercustomn, ); @override List get $columns => [ @@ -8405,7 +8688,7 @@ class $PinnedMessageReactionsTable extends PinnedMessageReactions createdAt, updatedAt, score, - extraData, + custom, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -8503,10 +8786,10 @@ class $PinnedMessageReactionsTable extends PinnedMessageReactions DriftSqlType.int, data['${effectivePrefix}score'], )!, - extraData: $PinnedMessageReactionsTable.$converterextraDatan.fromSql( + custom: $PinnedMessageReactionsTable.$convertercustomn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}custom'], ), ), ); @@ -8517,9 +8800,9 @@ class $PinnedMessageReactionsTable extends PinnedMessageReactions return $PinnedMessageReactionsTable(attachedDatabase, alias); } - static TypeConverter, String> $converterextraData = MapConverter(); - static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( - $converterextraData, + static TypeConverter, String> $convertercustom = MapConverter(); + static TypeConverter?, String?> $convertercustomn = NullAwareTypeConverter.wrap( + $convertercustom, ); } @@ -8545,8 +8828,8 @@ class PinnedMessageReactionEntity extends DataClass implements Insertable? extraData; + /// Custom data attached to the reaction. + final Map? custom; const PinnedMessageReactionEntity({ this.userId, this.messageId, @@ -8555,7 +8838,7 @@ class PinnedMessageReactionEntity extends DataClass implements Insertable toColumns(bool nullToAbsent) { @@ -8573,9 +8856,9 @@ class PinnedMessageReactionEntity extends DataClass implements Insertable(createdAt); map['updated_at'] = Variable(updatedAt); map['score'] = Variable(score); - if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $PinnedMessageReactionsTable.$converterextraDatan.toSql(extraData), + if (!nullToAbsent || custom != null) { + map['custom'] = Variable( + $PinnedMessageReactionsTable.$convertercustomn.toSql(custom), ); } return map; @@ -8594,7 +8877,7 @@ class PinnedMessageReactionEntity extends DataClass implements Insertable(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), score: serializer.fromJson(json['score']), - extraData: serializer.fromJson?>(json['extraData']), + custom: serializer.fromJson?>(json['custom']), ); } @override @@ -8608,7 +8891,7 @@ class PinnedMessageReactionEntity extends DataClass implements Insertable(createdAt), 'updatedAt': serializer.toJson(updatedAt), 'score': serializer.toJson(score), - 'extraData': serializer.toJson?>(extraData), + 'custom': serializer.toJson?>(custom), }; } @@ -8620,7 +8903,7 @@ class PinnedMessageReactionEntity extends DataClass implements Insertable?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), }) => PinnedMessageReactionEntity( userId: userId.present ? userId.value : this.userId, messageId: messageId.present ? messageId.value : this.messageId, @@ -8629,7 +8912,7 @@ class PinnedMessageReactionEntity extends DataClass implements Insertable @@ -8683,7 +8966,7 @@ class PinnedMessageReactionEntity extends DataClass implements Insertable { @@ -8694,7 +8977,7 @@ class PinnedMessageReactionsCompanion extends UpdateCompanion createdAt; final Value updatedAt; final Value score; - final Value?> extraData; + final Value?> custom; final Value rowid; const PinnedMessageReactionsCompanion({ this.userId = const Value.absent(), @@ -8704,7 +8987,7 @@ class PinnedMessageReactionsCompanion extends UpdateCompanion custom({ + static Insertable createCustom({ Expression? userId, Expression? messageId, Expression? type, @@ -8726,7 +9009,7 @@ class PinnedMessageReactionsCompanion extends UpdateCompanion? createdAt, Expression? updatedAt, Expression? score, - Expression? extraData, + Expression? custom, Expression? rowid, }) { return RawValuesInsertable({ @@ -8737,7 +9020,7 @@ class PinnedMessageReactionsCompanion extends UpdateCompanion? createdAt, Value? updatedAt, Value? score, - Value?>? extraData, + Value?>? custom, Value? rowid, }) { return PinnedMessageReactionsCompanion( @@ -8761,7 +9044,7 @@ class PinnedMessageReactionsCompanion extends UpdateCompanion(score.value); } - if (extraData.present) { - map['extra_data'] = Variable( - $PinnedMessageReactionsTable.$converterextraDatan.toSql( - extraData.value, - ), + if (custom.present) { + map['custom'] = Variable( + $PinnedMessageReactionsTable.$convertercustomn.toSql(custom.value), ); } if (rowid.present) { @@ -8813,7 +9094,7 @@ class PinnedMessageReactionsCompanion extends UpdateCompanion?, String> extraData = GeneratedColumn( - 'extra_data', + late final GeneratedColumnWithTypeConverter?, String> custom = GeneratedColumn( + 'custom', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, - ).withConverter?>($ReactionsTable.$converterextraDatan); + ).withConverter?>($ReactionsTable.$convertercustomn); @override List get $columns => [ userId, @@ -8919,7 +9200,7 @@ class $ReactionsTable extends Reactions with TableInfo<$ReactionsTable, Reaction createdAt, updatedAt, score, - extraData, + custom, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -9014,10 +9295,10 @@ class $ReactionsTable extends Reactions with TableInfo<$ReactionsTable, Reaction DriftSqlType.int, data['${effectivePrefix}score'], )!, - extraData: $ReactionsTable.$converterextraDatan.fromSql( + custom: $ReactionsTable.$convertercustomn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}custom'], ), ), ); @@ -9028,9 +9309,9 @@ class $ReactionsTable extends Reactions with TableInfo<$ReactionsTable, Reaction return $ReactionsTable(attachedDatabase, alias); } - static TypeConverter, String> $converterextraData = MapConverter(); - static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( - $converterextraData, + static TypeConverter, String> $convertercustom = MapConverter(); + static TypeConverter?, String?> $convertercustomn = NullAwareTypeConverter.wrap( + $convertercustom, ); } @@ -9056,8 +9337,8 @@ class ReactionEntity extends DataClass implements Insertable { /// The score of the reaction (ie. number of reactions sent) final int score; - /// Reaction custom extraData - final Map? extraData; + /// Custom data attached to the reaction. + final Map? custom; const ReactionEntity({ this.userId, this.messageId, @@ -9066,7 +9347,7 @@ class ReactionEntity extends DataClass implements Insertable { required this.createdAt, required this.updatedAt, required this.score, - this.extraData, + this.custom, }); @override Map toColumns(bool nullToAbsent) { @@ -9084,9 +9365,9 @@ class ReactionEntity extends DataClass implements Insertable { map['created_at'] = Variable(createdAt); map['updated_at'] = Variable(updatedAt); map['score'] = Variable(score); - if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $ReactionsTable.$converterextraDatan.toSql(extraData), + if (!nullToAbsent || custom != null) { + map['custom'] = Variable( + $ReactionsTable.$convertercustomn.toSql(custom), ); } return map; @@ -9105,7 +9386,7 @@ class ReactionEntity extends DataClass implements Insertable { createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), score: serializer.fromJson(json['score']), - extraData: serializer.fromJson?>(json['extraData']), + custom: serializer.fromJson?>(json['custom']), ); } @override @@ -9119,7 +9400,7 @@ class ReactionEntity extends DataClass implements Insertable { 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), 'score': serializer.toJson(score), - 'extraData': serializer.toJson?>(extraData), + 'custom': serializer.toJson?>(custom), }; } @@ -9131,7 +9412,7 @@ class ReactionEntity extends DataClass implements Insertable { DateTime? createdAt, DateTime? updatedAt, int? score, - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), }) => ReactionEntity( userId: userId.present ? userId.value : this.userId, messageId: messageId.present ? messageId.value : this.messageId, @@ -9140,7 +9421,7 @@ class ReactionEntity extends DataClass implements Insertable { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, score: score ?? this.score, - extraData: extraData.present ? extraData.value : this.extraData, + custom: custom.present ? custom.value : this.custom, ); ReactionEntity copyWithCompanion(ReactionsCompanion data) { return ReactionEntity( @@ -9151,7 +9432,7 @@ class ReactionEntity extends DataClass implements Insertable { createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, score: data.score.present ? data.score.value : this.score, - extraData: data.extraData.present ? data.extraData.value : this.extraData, + custom: data.custom.present ? data.custom.value : this.custom, ); } @@ -9165,7 +9446,7 @@ class ReactionEntity extends DataClass implements Insertable { ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') ..write('score: $score, ') - ..write('extraData: $extraData') + ..write('custom: $custom') ..write(')')) .toString(); } @@ -9179,7 +9460,7 @@ class ReactionEntity extends DataClass implements Insertable { createdAt, updatedAt, score, - extraData, + custom, ); @override bool operator ==(Object other) => @@ -9192,7 +9473,7 @@ class ReactionEntity extends DataClass implements Insertable { other.createdAt == this.createdAt && other.updatedAt == this.updatedAt && other.score == this.score && - other.extraData == this.extraData); + other.custom == this.custom); } class ReactionsCompanion extends UpdateCompanion { @@ -9203,7 +9484,7 @@ class ReactionsCompanion extends UpdateCompanion { final Value createdAt; final Value updatedAt; final Value score; - final Value?> extraData; + final Value?> custom; final Value rowid; const ReactionsCompanion({ this.userId = const Value.absent(), @@ -9213,7 +9494,7 @@ class ReactionsCompanion extends UpdateCompanion { this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), this.score = const Value.absent(), - this.extraData = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }); ReactionsCompanion.insert({ @@ -9224,10 +9505,10 @@ class ReactionsCompanion extends UpdateCompanion { this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), this.score = const Value.absent(), - this.extraData = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }) : type = Value(type); - static Insertable custom({ + static Insertable createCustom({ Expression? userId, Expression? messageId, Expression? type, @@ -9235,7 +9516,7 @@ class ReactionsCompanion extends UpdateCompanion { Expression? createdAt, Expression? updatedAt, Expression? score, - Expression? extraData, + Expression? custom, Expression? rowid, }) { return RawValuesInsertable({ @@ -9246,7 +9527,7 @@ class ReactionsCompanion extends UpdateCompanion { if (createdAt != null) 'created_at': createdAt, if (updatedAt != null) 'updated_at': updatedAt, if (score != null) 'score': score, - if (extraData != null) 'extra_data': extraData, + if (custom != null) 'custom': custom, if (rowid != null) 'rowid': rowid, }); } @@ -9259,7 +9540,7 @@ class ReactionsCompanion extends UpdateCompanion { Value? createdAt, Value? updatedAt, Value? score, - Value?>? extraData, + Value?>? custom, Value? rowid, }) { return ReactionsCompanion( @@ -9270,7 +9551,7 @@ class ReactionsCompanion extends UpdateCompanion { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, score: score ?? this.score, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, rowid: rowid ?? this.rowid, ); } @@ -9299,9 +9580,9 @@ class ReactionsCompanion extends UpdateCompanion { if (score.present) { map['score'] = Variable(score.value); } - if (extraData.present) { - map['extra_data'] = Variable( - $ReactionsTable.$converterextraDatan.toSql(extraData.value), + if (custom.present) { + map['custom'] = Variable( + $ReactionsTable.$convertercustomn.toSql(custom.value), ); } if (rowid.present) { @@ -9320,7 +9601,7 @@ class ReactionsCompanion extends UpdateCompanion { ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') ..write('score: $score, ') - ..write('extraData: $extraData, ') + ..write('custom: $custom, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -14104,7 +14385,10 @@ typedef $$MessagesTableCreateCompanionBuilder = required String channelCid, Value?> i18n, Value?> restrictedVisibility, - Value?> extraData, + Value html, + Value mml, + Value deletedReplyCount, + Value?> custom, Value rowid, }); typedef $$MessagesTableUpdateCompanionBuilder = @@ -14145,7 +14429,10 @@ typedef $$MessagesTableUpdateCompanionBuilder = Value channelCid, Value?> i18n, Value?> restrictedVisibility, - Value?> extraData, + Value html, + Value mml, + Value deletedReplyCount, + Value?> custom, Value rowid, }); @@ -14409,11 +14696,25 @@ class $$MessagesTableFilterComposer extends Composer<_$DriftChatDatabase, $Messa builder: (column) => ColumnWithTypeConverterFilters(column), ); - ColumnWithTypeConverterFilters?, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnFilters get html => $composableBuilder( + column: $table.html, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get mml => $composableBuilder( + column: $table.mml, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deletedReplyCount => $composableBuilder( + column: $table.deletedReplyCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters?, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); $$ChannelsTableFilterComposer get channelCid { final $$ChannelsTableFilterComposer composer = $composerBuilder( @@ -14693,8 +14994,23 @@ class $$MessagesTableOrderingComposer extends Composer<_$DriftChatDatabase, $Mes builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get html => $composableBuilder( + column: $table.html, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get mml => $composableBuilder( + column: $table.mml, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deletedReplyCount => $composableBuilder( + column: $table.deletedReplyCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, builder: (column) => ColumnOrderings(column), ); @@ -14872,8 +15188,17 @@ class $$MessagesTableAnnotationComposer extends Composer<_$DriftChatDatabase, $M builder: (column) => column, ); - GeneratedColumnWithTypeConverter?, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumn get html => $composableBuilder(column: $table.html, builder: (column) => column); + + GeneratedColumn get mml => $composableBuilder(column: $table.mml, builder: (column) => column); + + GeneratedColumn get deletedReplyCount => $composableBuilder( + column: $table.deletedReplyCount, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter?, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); $$ChannelsTableAnnotationComposer get channelCid { final $$ChannelsTableAnnotationComposer composer = $composerBuilder( @@ -15036,7 +15361,10 @@ class $$MessagesTableTableManager Value channelCid = const Value.absent(), Value?> i18n = const Value.absent(), Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), + Value html = const Value.absent(), + Value mml = const Value.absent(), + Value deletedReplyCount = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion( id: id, @@ -15075,7 +15403,10 @@ class $$MessagesTableTableManager channelCid: channelCid, i18n: i18n, restrictedVisibility: restrictedVisibility, - extraData: extraData, + html: html, + mml: mml, + deletedReplyCount: deletedReplyCount, + custom: custom, rowid: rowid, ), createCompanionCallback: @@ -15116,7 +15447,10 @@ class $$MessagesTableTableManager required String channelCid, Value?> i18n = const Value.absent(), Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), + Value html = const Value.absent(), + Value mml = const Value.absent(), + Value deletedReplyCount = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion.insert( id: id, @@ -15155,7 +15489,10 @@ class $$MessagesTableTableManager channelCid: channelCid, i18n: i18n, restrictedVisibility: restrictedVisibility, - extraData: extraData, + html: html, + mml: mml, + deletedReplyCount: deletedReplyCount, + custom: custom, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -15295,7 +15632,7 @@ typedef $$DraftMessagesTableCreateCompanionBuilder = Value silent, Value createdAt, required String channelCid, - Value?> extraData, + Value?> custom, Value rowid, }); typedef $$DraftMessagesTableUpdateCompanionBuilder = @@ -15313,7 +15650,7 @@ typedef $$DraftMessagesTableUpdateCompanionBuilder = Value silent, Value createdAt, Value channelCid, - Value?> extraData, + Value?> custom, Value rowid, }); @@ -15423,11 +15760,10 @@ class $$DraftMessagesTableFilterComposer extends Composer<_$DriftChatDatabase, $ builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters?, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnWithTypeConverterFilters?, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); $$MessagesTableFilterComposer get parentId { final $$MessagesTableFilterComposer composer = $composerBuilder( @@ -15537,8 +15873,8 @@ class $$DraftMessagesTableOrderingComposer extends Composer<_$DriftChatDatabase, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, builder: (column) => ColumnOrderings(column), ); @@ -15632,8 +15968,8 @@ class $$DraftMessagesTableAnnotationComposer extends Composer<_$DriftChatDatabas GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); $$MessagesTableAnnotationComposer get parentId { final $$MessagesTableAnnotationComposer composer = $composerBuilder( @@ -15720,7 +16056,7 @@ class $$DraftMessagesTableTableManager Value silent = const Value.absent(), Value createdAt = const Value.absent(), Value channelCid = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => DraftMessagesCompanion( id: id, @@ -15736,7 +16072,7 @@ class $$DraftMessagesTableTableManager silent: silent, createdAt: createdAt, channelCid: channelCid, - extraData: extraData, + custom: custom, rowid: rowid, ), createCompanionCallback: @@ -15754,7 +16090,7 @@ class $$DraftMessagesTableTableManager Value silent = const Value.absent(), Value createdAt = const Value.absent(), required String channelCid, - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => DraftMessagesCompanion.insert( id: id, @@ -15770,7 +16106,7 @@ class $$DraftMessagesTableTableManager silent: silent, createdAt: createdAt, channelCid: channelCid, - extraData: extraData, + custom: custom, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -16341,7 +16677,10 @@ typedef $$PinnedMessagesTableCreateCompanionBuilder = required String channelCid, Value?> i18n, Value?> restrictedVisibility, - Value?> extraData, + Value html, + Value mml, + Value deletedReplyCount, + Value?> custom, Value rowid, }); typedef $$PinnedMessagesTableUpdateCompanionBuilder = @@ -16382,7 +16721,10 @@ typedef $$PinnedMessagesTableUpdateCompanionBuilder = Value channelCid, Value?> i18n, Value?> restrictedVisibility, - Value?> extraData, + Value html, + Value mml, + Value deletedReplyCount, + Value?> custom, Value rowid, }); @@ -16604,11 +16946,25 @@ class $$PinnedMessagesTableFilterComposer extends Composer<_$DriftChatDatabase, builder: (column) => ColumnWithTypeConverterFilters(column), ); - ColumnWithTypeConverterFilters?, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnFilters get html => $composableBuilder( + column: $table.html, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get mml => $composableBuilder( + column: $table.mml, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deletedReplyCount => $composableBuilder( + column: $table.deletedReplyCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters?, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); Expression pinnedMessageReactionsRefs( Expression Function($$PinnedMessageReactionsTableFilterComposer f) f, @@ -16823,8 +17179,23 @@ class $$PinnedMessagesTableOrderingComposer extends Composer<_$DriftChatDatabase builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get html => $composableBuilder( + column: $table.html, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get mml => $composableBuilder( + column: $table.mml, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deletedReplyCount => $composableBuilder( + column: $table.deletedReplyCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, builder: (column) => ColumnOrderings(column), ); } @@ -16985,8 +17356,17 @@ class $$PinnedMessagesTableAnnotationComposer extends Composer<_$DriftChatDataba builder: (column) => column, ); - GeneratedColumnWithTypeConverter?, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumn get html => $composableBuilder(column: $table.html, builder: (column) => column); + + GeneratedColumn get mml => $composableBuilder(column: $table.mml, builder: (column) => column); + + GeneratedColumn get deletedReplyCount => $composableBuilder( + column: $table.deletedReplyCount, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter?, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); Expression pinnedMessageReactionsRefs( Expression Function($$PinnedMessageReactionsTableAnnotationComposer a) f, @@ -17076,7 +17456,10 @@ class $$PinnedMessagesTableTableManager Value channelCid = const Value.absent(), Value?> i18n = const Value.absent(), Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), + Value html = const Value.absent(), + Value mml = const Value.absent(), + Value deletedReplyCount = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => PinnedMessagesCompanion( id: id, @@ -17115,7 +17498,10 @@ class $$PinnedMessagesTableTableManager channelCid: channelCid, i18n: i18n, restrictedVisibility: restrictedVisibility, - extraData: extraData, + html: html, + mml: mml, + deletedReplyCount: deletedReplyCount, + custom: custom, rowid: rowid, ), createCompanionCallback: @@ -17156,7 +17542,10 @@ class $$PinnedMessagesTableTableManager required String channelCid, Value?> i18n = const Value.absent(), Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), + Value html = const Value.absent(), + Value mml = const Value.absent(), + Value deletedReplyCount = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => PinnedMessagesCompanion.insert( id: id, @@ -17195,7 +17584,10 @@ class $$PinnedMessagesTableTableManager channelCid: channelCid, i18n: i18n, restrictedVisibility: restrictedVisibility, - extraData: extraData, + html: html, + mml: mml, + deletedReplyCount: deletedReplyCount, + custom: custom, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -18183,7 +18575,7 @@ typedef $$PinnedMessageReactionsTableCreateCompanionBuilder = Value createdAt, Value updatedAt, Value score, - Value?> extraData, + Value?> custom, Value rowid, }); typedef $$PinnedMessageReactionsTableUpdateCompanionBuilder = @@ -18195,7 +18587,7 @@ typedef $$PinnedMessageReactionsTableUpdateCompanionBuilder = Value createdAt, Value updatedAt, Value score, - Value?> extraData, + Value?> custom, Value rowid, }); @@ -18263,11 +18655,10 @@ class $$PinnedMessageReactionsTableFilterComposer extends Composer<_$DriftChatDa builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters?, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnWithTypeConverterFilters?, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); $$PinnedMessagesTableFilterComposer get messageId { final $$PinnedMessagesTableFilterComposer composer = $composerBuilder( @@ -18331,8 +18722,8 @@ class $$PinnedMessageReactionsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, builder: (column) => ColumnOrderings(column), ); @@ -18380,8 +18771,8 @@ class $$PinnedMessageReactionsTableAnnotationComposer GeneratedColumn get score => $composableBuilder(column: $table.score, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); $$PinnedMessagesTableAnnotationComposer get messageId { final $$PinnedMessagesTableAnnotationComposer composer = $composerBuilder( @@ -18452,7 +18843,7 @@ class $$PinnedMessageReactionsTableTableManager Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), Value score = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => PinnedMessageReactionsCompanion( userId: userId, @@ -18462,7 +18853,7 @@ class $$PinnedMessageReactionsTableTableManager createdAt: createdAt, updatedAt: updatedAt, score: score, - extraData: extraData, + custom: custom, rowid: rowid, ), createCompanionCallback: @@ -18474,7 +18865,7 @@ class $$PinnedMessageReactionsTableTableManager Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), Value score = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => PinnedMessageReactionsCompanion.insert( userId: userId, @@ -18484,7 +18875,7 @@ class $$PinnedMessageReactionsTableTableManager createdAt: createdAt, updatedAt: updatedAt, score: score, - extraData: extraData, + custom: custom, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -18560,7 +18951,7 @@ typedef $$ReactionsTableCreateCompanionBuilder = Value createdAt, Value updatedAt, Value score, - Value?> extraData, + Value?> custom, Value rowid, }); typedef $$ReactionsTableUpdateCompanionBuilder = @@ -18572,7 +18963,7 @@ typedef $$ReactionsTableUpdateCompanionBuilder = Value createdAt, Value updatedAt, Value score, - Value?> extraData, + Value?> custom, Value rowid, }); @@ -18635,11 +19026,10 @@ class $$ReactionsTableFilterComposer extends Composer<_$DriftChatDatabase, $Reac builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters?, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnWithTypeConverterFilters?, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); $$MessagesTableFilterComposer get messageId { final $$MessagesTableFilterComposer composer = $composerBuilder( @@ -18702,8 +19092,8 @@ class $$ReactionsTableOrderingComposer extends Composer<_$DriftChatDatabase, $Re builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, builder: (column) => ColumnOrderings(column), ); @@ -18750,8 +19140,8 @@ class $$ReactionsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $ GeneratedColumn get score => $composableBuilder(column: $table.score, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); $$MessagesTableAnnotationComposer get messageId { final $$MessagesTableAnnotationComposer composer = $composerBuilder( @@ -18808,7 +19198,7 @@ class $$ReactionsTableTableManager Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), Value score = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => ReactionsCompanion( userId: userId, @@ -18818,7 +19208,7 @@ class $$ReactionsTableTableManager createdAt: createdAt, updatedAt: updatedAt, score: score, - extraData: extraData, + custom: custom, rowid: rowid, ), createCompanionCallback: @@ -18830,7 +19220,7 @@ class $$ReactionsTableTableManager Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), Value score = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => ReactionsCompanion.insert( userId: userId, @@ -18840,7 +19230,7 @@ class $$ReactionsTableTableManager createdAt: createdAt, updatedAt: updatedAt, score: score, - extraData: extraData, + custom: custom, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart b/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart index 74a7c99f9d..16d7f671e6 100644 --- a/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart +++ b/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart @@ -47,8 +47,8 @@ class DraftMessages extends Table { /// The channel cid of which this message is part of TextColumn get channelCid => text().references(Channels, #cid, onDelete: KeyAction.cascade)(); - /// Message custom extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + /// Custom data attached to the draft message. + TextColumn get custom => text().nullable().map(MapConverter())(); @override Set get primaryKey => {id}; diff --git a/packages/stream_chat_persistence/lib/src/entity/messages.dart b/packages/stream_chat_persistence/lib/src/entity/messages.dart index 965825b787..d22491ab58 100644 --- a/packages/stream_chat_persistence/lib/src/entity/messages.dart +++ b/packages/stream_chat_persistence/lib/src/entity/messages.dart @@ -148,8 +148,17 @@ class Messages extends Table { /// The list of user ids that should be able to see the message. TextColumn get restrictedVisibility => text().nullable().map(ListConverter())(); - /// Message custom extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + /// The message rendered as HTML. + TextColumn get html => text().nullable()(); + + /// The message markup language (MML) source of the message. + TextColumn get mml => text().nullable()(); + + /// The number of replies to the message that were deleted. + IntColumn get deletedReplyCount => integer().nullable()(); + + /// Custom data attached to the message. + TextColumn get custom => text().nullable().map(MapConverter())(); @override Set get primaryKey => {id}; diff --git a/packages/stream_chat_persistence/lib/src/entity/reactions.dart b/packages/stream_chat_persistence/lib/src/entity/reactions.dart index 5be4f59838..71a32ab15e 100644 --- a/packages/stream_chat_persistence/lib/src/entity/reactions.dart +++ b/packages/stream_chat_persistence/lib/src/entity/reactions.dart @@ -28,8 +28,8 @@ class Reactions extends Table { /// The score of the reaction (ie. number of reactions sent) IntColumn get score => integer().withDefault(const Constant(0))(); - /// Reaction custom extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + /// Custom data attached to the reaction. + TextColumn get custom => text().nullable().map(MapConverter())(); @override Set get primaryKey => { diff --git a/packages/stream_chat_persistence/lib/src/mapper/draft_message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/draft_message_mapper.dart index 76f176e028..84d17c5a35 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/draft_message_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/draft_message_mapper.dart @@ -34,7 +34,7 @@ extension DraftMessageEntityX on DraftMessageEntity { command: command, poll: poll, pollId: pollId, - extraData: extraData ?? {}, + custom: custom ?? {}, ), parentId: parentId, parentMessage: parentMessage, @@ -64,6 +64,6 @@ extension DraftMessageX on Draft { silent: message.silent, command: message.command, pollId: message.pollId, - extraData: message.extraData, + custom: message.custom, ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart index 5670d6f7c7..64a61905d5 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart @@ -23,7 +23,11 @@ extension MessageEntityX on MessageEntity { final json = jsonDecode(it); return Attachment.fromData(json); }).toList(), - extraData: extraData ?? {}, + custom: custom ?? {}, + cid: channelCid, + html: html, + mml: mml, + deletedReplyCount: deletedReplyCount, createdAt: remoteCreatedAt, localCreatedAt: localCreatedAt, updatedAt: remoteUpdatedAt, @@ -91,7 +95,10 @@ extension MessageX on Message { state: jsonEncode(state.toJson()), remoteUpdatedAt: remoteUpdatedAt, localUpdatedAt: localUpdatedAt, - extraData: extraData, + custom: custom, + html: html, + mml: mml, + deletedReplyCount: deletedReplyCount, userId: user?.id, channelRole: channelRole, remoteDeletedAt: remoteDeletedAt, diff --git a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart index ce2a7a836c..e8c98d9c3d 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart @@ -23,7 +23,11 @@ extension PinnedMessageEntityX on PinnedMessageEntity { final json = jsonDecode(it); return Attachment.fromData(json); }).toList(), - extraData: extraData ?? {}, + custom: custom ?? {}, + cid: channelCid, + html: html, + mml: mml, + deletedReplyCount: deletedReplyCount, createdAt: remoteCreatedAt, localCreatedAt: localCreatedAt, updatedAt: remoteUpdatedAt, @@ -81,7 +85,10 @@ extension PMessageX on Message { state: jsonEncode(state.toJson()), remoteUpdatedAt: remoteUpdatedAt, localUpdatedAt: localUpdatedAt, - extraData: extraData, + custom: custom, + html: html, + mml: mml, + deletedReplyCount: deletedReplyCount, userId: user?.id, channelRole: channelRole, remoteDeletedAt: remoteDeletedAt, diff --git a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_reaction_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_reaction_mapper.dart index 228abe1ea5..1a1719534d 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_reaction_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_reaction_mapper.dart @@ -13,7 +13,7 @@ extension PinnedMessageReactionEntityX on PinnedMessageReactionEntity { emojiCode: emojiCode, createdAt: createdAt, updatedAt: updatedAt, - extraData: extraData ?? {}, + custom: custom ?? {}, ); } @@ -28,6 +28,6 @@ extension PReactionX on Reaction { emojiCode: emojiCode, createdAt: createdAt, updatedAt: updatedAt, - extraData: extraData, + custom: custom, ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart index 940a2571c5..186686a557 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart @@ -13,7 +13,7 @@ extension ReactionEntityX on ReactionEntity { emojiCode: emojiCode, createdAt: createdAt, updatedAt: updatedAt, - extraData: extraData ?? {}, + custom: custom ?? {}, ); } @@ -28,6 +28,6 @@ extension ReactionX on Reaction { emojiCode: emojiCode, createdAt: createdAt, updatedAt: updatedAt, - extraData: extraData, + custom: custom, ); } diff --git a/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart index b912eb3941..1abe818c65 100644 --- a/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart @@ -78,7 +78,7 @@ void main() { quotedMessageId: withQuotedMessage ? messages[index].id : null, parentId: withParentMessage ? messages[index].id : null, pollId: withPoll && polls != null ? polls[index].id : null, - extraData: {'extra_test_field': 'extraTestData$index'}, + custom: {'extra_test_field': 'extraTestData$index'}, ); return Draft( diff --git a/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart index daf6c4d32f..46a6ab4ef1 100644 --- a/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart @@ -44,7 +44,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Hello #$index', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -94,7 +94,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Hello #$index', quotedMessageId: messages[index].id, pinned: math.Random().nextBool(), @@ -119,7 +119,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Hello #$index', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -1087,7 +1087,7 @@ void main() { showInChannel: math.Random().nextBool(), replyCount: 4, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #4', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), diff --git a/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart index 2405eaf2e3..b0af8bcafa 100644 --- a/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart @@ -44,7 +44,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -66,7 +66,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', quotedMessageId: messages[index].id, pinned: math.Random().nextBool(), @@ -86,7 +86,7 @@ void main() { shadowed: math.Random().nextBool(), replyCount: index, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #$index', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -618,7 +618,7 @@ void main() { showInChannel: math.Random().nextBool(), replyCount: 4, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text #4', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), diff --git a/packages/stream_chat_persistence/test/src/dao/pinned_message_reaction_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/pinned_message_reaction_dao_test.dart index 5de47893cf..7caf029670 100644 --- a/packages/stream_chat_persistence/test/src/dao/pinned_message_reaction_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/pinned_message_reaction_dao_test.dart @@ -34,7 +34,7 @@ void main() { showInChannel: math.Random().nextBool(), replyCount: 3, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -54,7 +54,7 @@ void main() { messageId: message.id, score: count + 3, emojiCode: '😂$index', - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, ); }, ); @@ -90,7 +90,7 @@ void main() { messageId: messageId, score: 30, emojiCode: '🎈', - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, ); await pinnedMessageReactionDao.updateReactions([copyReaction, newReaction]); diff --git a/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart index bd2205777f..11247d7d36 100644 --- a/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart @@ -34,7 +34,7 @@ void main() { showInChannel: math.Random().nextBool(), replyCount: 3, updatedAt: DateTime.now(), - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, text: 'Dummy text', pinned: math.Random().nextBool(), pinnedAt: DateTime.now(), @@ -54,7 +54,7 @@ void main() { messageId: message.id, score: count + 3, emojiCode: '😂$index', - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, ); }, ); @@ -90,7 +90,7 @@ void main() { messageId: messageId, score: 30, emojiCode: '🎈', - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, ); await reactionDao.updateReactions([copyReaction, newReaction]); diff --git a/packages/stream_chat_persistence/test/src/mapper/draft_message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/draft_message_mapper_test.dart index a476f59093..d2eca6de37 100644 --- a/packages/stream_chat_persistence/test/src/mapper/draft_message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/draft_message_mapper_test.dart @@ -47,7 +47,7 @@ void main() { command: 'testCommand', pollId: poll.id, createdAt: createdAt, - extraData: {'extra_test_field': 'extraTestData'}, + custom: {'extra_test_field': 'extraTestData'}, ); final draft = entity.toDraft( @@ -64,7 +64,7 @@ void main() { expect(draft.message.showInChannel, entity.showInChannel); expect(draft.message.silent, entity.silent); expect(draft.message.command, entity.command); - expect(draft.message.extraData, entity.extraData); + expect(draft.message.custom, entity.custom); expect(draft.message.parentId, entity.parentId); expect(draft.message.quotedMessageId, entity.quotedMessageId); expect(draft.message.pollId, entity.pollId); @@ -124,7 +124,7 @@ void main() { command: 'testCommand', poll: poll, pollId: poll.id, - extraData: const {'extra_test_field': 'extraTestData'}, + custom: const {'extra_test_field': 'extraTestData'}, ); final draft = Draft( @@ -146,7 +146,7 @@ void main() { expect(entity.showInChannel, draft.message.showInChannel); expect(entity.silent, draft.message.silent); expect(entity.command, draft.message.command); - expect(entity.extraData, draft.message.extraData); + expect(entity.custom, draft.message.custom); expect(entity.parentId, draft.message.parentId); expect(entity.quotedMessageId, draft.message.quotedMessageId); expect(entity.pollId, draft.message.pollId); diff --git a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart index 94f5e8a994..79ba376de5 100644 --- a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart @@ -92,7 +92,7 @@ void main() { localUpdatedAt: DateTime.now(), remoteUpdatedAt: DateTime.now().add(const Duration(seconds: 1)), messageTextUpdatedAt: DateTime.now().add(const Duration(minutes: 5)), - extraData: {'extra_test_data': 'extraData'}, + custom: {'extra_test_data': 'custom'}, userId: user.id, channelRole: 'channel_member', localDeletedAt: DateTime.now(), @@ -109,6 +109,9 @@ void main() { 'language': 'en', }, restrictedVisibility: const ['user-id-2'], + html: '

Hello

', + mml: 'Hello', + deletedReplyCount: 2, ); final message = entity.toMessage( user: user, @@ -159,7 +162,7 @@ void main() { message.messageTextUpdatedAt, isSameDateAs(entity.messageTextUpdatedAt), ); - expect(message.extraData, entity.extraData); + expect(message.custom, entity.custom); expect(message.user!.id, entity.userId); expect(message.localDeletedAt, isSameDateAs(entity.localDeletedAt)); expect(message.remoteDeletedAt, isSameDateAs(entity.remoteDeletedAt)); @@ -181,6 +184,10 @@ void main() { expect(messageAttachment.assetUrl, entityAttachment.assetUrl); } expect(message.restrictedVisibility, entity.restrictedVisibility); + expect(message.cid, entity.channelCid); + expect(message.html, entity.html); + expect(message.mml, entity.mml); + expect(message.deletedReplyCount, entity.deletedReplyCount); }); test('toEntity should map message into MessageEntity', () { @@ -265,7 +272,7 @@ void main() { localUpdatedAt: DateTime.now(), updatedAt: DateTime.now().add(const Duration(seconds: 1)), messageTextUpdatedAt: DateTime.now().add(const Duration(minutes: 5)), - extraData: const {'extra_test_data': 'extraData'}, + custom: const {'extra_test_data': 'custom'}, user: user, channelRole: 'channel_member', localDeletedAt: DateTime.now(), @@ -282,6 +289,9 @@ void main() { 'language': 'en', }, restrictedVisibility: const ['user-id-2'], + html: '

Hello

', + mml: 'Hello', + deletedReplyCount: 2, ); final entity = message.toEntity(cid: cid); expect(entity, isA()); @@ -320,7 +330,7 @@ void main() { entity.messageTextUpdatedAt, isSameDateAs(message.messageTextUpdatedAt), ); - expect(entity.extraData, message.extraData); + expect(entity.custom, message.custom); expect(entity.userId, message.user!.id); expect(entity.localDeletedAt, isSameDateAs(message.localDeletedAt)); expect(entity.remoteDeletedAt, isSameDateAs(message.remoteDeletedAt)); @@ -338,5 +348,8 @@ void main() { ); expect(entity.i18n, message.i18n); expect(entity.restrictedVisibility, message.restrictedVisibility); + expect(entity.html, message.html); + expect(entity.mml, message.mml); + expect(entity.deletedReplyCount, message.deletedReplyCount); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart index 170026665c..c537b729ae 100644 --- a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart @@ -76,7 +76,7 @@ void main() { localUpdatedAt: DateTime.now(), remoteUpdatedAt: DateTime.now().add(const Duration(seconds: 1)), messageTextUpdatedAt: DateTime.now().add(const Duration(minutes: 5)), - extraData: {'extra_test_data': 'extraData'}, + custom: {'extra_test_data': 'custom'}, userId: user.id, channelRole: 'channel_member', localDeletedAt: DateTime.now(), @@ -93,6 +93,9 @@ void main() { 'language': 'en', }, restrictedVisibility: const ['user-id-2'], + html: '

Hello

', + mml: 'Hello', + deletedReplyCount: 2, ); final message = entity.toMessage( user: user, @@ -127,7 +130,7 @@ void main() { message.messageTextUpdatedAt, isSameDateAs(entity.messageTextUpdatedAt), ); - expect(message.extraData, entity.extraData); + expect(message.custom, entity.custom); expect(message.user!.id, entity.userId); expect(message.localDeletedAt, isSameDateAs(entity.localDeletedAt)); expect(message.remoteDeletedAt, isSameDateAs(entity.remoteDeletedAt)); @@ -149,6 +152,10 @@ void main() { expect(messageAttachment.assetUrl, entityAttachment.assetUrl); } expect(message.restrictedVisibility, entity.restrictedVisibility); + expect(message.cid, entity.channelCid); + expect(message.html, entity.html); + expect(message.mml, entity.mml); + expect(message.deletedReplyCount, entity.deletedReplyCount); }); test('toEntity should map message into MessageEntity', () { @@ -217,7 +224,7 @@ void main() { localUpdatedAt: DateTime.now(), updatedAt: DateTime.now().add(const Duration(seconds: 1)), messageTextUpdatedAt: DateTime.now().add(const Duration(minutes: 5)), - extraData: const {'extra_test_data': 'extraData'}, + custom: const {'extra_test_data': 'custom'}, user: user, channelRole: 'channel_member', localDeletedAt: DateTime.now(), @@ -234,6 +241,9 @@ void main() { 'language': 'en', }, restrictedVisibility: const ['user-id-2'], + html: '

Hello

', + mml: 'Hello', + deletedReplyCount: 2, ); final entity = message.toPinnedEntity(cid: cid); expect(entity, isA()); @@ -256,7 +266,7 @@ void main() { message.messageTextUpdatedAt, isSameDateAs(entity.messageTextUpdatedAt), ); - expect(entity.extraData, message.extraData); + expect(entity.custom, message.custom); expect(entity.userId, message.user!.id); expect(entity.localDeletedAt, isSameDateAs(message.localDeletedAt)); expect(entity.remoteDeletedAt, isSameDateAs(message.remoteDeletedAt)); @@ -274,5 +284,8 @@ void main() { ); expect(entity.i18n, message.i18n); expect(entity.restrictedVisibility, message.restrictedVisibility); + expect(entity.html, message.html); + expect(entity.mml, message.mml); + expect(entity.deletedReplyCount, message.deletedReplyCount); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/pinned_message_reaction_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/pinned_message_reaction_mapper_test.dart index d2b4a67094..add9882c78 100644 --- a/packages/stream_chat_persistence/test/src/mapper/pinned_message_reaction_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/pinned_message_reaction_mapper_test.dart @@ -18,7 +18,7 @@ void main() { emojiCode: '😂', createdAt: now, updatedAt: now.add(const Duration(minutes: 5)), - extraData: {'extra_test_data': 'extraData'}, + custom: {'extra_test_data': 'custom'}, ); final reaction = entity.toReaction(user: user); @@ -30,7 +30,7 @@ void main() { expect(reaction.emojiCode, entity.emojiCode); expect(reaction.createdAt, isSameDateAs(entity.createdAt)); expect(reaction.updatedAt, isSameDateAs(entity.updatedAt)); - expect(reaction.extraData, entity.extraData); + expect(reaction.custom, entity.custom); }); test('toEntity should map reaction into PinnedMessageReactionEntity', () { @@ -45,7 +45,7 @@ void main() { emojiCode: '😂', createdAt: now, updatedAt: now.add(const Duration(minutes: 5)), - extraData: const {'extra_test_data': 'extraData'}, + custom: const {'extra_test_data': 'custom'}, ); final entity = reaction.toPinnedEntity(); @@ -57,6 +57,6 @@ void main() { expect(entity.emojiCode, reaction.emojiCode); expect(entity.createdAt, isSameDateAs(reaction.createdAt)); expect(entity.updatedAt, isSameDateAs(reaction.updatedAt)); - expect(entity.extraData, reaction.extraData); + expect(entity.custom, reaction.custom); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart index 43a3b93cfb..b8694e8cf0 100644 --- a/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart @@ -18,7 +18,7 @@ void main() { emojiCode: '😂', createdAt: now, updatedAt: now.add(const Duration(minutes: 5)), - extraData: {'extra_test_data': 'extraData'}, + custom: {'extra_test_data': 'custom'}, ); final reaction = entity.toReaction(user: user); @@ -30,7 +30,7 @@ void main() { expect(reaction.emojiCode, entity.emojiCode); expect(reaction.createdAt, isSameDateAs(entity.createdAt)); expect(reaction.updatedAt, isSameDateAs(entity.updatedAt)); - expect(reaction.extraData, entity.extraData); + expect(reaction.custom, entity.custom); }); test('toEntity should map reaction into ReactionEntity', () { @@ -45,7 +45,7 @@ void main() { emojiCode: '😂', createdAt: now, updatedAt: now.add(const Duration(minutes: 5)), - extraData: const {'extra_test_data': 'extraData'}, + custom: const {'extra_test_data': 'custom'}, ); final entity = reaction.toEntity(); @@ -57,6 +57,6 @@ void main() { expect(entity.emojiCode, reaction.emojiCode); expect(entity.createdAt, isSameDateAs(reaction.createdAt)); expect(entity.updatedAt, isSameDateAs(reaction.updatedAt)); - expect(entity.extraData, reaction.extraData); + expect(entity.custom, reaction.custom); }); } From 2ee356327d55ac4ace289dd478db083f278355be Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 19 Oct 2026 10:46:29 +0200 Subject: [PATCH 10/14] style(llc): wrap the reminder and message model comments at 80 columns Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat/lib/src/client/client.dart | 16 ++++++++++------ .../stream_chat/lib/src/core/models/action.dart | 3 ++- .../lib/src/core/models/location.dart | 3 ++- .../lib/src/core/models/message_reminder.dart | 13 ++++++++----- .../lib/src/core/models/reaction_group.dart | 3 ++- .../lib/src/core/util/message_decoding.dart | 17 ++++++++++------- .../src/repository/mapper/reminders_mapper.dart | 3 ++- .../src/repository/reminders_repository.dart | 13 ++++++++----- .../src/client/client_query_reminders_test.dart | 8 +++++--- .../src/client/client_update_reminder_test.dart | 3 ++- .../src/core/models/message_reminder_test.dart | 2 +- .../converter/reaction_groups_converter.dart | 3 ++- 12 files changed, 54 insertions(+), 33 deletions(-) diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 1da6bafcf5..36c69c9a7b 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -2353,11 +2353,13 @@ class StreamChatClient { ); } - /// Fetches one page of the current user's reminders matching [filter], ordered by [sort]. + /// Fetches one page of the current user's reminders matching [filter], + /// ordered by [sort]. /// - /// 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. + /// 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, @@ -2368,7 +2370,8 @@ class StreamChatClient { /// 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. + /// The reminder is due at [remindAt]. Without it, the reminder is a bookmark, + /// which is never due. Future> createReminder( String messageId, { DateTime? remindAt, @@ -2376,7 +2379,8 @@ class StreamChatClient { /// Updates the reminder on the message with the id [messageId]. /// - /// The reminder becomes due at [remindAt]. Without it, the reminder becomes a bookmark, which is never due. + /// The reminder becomes due at [remindAt]. Without it, the reminder becomes a + /// bookmark, which is never due. Future> updateReminder( String messageId, { DateTime? remindAt, diff --git a/packages/stream_chat/lib/src/core/models/action.dart b/packages/stream_chat/lib/src/core/models/action.dart index caf4be4cf9..02c936a9a5 100644 --- a/packages/stream_chat/lib/src/core/models/action.dart +++ b/packages/stream_chat/lib/src/core/models/action.dart @@ -2,7 +2,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'action.freezed.dart'; -/// An interactive control attached to a message, such as the Send, Shuffle and Cancel buttons of a giphy preview. +/// 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]. diff --git a/packages/stream_chat/lib/src/core/models/location.dart b/packages/stream_chat/lib/src/core/models/location.dart index 0541813b93..3717d2298a 100644 --- a/packages/stream_chat/lib/src/core/models/location.dart +++ b/packages/stream_chat/lib/src/core/models/location.dart @@ -89,7 +89,8 @@ class Location with _$Location { return endAt.isAfter(DateTime.now()); } - /// Whether this location is not [isActive]: a static location, or a live one whose [endAt] has passed. + /// Whether this location is not [isActive]: a static location, or a live one + /// whose [endAt] has passed. bool get isExpired => !isActive; /// Whether this is a live location, one with an [endAt]. 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 d4d7efb76e..54282dc882 100644 --- a/packages/stream_chat/lib/src/core/models/message_reminder.dart +++ b/packages/stream_chat/lib/src/core/models/message_reminder.dart @@ -16,8 +16,9 @@ const _nullConst = _NullConst(); /// {@template messageReminder} /// A reminder a user set on a message. /// -/// 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. +/// 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} @Freezed(copyWith: false) class MessageReminder with _$MessageReminder { @@ -75,7 +76,8 @@ class MessageReminder with _$MessageReminder { /// 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. + /// A field passed as null keeps its current value, except [remindAt], which a + /// null clears. MessageReminder copyWith({ String? channelCid, ChannelModel? channel, @@ -102,8 +104,9 @@ class MessageReminder with _$MessageReminder { /// 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. + /// 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( 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 7e7f831dac..1bd36fd599 100644 --- a/packages/stream_chat/lib/src/core/models/reaction_group.dart +++ b/packages/stream_chat/lib/src/core/models/reaction_group.dart @@ -21,7 +21,8 @@ class ReactionGroup with _$ReactionGroup { }) : firstReactionAt = firstReactionAt ?? DateTime.timestamp(), lastReactionAt = lastReactionAt ?? DateTime.timestamp(); - /// Creates a [ReactionGroup] from the offline-database format written by [toData]. + /// 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); diff --git a/packages/stream_chat/lib/src/core/util/message_decoding.dart b/packages/stream_chat/lib/src/core/util/message_decoding.dart index c4ddce3569..7259f3c1c3 100644 --- a/packages/stream_chat/lib/src/core/util/message_decoding.dart +++ b/packages/stream_chat/lib/src/core/util/message_decoding.dart @@ -6,10 +6,11 @@ import '../models/reaction_group.dart'; 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. + /// 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. @@ -25,10 +26,12 @@ extension MessageDecoding on Message { } } -/// Builds the reaction groups of a received message from its reaction [counts] and [scores], keyed by reaction type. +/// 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. +/// 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; diff --git a/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart index 518e0825f0..e37e395805 100644 --- a/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/reminders_mapper.dart @@ -14,7 +14,8 @@ extension CreateReminderResponseMapper on api.CreateReminderResponse { CreateReminderResponse toModel() => CreateReminderResponse(duration: duration, reminder: reminder.toModel()); } -/// Maps a generated [api.UpdateReminderResponse] to an [UpdateReminderResponse]. +/// 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()); diff --git a/packages/stream_chat/lib/src/repository/reminders_repository.dart b/packages/stream_chat/lib/src/repository/reminders_repository.dart index 69038b7538..553de26a3a 100644 --- a/packages/stream_chat/lib/src/repository/reminders_repository.dart +++ b/packages/stream_chat/lib/src/repository/reminders_repository.dart @@ -16,9 +16,11 @@ class RemindersRepository { final api.DefaultApi _api; - /// Fetches one page of the current user's reminders matching [filter], ordered by [sort]. + /// 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. + /// [next] and [prev] are the cursors a previous page returned; at most one of + /// them may be given. Future> queryReminders({ MessageReminderFilter? filter, List? sort, @@ -39,7 +41,8 @@ class RemindersRepository { return result.map((response) => response.toModel()); } - /// Creates a reminder on the message with the id [messageId], due at [remindAt], or a bookmark without it. + /// 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, @@ -49,8 +52,8 @@ class RemindersRepository { 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. + /// 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, 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 index f9bcac6320..a1e4d9ebb9 100644 --- a/packages/stream_chat/test/src/client/client_query_reminders_test.dart +++ b/packages/stream_chat/test/src/client/client_query_reminders_test.dart @@ -155,8 +155,9 @@ final _generatedChannel = api.ChannelResponse( 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]. +// 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, @@ -169,7 +170,8 @@ MessageReminder _expectedReminder(MessageReminder actual) => MessageReminder( updatedAt: DateTime.utc(2026, 1, 8), ); -// The reminder [_generatedBookmark] maps to, with its channel read off [actual]. +// The reminder [_generatedBookmark] maps to, with its channel read off +// [actual]. MessageReminder _expectedBookmark(MessageReminder actual) => MessageReminder( channelCid: 'messaging:general', channel: actual.channel, 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 index d7909102b8..dee9c69dec 100644 --- a/packages/stream_chat/test/src/client/client_update_reminder_test.dart +++ b/packages/stream_chat/test/src/client/client_update_reminder_test.dart @@ -87,7 +87,8 @@ void main() { }); } -// A reminder as an update answers it: with the message and the user, but no channel. +// 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), 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 53aa4986a0..8653fdbbce 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 @@ -228,7 +228,7 @@ void main() { 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. + // An updated reminder can lack its channel, message and user. final eventReminder = messageReminder.copyWith(updatedAt: now.add(const Duration(hours: 1))); final mergedReminder = listedReminder.merge(eventReminder); 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 fcddc97e45..eaf201893f 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,7 +4,8 @@ import 'package:drift/drift.dart'; import 'package:stream_chat/stream_chat.dart'; import 'converter.dart'; -/// A [TypeConverter] that stores a message's [ReactionGroup]s as a JSON [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) { From 59acdc32d121d1e81691a65a4fa964dc7974bce3 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 19 Oct 2026 11:28:33 +0200 Subject: [PATCH 11/14] refactor(llc): apply the review fixes to reminders, message docs and tests Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 2 +- openapi-migration/10-messages.md | 15 +++--- openapi-migration/tool/generate_plan.py | 15 +++--- packages/stream_chat/CHANGELOG.md | 2 +- .../lib/src/client/channel/channel.dart | 4 +- .../stream_chat/lib/src/client/client.dart | 6 +-- .../lib/src/core/models/attachment.dart | 2 +- .../models/converters/v1_json_converters.dart | 4 +- .../lib/src/core/models/reaction.dart | 16 +++---- .../response/create_reminder_response.dart | 3 +- .../response/delete_reminder_response.dart | 3 +- .../response/query_reminders_response.dart | 3 +- .../response/update_reminder_response.dart | 3 +- .../lib/src/core/util/message_decoding.dart | 3 +- .../src/repository/reminders_repository.dart | 3 +- .../stream_chat/test/fixtures/attachment.json | 5 ++ .../stream_chat/test/fixtures/message.json | 8 ++++ .../test/src/core/models/action_test.dart | 14 ------ .../test/src/core/models/attachment_test.dart | 19 ++------ .../converters/v1_json_converters_test.dart | 46 +++++++++++-------- .../src/core/models/draft_message_test.dart | 16 ++----- .../test/src/core/models/message_test.dart | 35 ++------------ ...message_reminder_list_controller_test.dart | 23 ++++++++++ 23 files changed, 122 insertions(+), 128 deletions(-) delete mode 100644 packages/stream_chat/test/src/core/models/action_test.dart diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 8a4e3bbee2..140336a6bc 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -1161,7 +1161,7 @@ kept.** v10 left them in `extraData`. **`custom` is the custom data exactly as the API returns it.** An entry named like one of the model's own fields stays in `custom` and leaves the field alone; it is left out when the model is sent, so it cannot overwrite the -field. A reaction's emoji code read from the API stays in its `custom` as well as in `Reaction.emojiCode`. +field. A reaction on a reminder's message keeps its emoji code in its `custom` as well as in `Reaction.emojiCode`. > **Why:** the API calls it `custom`, and every migrated model takes that name. diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index ab793032a3..20510cb1ba 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -62,13 +62,13 @@ an endpoint, so the definition of done below stays open. `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. + `Message` stops decoding v1 JSON. `ModerationV2Response` maps beside the message, in + `messages_mapper.dart`. - **`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 + decode-only; `reactionGroupsFromCounts` 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 @@ -82,10 +82,11 @@ an endpoint, so the definition of done below stays open. - **`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. + is removed; the converter owns the key list. `ReactionV1JsonConverter` reads and writes a reaction + 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` diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 501cef1e63..d4a073b820 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -680,13 +680,13 @@ def match(verb, path): `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. + `Message` stops decoding v1 JSON. `ModerationV2Response` maps beside the message, in + `messages_mapper.dart`. - **`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 + decode-only; `reactionGroupsFromCounts` 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 @@ -700,10 +700,11 @@ def match(verb, path): - **`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. + is removed; the converter owns the key list. `ReactionV1JsonConverter` reads and writes a reaction + 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` diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index ca1023b528..b22400a1e3 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -154,7 +154,7 @@ - A message's channel id, HTML, markup and deleted reply count, and an attachment's Giphy renditions, are fields instead of custom data: `custom` no longer holds them, and an entry with one of their names in `custom` no longer sets the field. - `Message.toJson` no longer writes the message's channel id, HTML, markup or deleted reply count, so a message can no longer be sent with markup. - A message's image labels and mentioned channel members, and a draft message's HTML and markup, are no longer kept in their custom data. -- A reaction's emoji code read from the API stays in its custom data, as well as in `Reaction.emojiCode`. +- A reaction on a reminder's message keeps its emoji code in its custom data, as well as in `Reaction.emojiCode`. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 7607f626f2..dcc59880de 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1270,7 +1270,7 @@ class Channel { /// Sends a static location to this channel. /// - /// A `messageText` and `custom` data, when given, are sent in the message + /// A [messageText] and [custom] data, when given, are sent in the message /// with the location. Future sendStaticLocation({ String? id, @@ -1302,7 +1302,7 @@ class Channel { /// Sends a live location sharing message to this channel. /// - /// A `messageText` and `custom` data, when given, are sent in the message + /// A [messageText] and [custom] data, when given, are sent in the message /// with the location. Future startLiveLocationSharing({ String? id, diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 36c69c9a7b..ef88742c7b 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -2357,9 +2357,9 @@ class StreamChatClient { /// ordered by [sort]. /// /// 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. + /// a response's [QueryRemindersResponse.next] as [next], and the previous + /// page by passing its [QueryRemindersResponse.prev] as [prev]. At most one + /// of [next] and [prev] may be given. Future> queryReminders({ MessageReminderFilter? filter, List? sort, diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index a6b1d9678f..0d7cea5fd6 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -238,7 +238,7 @@ class Attachment extends Equatable { ..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key)); // The fields toData leaves custom data out of. A custom `id`, the id the - // attachment was sent with, still replaces the local one, as it did before. + // attachment was sent with, replaces the local one. static const _storedFields = [...topLevelFields, 'file', 'upload_state']; /// Serialize to db data 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 5d1bb5bf8e..28752e9faa 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 @@ -40,8 +40,8 @@ class ActionV1JsonConverter implements JsonConverter toJson(Action action) => { 'name': action.name, diff --git a/packages/stream_chat/lib/src/core/models/reaction.dart b/packages/stream_chat/lib/src/core/models/reaction.dart index fc7cce2a35..5e2312087b 100644 --- a/packages/stream_chat/lib/src/core/models/reaction.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.dart @@ -26,35 +26,35 @@ class Reaction with _$Reaction { createdAt = createdAt ?? DateTime.timestamp(), updatedAt = updatedAt ?? DateTime.timestamp(); - /// The messageId to which the reaction belongs + /// The id of the message the reaction is on. @override final String? messageId; - /// The type of the reaction + /// The kind of reaction, such as `like` or `love`. @override final String type; - /// The score of the reaction (ie. number of reactions sent) + /// How many times the user sent this reaction. @override final int score; - /// The emoji code of the reaction (used for notifications) + /// The emoji that stands for the reaction, such as `❤️`. @override final String? emojiCode; - /// The user that sent the reaction + /// The user who sent the reaction. @override final User? user; - /// The userId that sent the reaction + /// The id of the user who sent the reaction. @override final String? userId; - /// The date of the reaction + /// The date at which the reaction was sent. @override final DateTime createdAt; - /// The date of the reaction update + /// The date at which the reaction was last updated. @override final DateTime updatedAt; 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 index 52b1625cc9..98b961801f 100644 --- 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 @@ -4,7 +4,8 @@ import '../message_reminder.dart'; part 'create_reminder_response.freezed.dart'; -/// The outcome of creating a reminder on a message. +/// The result of creating a reminder on a message, returned by +/// [StreamChatClient.createReminder] and [Channel.createReminder]. @freezed class CreateReminderResponse with _$CreateReminderResponse { /// Creates a new [CreateReminderResponse]. 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 index 5325b849ee..b34390f829 100644 --- 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 @@ -2,7 +2,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'delete_reminder_response.freezed.dart'; -/// The outcome of deleting the reminder on a message. +/// The result of deleting the reminder on a message, returned by +/// [StreamChatClient.deleteReminder] and [Channel.deleteReminder]. @freezed class DeleteReminderResponse with _$DeleteReminderResponse { /// Creates a new [DeleteReminderResponse]. 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 index 5b5029e491..d25baf866e 100644 --- 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 @@ -4,7 +4,8 @@ import '../message_reminder.dart'; part 'query_reminders_response.freezed.dart'; -/// One page of the current user's reminders matching a query. +/// One page of the current user's reminders matching a query, returned by +/// [StreamChatClient.queryReminders]. @freezed class QueryRemindersResponse with _$QueryRemindersResponse { /// Creates a new [QueryRemindersResponse]. 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 index eca94365af..ebdcf525de 100644 --- 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 @@ -4,7 +4,8 @@ import '../message_reminder.dart'; part 'update_reminder_response.freezed.dart'; -/// The outcome of updating the reminder on a message. +/// The result of updating the reminder on a message, returned by +/// [StreamChatClient.updateReminder] and [Channel.updateReminder]. @freezed class UpdateReminderResponse with _$UpdateReminderResponse { /// Creates a new [UpdateReminderResponse]. diff --git a/packages/stream_chat/lib/src/core/util/message_decoding.dart b/packages/stream_chat/lib/src/core/util/message_decoding.dart index 7259f3c1c3..7c14624567 100644 --- a/packages/stream_chat/lib/src/core/util/message_decoding.dart +++ b/packages/stream_chat/lib/src/core/util/message_decoding.dart @@ -13,7 +13,8 @@ extension MessageDecoding on Message { /// 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. + // TODO: Remove this override once a message deleted only for the current + // user arrives with the deleted type. final type = isDeletedForMe ? MessageType.deleted : this.type; final state = switch (this) { _ when isDeletedForMe => MessageState.deletedForMe, diff --git a/packages/stream_chat/lib/src/repository/reminders_repository.dart b/packages/stream_chat/lib/src/repository/reminders_repository.dart index 553de26a3a..00ba7fef3a 100644 --- a/packages/stream_chat/lib/src/repository/reminders_repository.dart +++ b/packages/stream_chat/lib/src/repository/reminders_repository.dart @@ -6,6 +6,7 @@ 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/filter_mapper.dart'; import 'mapper/reminders_mapper.dart'; import 'mapper/sort_mapper.dart'; @@ -30,7 +31,7 @@ class RemindersRepository { }) async { final result = await _api.queryReminders( queryRemindersRequest: api.QueryRemindersRequest( - filter: filter?.toJson(), + filter: filter?.toRequest(), sort: sort?.map((it) => it.toRequest()).toList(), limit: limit, next: next, diff --git a/packages/stream_chat/test/fixtures/attachment.json b/packages/stream_chat/test/fixtures/attachment.json index faf38b9081..f78c0dc429 100644 --- a/packages/stream_chat/test/fixtures/attachment.json +++ b/packages/stream_chat/test/fixtures/attachment.json @@ -3,6 +3,11 @@ "title": "awesome", "title_link": "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti", "thumb_url": "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif", + "giphy": { + "original": { + "url": "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif" + } + }, "actions": [ { "name": "image_action", diff --git a/packages/stream_chat/test/fixtures/message.json b/packages/stream_chat/test/fixtures/message.json index 8dc3b15aea..25d8491dc4 100644 --- a/packages/stream_chat/test/fixtures/message.json +++ b/packages/stream_chat/test/fixtures/message.json @@ -4,6 +4,14 @@ "type": "regular", "silent": false, "status": "SENT", + "cid": "messaging:general", + "html": "

Hi

", + "mml": "Hi", + "deleted_reply_count": 2, + "image_labels": { + "cat.png": ["cat"] + }, + "mentioned_channel_members": {}, "user": { "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", "role": "user", diff --git a/packages/stream_chat/test/src/core/models/action_test.dart b/packages/stream_chat/test/src/core/models/action_test.dart deleted file mode 100644 index f722d0877c..0000000000 --- a/packages/stream_chat/test/src/core/models/action_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:stream_chat/src/core/models/action.dart'; -import 'package:test/test.dart'; - -void main() { - 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 70c17e4846..a536ae3f80 100644 --- a/packages/stream_chat/test/src/core/models/attachment_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_test.dart @@ -1,5 +1,3 @@ -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'; @@ -25,6 +23,10 @@ void main() { expect(attachment.actions, isNotEmpty); expect(attachment.actions, hasLength(3)); expect(attachment.actions![0], isA()); + expect(attachment.giphy, { + 'original': {'url': 'https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif'}, + }); + expect(attachment.custom, isEmpty); }); test('should serialize to json correctly', () { @@ -166,19 +168,6 @@ void main() { }); }); - test('Attachment.fromJson reads the Giphy renditions into giphy, not custom data', () { - // 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'}, - }); - expect(attachment.custom, isEmpty); - }); - test('Attachment.toJson writes the Giphy renditions, so an edited message keeps them', () { final attachment = Attachment( type: 'giphy', 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 335d2877e8..c52a51eb79 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 @@ -307,24 +307,34 @@ void main() { expect(event.reaction!.score, 1); }); - test( - 'Event.toJson writes the reaction in the request shape, with custom data not named like a field at the root', - () { - final event = Event( - type: 'reaction.new', - reaction: Reaction( - messageId: 'message-id', - type: 'wow', - user: User(id: 'user-id'), - score: 2, - emojiCode: '😮', - custom: const {'bananas': 'yes', 'emoji_code': 'custom', 'type': 'custom'}, - ), - ); - - expect(event.toJson()['reaction'], {'type': 'wow', 'score': 2, 'emoji_code': '😮', 'bananas': 'yes'}); - }, - ); + test('Event.toJson writes the reaction in the request shape, with its 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: '😮', + custom: const {'bananas': 'yes'}, + ), + ); + + expect(event.toJson()['reaction'], {'type': 'wow', 'score': 2, 'emoji_code': '😮', 'bananas': 'yes'}); + }); + + test('Event.toJson leaves out a reaction custom entry named like a reaction key', () { + final event = Event( + type: 'reaction.new', + reaction: Reaction( + type: 'wow', + emojiCode: '😮', + custom: const {'emoji_code': 'custom', 'type': 'custom'}, + ), + ); + + expect(event.toJson()['reaction'], {'type': 'wow', 'score': 1, 'emoji_code': '😮'}); + }); test('Message.fromJson reads the latest and own reactions', () { final message = Message.fromJson(const { diff --git a/packages/stream_chat/test/src/core/models/draft_message_test.dart b/packages/stream_chat/test/src/core/models/draft_message_test.dart index 0f870effe2..a8e4f0e7fe 100644 --- a/packages/stream_chat/test/src/core/models/draft_message_test.dart +++ b/packages/stream_chat/test/src/core/models/draft_message_test.dart @@ -236,14 +236,15 @@ void main() { 'text': text, 'custom_field': 'custom_value', 'priority': 5, + 'html': '

Hi

', + 'mml': 'Hi', }; final deserializedMessage = DraftMessage.fromJson(json); expect(deserializedMessage.id, equals(id)); expect(deserializedMessage.text, equals(text)); - expect(deserializedMessage.custom['custom_field'], equals('custom_value')); - expect(deserializedMessage.custom['priority'], equals(5)); + expect(deserializedMessage.custom, {'custom_field': 'custom_value', 'priority': 5}); }); test('should deserialize poll_id correctly', () { @@ -449,17 +450,6 @@ void main() { }); }); - test('DraftMessage.fromJson leaves the HTML and markup out of custom data', () { - final draft = DraftMessage.fromJson(const { - 'id': 'draft-id', - 'html': '

Hi

', - 'mml': 'Hi', - 'mood': 'busy', - }); - - expect(draft.custom, {'mood': 'busy'}); - }); - test('DraftMessage.toJson leaves out custom data named like a field', () { final draft = DraftMessage(text: 'Hi', custom: const {'text': 'custom', 'mood': 'busy'}); 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 9292c09f33..2478de29d3 100644 --- a/packages/stream_chat/test/src/core/models/message_test.dart +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -54,6 +54,11 @@ void main() { expect(message.i18n, null); expect(message.restrictedVisibility, isA>()); expect(message.channelRole, 'channel_member'); + expect(message.cid, 'messaging:general'); + expect(message.html, '

Hi

'); + expect(message.mml, 'Hi'); + expect(message.deletedReplyCount, 2); + expect(message.custom, {'status': 'SENT'}); }); test('should serialize to json correctly', () { @@ -504,36 +509,6 @@ void main() { }); }); - test('Message.fromJson reads the channel id, HTML, markup and deleted reply count into their fields', () { - final message = Message.fromJson(const { - 'id': 'message-id', - 'cid': 'messaging:general', - 'html': '

Hi

', - 'mml': 'Hi', - 'deleted_reply_count': 2, - 'priority': 'high', - }); - - expect(message.cid, 'messaging:general'); - expect(message.html, '

Hi

'); - expect(message.mml, 'Hi'); - expect(message.deletedReplyCount, 2); - expect(message.custom, {'priority': 'high'}); - }); - - test('Message.fromJson leaves image labels and mentioned channel members out of custom data', () { - final message = Message.fromJson(const { - 'id': 'message-id', - 'image_labels': { - 'cat.png': ['cat'], - }, - 'mentioned_channel_members': {}, - 'priority': 'high', - }); - - expect(message.custom, {'priority': 'high'}); - }); - test('Message.toJson leaves out custom data named like a field', () { final message = Message(text: 'Hi', custom: const {'text': 'custom', 'priority': 'high'}); 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 20715d8a8a..68d6edef51 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 @@ -172,6 +172,29 @@ void main() { expect(controller.value, isA()); expect((controller.value as Error).error, equals(chatError)); }); + + test( + 'StreamMessageReminderListController.doInitialLoad wraps a failure that is not a chat exception', + () async { + final cause = StateError('boom'); + when( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + ), + ).thenAnswer((_) async => Result.failure(cause)); + + final controller = StreamMessageReminderListController(client: client); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + final error = controller.value.mapOrNull(null, error: (it) => it.error); + expect(error, isA().having((it) => it.cause, 'cause', cause)); + }, + ); }); group('Pagination', () { From f14b618d5f8f12c457d1bde8fb2eea9f05bc94b2 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 19 Oct 2026 12:45:37 +0200 Subject: [PATCH 12/14] refactor(llc): take freezed copyWith on the plain message models and drop the listed-only custom keys Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 22 ++-- openapi-migration/06-reminders.md | 5 +- openapi-migration/10-messages.md | 26 ++--- openapi-migration/tool/generate_plan.py | 31 +++--- packages/stream_chat/CHANGELOG.md | 34 +++--- .../lib/src/client/channel/channel.dart | 2 +- .../models/converters/v1_json_converters.dart | 4 +- .../lib/src/core/models/draft_message.dart | 3 - .../lib/src/core/models/location.dart | 31 +----- .../lib/src/core/models/location.freezed.dart | 100 ++++++++++++++++++ .../lib/src/core/models/message.dart | 3 - .../lib/src/core/models/message_reminder.dart | 42 +------- .../core/models/message_reminder.freezed.dart | 90 ++++++++++++++++ .../lib/src/core/models/reaction.dart | 35 ++---- .../lib/src/core/models/reaction.freezed.dart | 88 +++++++++++++++ .../lib/src/core/models/reaction_group.dart | 19 +--- .../core/models/reaction_group.freezed.dart | 54 ++++++++++ .../stream_chat/test/fixtures/message.json | 4 - .../src/core/models/draft_message_test.dart | 2 - .../test/src/core/models/reaction_test.dart | 11 ++ 20 files changed, 420 insertions(+), 186 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 73a8bc8dd3..919e7d7e64 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -317,13 +317,13 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `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` | +| `ReactionGroup extends Equatable`, `ReactionGroup.props` | `ReactionGroup` (value `==`) | `removed` | Equality is 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` | +| `Reaction extends Equatable`, `Reaction.props` | `Reaction` (value `==`) | `removed` | Equality 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` | +| `Location extends Equatable`, `Location.props` | `Location` (value `==`) | `removed` | Equality is 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 | @@ -359,13 +359,13 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | 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` | +| `MessageReminder extends Equatable`, `MessageReminder.props` | `MessageReminder` (value `==`) | `removed` | Equality and `merge` are unchanged; `props` is gone and `MessageReminder` is no longer an `Equatable` | +| `Location.copyWith` / `MessageReminder.copyWith` / `Reaction.copyWith` / `ReactionGroup.copyWith` keeping a field passed as `null` | freezed's `copyWith` | `retyped` | `null` clears a nullable field and resets `createdAt`, `updatedAt`, `firstReactionAt` and `lastReactionAt` to now; `MessageReminder.remindAt` already cleared | | `StreamChatApi.reminders` (`RemindersApi`) | `StreamChatClient` reminder methods | `removed` | The endpoints moved to the generated client | | `Message.extraData` / `Attachment.extraData` / `Reaction.extraData` / `DraftMessage.extraData` | `Message.custom` / `Attachment.custom` / `Reaction.custom` / `DraftMessage.custom` | `renamed` | The wire's name for it; also the constructor and `copyWith` arguments | | `Channel.sendStaticLocation(extraData: …)` / `startLiveLocationSharing(extraData: …)` | `sendStaticLocation(custom: …)` / `startLiveLocationSharing(custom: …)` | `renamed` | The message's custom data | | `message.extraData['cid']` / `['html']` / `['mml']` / `['deleted_reply_count']` | `Message.cid` / `html` / `mml` / `deletedReplyCount` | `retyped` | Fields now, not custom data; `toJson` does not write them, so a message can no longer be sent with markup | | `attachment.extraData['giphy']` | `Attachment.giphy` | `retyped` | A field now, not custom data | -| `message.extraData['image_labels']` / `['mentioned_channel_members']`, `draftMessage.extraData['html']` / `['mml']` | — | `removed` | No longer kept | | `Message.custom` / `Attachment.custom` / `Reaction.custom` / `DraftMessage.custom` entry named like one of the model's own fields | the same entry, as custom data | `retyped` | Kept as the API returns it; left out when the model is sent, so it cannot overwrite the field | | `Reaction.topLevelFields` | — | `removed` | It listed the JSON keys of a model that no longer has JSON | | _(more added per feature as PRs land)_ | | | | @@ -1157,9 +1157,6 @@ final html = message.extraData['html'] as String?; final html = message.html; ``` -**A message's image labels and mentioned channel members, and a draft message's HTML and markup, are no longer -kept.** v10 left them in `extraData`. - **`custom` is the custom data exactly as the API returns it.** An entry named like one of the model's own fields stays in `custom` and leaves the field alone; it is left out when the model is sent, so it cannot overwrite the field. A reaction on a reminder's message keeps its emoji code in its `custom` as well as in `Reaction.emojiCode`. @@ -1441,8 +1438,8 @@ 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. +same keys. `MessageReminder` is no longer an `Equatable`: equality and `merge` are unchanged, and `props` is +gone. Its `copyWith` is freezed's: `null` clears a nullable field and resets a date to now. **`StreamChatApi.reminders` is removed.** Call the reminder methods on `StreamChatClient` or `Channel` instead. @@ -1502,8 +1499,9 @@ JSON. 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. +`Equatable`; equality is unchanged, as is `Reaction.merge`. Messages, events, channel states, `sendReaction` and +the live location calls still carry them as before. `copyWith` on `Reaction`, `Location` and `ReactionGroup` is +freezed's: `null` clears a nullable field and resets a date to now. ```dart // v10 diff --git a/openapi-migration/06-reminders.md b/openapi-migration/06-reminders.md index f45222c057..3bc82ff111 100644 --- a/openapi-migration/06-reminders.md +++ b/openapi-migration/06-reminders.md @@ -26,8 +26,9 @@ ## Decisions taken - **`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 + equality is unchanged. Its constructor stays non-const (dates default to now). It takes freezed's + `copyWith` (audited: only `merge` needed `?? channel`, `?? message` and `?? user` to keep what an + event leaves out) and keeps v10's `merge`. `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 diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 20510cb1ba..634b42024a 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -65,9 +65,9 @@ an endpoint, so the definition of done below stays open. `Message` stops decoding v1 JSON. `ModerationV2Response` maps beside the message, in `messages_mapper.dart`. - **`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`, + equality is unchanged. Its constructor stays non-const, defaulting both dates to now, and it takes + freezed's `copyWith` (audited: no site passes a `null` date). `Message.reactionGroups` decodes through + `reactionGroupsFromV1Json`, decode-only; `reactionGroupsFromCounts` 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 @@ -81,15 +81,16 @@ an endpoint, so the definition of done below stays open. 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` - is removed; the converter owns the key list. `ReactionV1JsonConverter` reads and writes a reaction - 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. + takes freezed's `copyWith` (audited: `merge` keeps a `null` message id, user, user id and emoji code + with `??`, and `sendReaction` keeps the reaction's user when there is no current user). + `Reaction.topLevelFields` is removed; the converter owns the key list. `ReactionV1JsonConverter` reads + and writes a reaction 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` + it takes freezed's `copyWith` (audited: no site changes). 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 @@ -114,8 +115,9 @@ an endpoint, so the definition of done below stays open. `Attachment.giphy`, stored through the attachment's `toData`. `Message.toJson` writes none of the message's; `Attachment.toJson` writes `giphy`, so an edited Giphy message keeps its renditions. - **What the SDK has no feature for stays out:** `image_labels` and `mentioned_channel_members` on a - message, and `html` and `mml` on a draft message, which iOS and Android drop too. The v1 decoders - list them in `topLevelFields`, so they no longer land in `custom` on either path. + message, and `html` and `mml` on a draft message, which iOS and Android drop too. The mappers leave + them out; the v1 decoders split flat JSON by `topLevelFields` alone, so they keep them in `custom`, + as v10 did. - **A reaction's emoji code arrives in `custom`,** which keeps it, and also 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 diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index f073c0297f..7dae65ce9c 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -421,8 +421,9 @@ def match(verb, path): 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 + equality is unchanged. Its constructor stays non-const (dates default to now). It takes freezed's + `copyWith` (audited: only `merge` needed `?? channel`, `?? message` and `?? user` to keep what an + event leaves out) and keeps v10's `merge`. `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 @@ -690,9 +691,9 @@ def match(verb, path): `Message` stops decoding v1 JSON. `ModerationV2Response` maps beside the message, in `messages_mapper.dart`. - **`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`, + equality is unchanged. Its constructor stays non-const, defaulting both dates to now, and it takes + freezed's `copyWith` (audited: no site passes a `null` date). `Message.reactionGroups` decodes through + `reactionGroupsFromV1Json`, decode-only; `reactionGroupsFromCounts` 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 @@ -706,15 +707,16 @@ def match(verb, path): 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` - is removed; the converter owns the key list. `ReactionV1JsonConverter` reads and writes a reaction - 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. + takes freezed's `copyWith` (audited: `merge` keeps a `null` message id, user, user id and emoji code + with `??`, and `sendReaction` keeps the reaction's user when there is no current user). + `Reaction.topLevelFields` is removed; the converter owns the key list. `ReactionV1JsonConverter` reads + and writes a reaction 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` + it takes freezed's `copyWith` (audited: no site changes). 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 @@ -739,8 +741,9 @@ def match(verb, path): `Attachment.giphy`, stored through the attachment's `toData`. `Message.toJson` writes none of the message's; `Attachment.toJson` writes `giphy`, so an edited Giphy message keeps its renditions. - **What the SDK has no feature for stays out:** `image_labels` and `mentioned_channel_members` on a - message, and `html` and `mml` on a draft message, which iOS and Android drop too. The v1 decoders - list them in `topLevelFields`, so they no longer land in `custom` on either path. + message, and `html` and `mml` on a draft message, which iOS and Android drop too. The mappers leave + them out; the v1 decoders split flat JSON by `topLevelFields` alone, so they keep them in `custom`, + as v10 did. - **A reaction's emoji code arrives in `custom`,** which keeps it, and also 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 diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 7b24a808ca..e41c2322a3 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -121,11 +121,12 @@ - `UserBlock` no longer decodes from or encodes to JSON and no longer extends `Equatable`, so `props` is removed; it still compares by value. - `UserBlock.blockedUser`, `userId`, `blockedUserId` and `createdAt` are required and non-nullable, and `UserBlock.copyWith` takes every field as a non-nullable value. - `StreamChatApi.user`'s blocking methods are removed; use `blockUser`, `unblockUser` and `getBlockedUsers` on `StreamChatClient` instead. -- `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. +- `Moderation`, `ReactionGroup`, `Action`, `Reaction`, `Location` and `MessageReminder` no longer decode from or encode to JSON. +- `Moderation`, `ReactionGroup`, `Reaction`, `Location` and `MessageReminder` no longer extend `Equatable`, so `props` is removed; they still compare by value. +- `Action` compares by value instead of by identity. +- `Moderation` and `Action` gain `copyWith`. +- `ReactionGroup` gains `fromData` and `toData`, for the offline cache only. +- `copyWith` on `Location`, `MessageReminder`, `Reaction` and `ReactionGroup` clears a nullable field passed as `null`, and resets a date passed as `null` to now. - `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`. @@ -134,11 +135,11 @@ - The users in an `UpdateUsersResponse` no longer carry their devices, mutes, channel mutes, privacy settings, unread counts, hidden channels or token revocation time in their custom data. - `updateUser` and `updateUsers` no longer send the user's `role`, `teams` and `teamsRole`. - `StreamChatApi.user.updateUsers` and `partialUpdateUsers` are removed; call `updateUsers` and `updateUsersPartial` on `StreamChatClient` instead. -- 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`. +- The reminder calls on `StreamChatClient` and `Channel` return a `Result` instead of throwing. +- `deleteReminder` returns a `DeleteReminderResponse` instead of an `EmptyResponse`. +- `queryReminders` takes `limit`, `next` and `prev` instead of `PaginationParams`. +- `CreateReminderResponse`, `UpdateReminderResponse` and `QueryRemindersResponse` are immutable, compare by value, and their `duration` is non-nullable. +- `MessageReminderResponse` and `StreamChatApi.reminders` are removed. - `ChannelModel.extraData` and `Member.extraData` are renamed `custom`, and so are `Channel.extraData`, `Channel.extraDataStream` (now `customStream`), and the `extraData` argument of the `Channel` constructor and of `StreamChatClient.channel`. - Several channel and member values the custom data used to hold, such as `ChannelModel.hidden`, `ChannelModel.truncatedAt` and `Member.status`, are fields instead: `custom` no longer holds them, `toJson` no longer writes them, and an entry with one of their names in `custom` or in `copyWith(custom:)` no longer sets the field. - `ChannelModel.custom` and `Member.custom` hold the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the model's own fields. @@ -149,13 +150,11 @@ - `User.custom` holds the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the user's own fields. - `OwnUser.fromUser` no longer reads devices, mutes, unread counts, push preferences or privacy settings out of the user's custom data; they are left at their defaults. - `rawType` is removed from `MessageType`, `AttachmentType`, `PushLevel`, `ChatPreferenceLevel`, `ChatLevel`, `CallLevel` and `RoleType`, as are `ModerationAction.action` and `ChannelCapability.capability`; use the value itself instead. -- `Message.extraData`, `Attachment.extraData`, `Reaction.extraData` and `DraftMessage.extraData` are renamed `custom`, and so is the `extraData` argument of their constructors and `copyWith` and of `Channel.sendStaticLocation` and `startLiveLocationSharing`. -- `Message.custom`, `Attachment.custom`, `Reaction.custom` and `DraftMessage.custom` hold the custom data exactly as the API returns it, and an entry named like one of the model's own fields is left out when the model is sent. +- `extraData` is renamed `custom` on `Message`, `Attachment`, `Reaction` and `DraftMessage`, and on `Channel.sendStaticLocation` and `startLiveLocationSharing`. +- A custom entry named like one of the model's own fields is no longer sent. +- A message's channel id, HTML, markup and deleted reply count, and an attachment's Giphy renditions, are fields instead of custom data. +- `Message.toJson` no longer writes the message's channel id, HTML, markup or deleted reply count. - `Reaction.topLevelFields` is removed. -- A message's channel id, HTML, markup and deleted reply count, and an attachment's Giphy renditions, are fields instead of custom data: `custom` no longer holds them, and an entry with one of their names in `custom` no longer sets the field. -- `Message.toJson` no longer writes the message's channel id, HTML, markup or deleted reply count, so a message can no longer be sent with markup. -- A message's image labels and mentioned channel members, and a draft message's HTML and markup, are no longer kept in their custom data. -- A reaction on a reminder's message keeps its emoji code in its custom data, as well as in `Reaction.emojiCode`. 🐞 Fixed @@ -191,7 +190,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 `User.deactivatedAt`, `deletedAt`, `shadowBanned`, `blockedUserIds` and `nameOrId`; the constructors and `copyWith` accept the new fields. - Added `ChannelModel.muted`, `blocked`, `truncatedBy`, `autoTranslationEnabled` and `autoTranslationLanguage`, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`; the constructors and `copyWith` accept the new fields. -- Added `Message.cid`, `html`, `mml` and `deletedReplyCount`, and `Attachment.giphy`; the constructors and `copyWith` accept them. +- Added `Message.cid`, `html`, `mml` and `deletedReplyCount`, and `Attachment.giphy`. +- Added a `prev` cursor to `QueryRemindersResponse`. - Added a `prev` argument to `queryPolls` and `queryPollVotes`, and a `prev` cursor on their responses, for paging backwards. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index dcc59880de..23a8b46dbf 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1649,7 +1649,7 @@ class Channel { // ignore: parameter_assignments reaction = reaction.copyWith( messageId: messageId, - user: _client.state.currentUser, + user: _client.state.currentUser ?? reaction.user, ); final updatedMessage = message.addMyReaction( 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 28752e9faa..26b00c1beb 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 @@ -232,8 +232,8 @@ Moderation? moderationFromV1Json(Map? json) { 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(), + 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, 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 e43bfbbfcd..457c460f18 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.dart @@ -115,9 +115,6 @@ class DraftMessage extends Equatable { 'command', 'poll_id', 'extra_data', - // Not modelled on a draft message; listed so they never land in [custom]. - 'html', - 'mml', ]; /// Serialize to json. diff --git a/packages/stream_chat/lib/src/core/models/location.dart b/packages/stream_chat/lib/src/core/models/location.dart index 3717d2298a..0acb87540c 100644 --- a/packages/stream_chat/lib/src/core/models/location.dart +++ b/packages/stream_chat/lib/src/core/models/location.dart @@ -16,7 +16,7 @@ part 'location.freezed.dart'; /// 2. **Live Location**: A location that updates in real-time and has an /// end time. /// {@endtemplate} -@Freezed(copyWith: false) +@freezed class Location with _$Location { /// {@macro location} Location({ @@ -101,33 +101,4 @@ class Location with _$Location { /// Returns the coordinates of the shared location. LocationCoordinate get coordinates => .new(latitude: latitude, longitude: longitude); - - /// Creates a copy of [Location] with specified attributes overridden. - Location copyWith({ - String? channelCid, - ChannelModel? channel, - String? messageId, - Message? message, - String? userId, - double? latitude, - double? longitude, - String? createdByDeviceId, - DateTime? endAt, - DateTime? createdAt, - DateTime? updatedAt, - }) { - return Location( - channelCid: channelCid ?? this.channelCid, - channel: channel ?? this.channel, - messageId: messageId ?? this.messageId, - message: message ?? this.message, - userId: userId ?? this.userId, - latitude: latitude ?? this.latitude, - longitude: longitude ?? this.longitude, - createdByDeviceId: createdByDeviceId ?? this.createdByDeviceId, - endAt: endAt ?? this.endAt, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.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 index 35314dd42f..2fe514bcc7 100644 --- a/packages/stream_chat/lib/src/core/models/location.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/location.freezed.dart @@ -25,6 +25,12 @@ mixin _$Location { DateTime get createdAt; DateTime get updatedAt; + /// Create a copy of Location + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $LocationCopyWith get copyWith => _$LocationCopyWithImpl(this as Location, _$identity); + @override bool operator ==(Object other) { return identical(this, other) || @@ -64,3 +70,97 @@ mixin _$Location { return 'Location(channelCid: $channelCid, channel: $channel, messageId: $messageId, message: $message, userId: $userId, latitude: $latitude, longitude: $longitude, createdByDeviceId: $createdByDeviceId, endAt: $endAt, createdAt: $createdAt, updatedAt: $updatedAt)'; } } + +/// @nodoc +abstract mixin class $LocationCopyWith<$Res> { + factory $LocationCopyWith(Location value, $Res Function(Location) _then) = _$LocationCopyWithImpl; + @useResult + $Res call({ + String? channelCid, + ChannelModel? channel, + String? messageId, + Message? message, + String? userId, + double latitude, + double longitude, + String? createdByDeviceId, + DateTime? endAt, + DateTime? createdAt, + DateTime? updatedAt, + }); +} + +/// @nodoc +class _$LocationCopyWithImpl<$Res> implements $LocationCopyWith<$Res> { + _$LocationCopyWithImpl(this._self, this._then); + + final Location _self; + final $Res Function(Location) _then; + + /// Create a copy of Location + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? channelCid = freezed, + Object? channel = freezed, + Object? messageId = freezed, + Object? message = freezed, + Object? userId = freezed, + Object? latitude = null, + Object? longitude = null, + Object? createdByDeviceId = freezed, + Object? endAt = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, + }) { + return _then( + Location( + channelCid: freezed == channelCid + ? _self.channelCid + : channelCid // ignore: cast_nullable_to_non_nullable + as String?, + channel: freezed == channel + ? _self.channel + : channel // ignore: cast_nullable_to_non_nullable + as ChannelModel?, + messageId: freezed == messageId + ? _self.messageId + : messageId // ignore: cast_nullable_to_non_nullable + as String?, + message: freezed == message + ? _self.message + : message // ignore: cast_nullable_to_non_nullable + as Message?, + userId: freezed == userId + ? _self.userId + : userId // ignore: cast_nullable_to_non_nullable + as String?, + latitude: null == latitude + ? _self.latitude + : latitude // ignore: cast_nullable_to_non_nullable + as double, + longitude: null == longitude + ? _self.longitude + : longitude // ignore: cast_nullable_to_non_nullable + as double, + createdByDeviceId: freezed == createdByDeviceId + ? _self.createdByDeviceId + : createdByDeviceId // ignore: cast_nullable_to_non_nullable + as String?, + endAt: freezed == endAt + ? _self.endAt + : endAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + createdAt: freezed == createdAt + ? _self.createdAt! + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _self.updatedAt! + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 2f4bc7d7ca..0fc440e90e 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -426,9 +426,6 @@ class Message extends Equatable { 'html', 'mml', 'deleted_reply_count', - // Not modelled on a message; listed so they never land in [custom]. - 'image_labels', - 'mentioned_channel_members', ]; /// Serialize to json. 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 54282dc882..a9ab473996 100644 --- a/packages/stream_chat/lib/src/core/models/message_reminder.dart +++ b/packages/stream_chat/lib/src/core/models/message_reminder.dart @@ -7,12 +7,6 @@ import 'user.dart'; part 'message_reminder.freezed.dart'; -class _NullConst { - const _NullConst(); -} - -const _nullConst = _NullConst(); - /// {@template messageReminder} /// A reminder a user set on a message. /// @@ -20,7 +14,7 @@ const _nullConst = _NullConst(); /// message at that time. One without it is a bookmark, which keeps the message /// for later without a notification. /// {@endtemplate} -@Freezed(copyWith: false) +@freezed class MessageReminder with _$MessageReminder { /// {@macro messageReminder} MessageReminder({ @@ -74,34 +68,6 @@ class MessageReminder with _$MessageReminder { @override final DateTime updatedAt; - /// 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, - String? messageId, - Message? message, - String? userId, - User? user, - Object? remindAt = _nullConst, - DateTime? createdAt, - DateTime? updatedAt, - }) { - return MessageReminder( - channelCid: channelCid ?? this.channelCid, - channel: channel ?? this.channel, - messageId: messageId ?? this.messageId, - message: message ?? this.message, - userId: userId ?? this.userId, - user: user ?? this.user, - remindAt: remindAt == _nullConst ? this.remindAt : remindAt as DateTime?, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ); - } - /// Returns this reminder with the fields [other] sets. /// /// A field [other] leaves null keeps its current value, except [remindAt]: a @@ -111,11 +77,11 @@ class MessageReminder with _$MessageReminder { if (other == null) return this; return copyWith( channelCid: other.channelCid, - channel: other.channel, + channel: other.channel ?? channel, messageId: other.messageId, - message: other.message, + message: other.message ?? message, userId: other.userId, - user: other.user, + user: other.user ?? user, remindAt: other.remindAt, createdAt: other.createdAt, updatedAt: other.updatedAt, 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 index 6aeab6eb0a..c37b45e055 100644 --- a/packages/stream_chat/lib/src/core/models/message_reminder.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/message_reminder.freezed.dart @@ -23,6 +23,13 @@ mixin _$MessageReminder { DateTime get createdAt; DateTime get updatedAt; + /// Create a copy of MessageReminder + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $MessageReminderCopyWith get copyWith => + _$MessageReminderCopyWithImpl(this as MessageReminder, _$identity); + @override bool operator ==(Object other) { return identical(this, other) || @@ -48,3 +55,86 @@ mixin _$MessageReminder { return 'MessageReminder(channelCid: $channelCid, channel: $channel, messageId: $messageId, message: $message, userId: $userId, user: $user, remindAt: $remindAt, createdAt: $createdAt, updatedAt: $updatedAt)'; } } + +/// @nodoc +abstract mixin class $MessageReminderCopyWith<$Res> { + factory $MessageReminderCopyWith(MessageReminder value, $Res Function(MessageReminder) _then) = + _$MessageReminderCopyWithImpl; + @useResult + $Res call({ + String channelCid, + ChannelModel? channel, + String messageId, + Message? message, + String userId, + User? user, + DateTime? remindAt, + DateTime? createdAt, + DateTime? updatedAt, + }); +} + +/// @nodoc +class _$MessageReminderCopyWithImpl<$Res> implements $MessageReminderCopyWith<$Res> { + _$MessageReminderCopyWithImpl(this._self, this._then); + + final MessageReminder _self; + final $Res Function(MessageReminder) _then; + + /// Create a copy of MessageReminder + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? channelCid = null, + Object? channel = freezed, + Object? messageId = null, + Object? message = freezed, + Object? userId = null, + Object? user = freezed, + Object? remindAt = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, + }) { + return _then( + MessageReminder( + channelCid: null == channelCid + ? _self.channelCid + : channelCid // ignore: cast_nullable_to_non_nullable + as String, + channel: freezed == channel + ? _self.channel + : channel // ignore: cast_nullable_to_non_nullable + as ChannelModel?, + messageId: null == messageId + ? _self.messageId + : messageId // ignore: cast_nullable_to_non_nullable + as String, + message: freezed == message + ? _self.message + : message // ignore: cast_nullable_to_non_nullable + as Message?, + userId: null == userId + ? _self.userId + : userId // ignore: cast_nullable_to_non_nullable + as String, + user: freezed == user + ? _self.user + : user // ignore: cast_nullable_to_non_nullable + as User?, + remindAt: freezed == remindAt + ? _self.remindAt + : remindAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + createdAt: freezed == createdAt + ? _self.createdAt! + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _self.updatedAt! + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/reaction.dart b/packages/stream_chat/lib/src/core/models/reaction.dart index 5e2312087b..96a3f31034 100644 --- a/packages/stream_chat/lib/src/core/models/reaction.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.dart @@ -6,7 +6,7 @@ import 'user.dart'; part 'reaction.freezed.dart'; /// A reaction a user added to a message. -@Freezed(copyWith: false) +@freezed class Reaction with _$Reaction { /// Creates a new [Reaction]. /// @@ -62,38 +62,17 @@ class Reaction with _$Reaction { @override final Map custom; - /// Creates a copy of [Reaction] with specified attributes overridden. - Reaction copyWith({ - String? messageId, - String? type, - User? user, - String? userId, - int? score, - String? emojiCode, - DateTime? createdAt, - DateTime? updatedAt, - Map? custom, - }) => Reaction( - messageId: messageId ?? this.messageId, - type: type ?? this.type, - user: user ?? this.user, - userId: userId ?? this.userId, - score: score ?? this.score, - emojiCode: emojiCode ?? this.emojiCode, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - custom: custom ?? this.custom, - ); - /// Returns a new [Reaction] that is a combination of this reaction and the /// given [other] reaction. + /// + /// A field [other] leaves null keeps its current value. Reaction merge(Reaction other) => copyWith( - messageId: other.messageId, + messageId: other.messageId ?? messageId, type: other.type, - user: other.user, - userId: other.userId, + user: other.user ?? user, + userId: other.userId ?? userId, score: other.score, - emojiCode: other.emojiCode, + emojiCode: other.emojiCode ?? emojiCode, createdAt: other.createdAt, updatedAt: other.updatedAt, custom: other.custom, diff --git a/packages/stream_chat/lib/src/core/models/reaction.freezed.dart b/packages/stream_chat/lib/src/core/models/reaction.freezed.dart index 2dbb19c5f7..35f5daeead 100644 --- a/packages/stream_chat/lib/src/core/models/reaction.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.freezed.dart @@ -23,6 +23,12 @@ mixin _$Reaction { DateTime get updatedAt; Map get custom; + /// Create a copy of Reaction + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ReactionCopyWith get copyWith => _$ReactionCopyWithImpl(this as Reaction, _$identity); + @override bool operator ==(Object other) { return identical(this, other) || @@ -58,3 +64,85 @@ mixin _$Reaction { return 'Reaction(messageId: $messageId, type: $type, score: $score, emojiCode: $emojiCode, user: $user, userId: $userId, createdAt: $createdAt, updatedAt: $updatedAt, custom: $custom)'; } } + +/// @nodoc +abstract mixin class $ReactionCopyWith<$Res> { + factory $ReactionCopyWith(Reaction value, $Res Function(Reaction) _then) = _$ReactionCopyWithImpl; + @useResult + $Res call({ + String? messageId, + String type, + User? user, + String? userId, + int score, + String? emojiCode, + DateTime? createdAt, + DateTime? updatedAt, + Map custom, + }); +} + +/// @nodoc +class _$ReactionCopyWithImpl<$Res> implements $ReactionCopyWith<$Res> { + _$ReactionCopyWithImpl(this._self, this._then); + + final Reaction _self; + final $Res Function(Reaction) _then; + + /// Create a copy of Reaction + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? messageId = freezed, + Object? type = null, + Object? user = freezed, + Object? userId = freezed, + Object? score = null, + Object? emojiCode = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, + Object? custom = null, + }) { + return _then( + Reaction( + messageId: freezed == messageId + ? _self.messageId + : messageId // ignore: cast_nullable_to_non_nullable + as String?, + type: null == type + ? _self.type + : type // ignore: cast_nullable_to_non_nullable + as String, + user: freezed == user + ? _self.user + : user // ignore: cast_nullable_to_non_nullable + as User?, + userId: freezed == userId + ? _self.userId + : userId // ignore: cast_nullable_to_non_nullable + as String?, + score: null == score + ? _self.score + : score // ignore: cast_nullable_to_non_nullable + as int, + emojiCode: freezed == emojiCode + ? _self.emojiCode + : emojiCode // ignore: cast_nullable_to_non_nullable + as String?, + createdAt: freezed == createdAt + ? _self.createdAt! + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _self.updatedAt! + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + custom: null == custom + ? _self.custom + : custom // ignore: cast_nullable_to_non_nullable + as Map, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/reaction_group.dart b/packages/stream_chat/lib/src/core/models/reaction_group.dart index 1bd36fd599..023011f2a9 100644 --- a/packages/stream_chat/lib/src/core/models/reaction_group.dart +++ b/packages/stream_chat/lib/src/core/models/reaction_group.dart @@ -6,7 +6,7 @@ part 'reaction_group.freezed.dart'; part 'reaction_group.g.dart'; /// The reactions of one type on a message, counted and scored together. -@Freezed(copyWith: false) +@freezed // TODO(openapi-migration): remove in group 10 @DataSerializable() class ReactionGroup with _$ReactionGroup { @@ -47,23 +47,6 @@ class ReactionGroup with _$ReactionGroup { /// /// It is not a codec for API payloads. Map toData() => _$ReactionGroupToJson(this); - - /// 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, - DateTime? firstReactionAt, - DateTime? lastReactionAt, - }) { - return ReactionGroup( - count: count ?? this.count, - sumScores: sumScores ?? this.sumScores, - firstReactionAt: firstReactionAt ?? this.firstReactionAt, - lastReactionAt: lastReactionAt ?? this.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 index 4b508ce1f6..7c5796b187 100644 --- a/packages/stream_chat/lib/src/core/models/reaction_group.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/reaction_group.freezed.dart @@ -18,6 +18,13 @@ mixin _$ReactionGroup { DateTime get firstReactionAt; DateTime get lastReactionAt; + /// Create a copy of ReactionGroup + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $ReactionGroupCopyWith get copyWith => + _$ReactionGroupCopyWithImpl(this as ReactionGroup, _$identity); + @override bool operator ==(Object other) { return identical(this, other) || @@ -37,3 +44,50 @@ mixin _$ReactionGroup { return 'ReactionGroup(count: $count, sumScores: $sumScores, firstReactionAt: $firstReactionAt, lastReactionAt: $lastReactionAt)'; } } + +/// @nodoc +abstract mixin class $ReactionGroupCopyWith<$Res> { + factory $ReactionGroupCopyWith(ReactionGroup value, $Res Function(ReactionGroup) _then) = _$ReactionGroupCopyWithImpl; + @useResult + $Res call({int count, int sumScores, DateTime? firstReactionAt, DateTime? lastReactionAt}); +} + +/// @nodoc +class _$ReactionGroupCopyWithImpl<$Res> implements $ReactionGroupCopyWith<$Res> { + _$ReactionGroupCopyWithImpl(this._self, this._then); + + final ReactionGroup _self; + final $Res Function(ReactionGroup) _then; + + /// Create a copy of ReactionGroup + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? count = null, + Object? sumScores = null, + Object? firstReactionAt = freezed, + Object? lastReactionAt = freezed, + }) { + return _then( + ReactionGroup( + count: null == count + ? _self.count + : count // ignore: cast_nullable_to_non_nullable + as int, + sumScores: null == sumScores + ? _self.sumScores + : sumScores // ignore: cast_nullable_to_non_nullable + as int, + firstReactionAt: freezed == firstReactionAt + ? _self.firstReactionAt! + : firstReactionAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + lastReactionAt: freezed == lastReactionAt + ? _self.lastReactionAt! + : lastReactionAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ), + ); + } +} diff --git a/packages/stream_chat/test/fixtures/message.json b/packages/stream_chat/test/fixtures/message.json index 25d8491dc4..9559565c97 100644 --- a/packages/stream_chat/test/fixtures/message.json +++ b/packages/stream_chat/test/fixtures/message.json @@ -8,10 +8,6 @@ "html": "

Hi

", "mml": "Hi", "deleted_reply_count": 2, - "image_labels": { - "cat.png": ["cat"] - }, - "mentioned_channel_members": {}, "user": { "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", "role": "user", diff --git a/packages/stream_chat/test/src/core/models/draft_message_test.dart b/packages/stream_chat/test/src/core/models/draft_message_test.dart index a8e4f0e7fe..9e14e10650 100644 --- a/packages/stream_chat/test/src/core/models/draft_message_test.dart +++ b/packages/stream_chat/test/src/core/models/draft_message_test.dart @@ -236,8 +236,6 @@ void main() { 'text': text, 'custom_field': 'custom_value', 'priority': 5, - 'html': '

Hi

', - 'mml': 'Hi', }; final deserializedMessage = DraftMessage.fromJson(json); 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 26adcb5d21..b1db8e50fc 100644 --- a/packages/stream_chat/test/src/core/models/reaction_test.dart +++ b/packages/stream_chat/test/src/core/models/reaction_test.dart @@ -99,6 +99,17 @@ void main() { }); }); + test('Reaction.merge keeps the message id, user and emoji code the other reaction lacks', () { + final reaction = _fixtureReaction(); + + final merged = reaction.merge(Reaction(type: 'lol')); + + expect( + (merged.messageId, merged.user, merged.userId, merged.emojiCode), + (reaction.messageId, reaction.user, reaction.userId, reaction.emojiCode), + ); + }); + test('merge', () { final reaction = _fixtureReaction(); final newUserCreateTime = DateTime.now(); From c29a062350f8818d5ee57a612bb111ef7b7e3349 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 19 Oct 2026 13:05:53 +0200 Subject: [PATCH 13/14] test(docs): pass attachment custom data as custom in the voice recording screenshots Co-Authored-By: Claude Opus 5.5 --- .../test/voice_recording/voice_recording_test.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart b/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart index 6653c22c13..e1f3129c6c 100644 --- a/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart +++ b/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart @@ -262,7 +262,7 @@ void main() { type: 'voiceRecording', assetUrl: 'https://example.com/recording.m4a', uploadState: const UploadState.success(), - extraData: const { + custom: const { 'duration': 15.0, 'waveform_data': [0.1, 0.5, 0.9, 0.4, 0.2], }, @@ -294,7 +294,7 @@ void main() { type: 'voiceRecording', assetUrl: 'https://example.com/recording.m4a', uploadState: const UploadState.success(), - extraData: const { + custom: const { 'duration': 15.0, 'waveform_data': [0.1, 0.5, 0.9, 0.4, 0.2], }, @@ -328,7 +328,7 @@ void main() { type: 'voiceRecording', assetUrl: 'https://example.com/recording.m4a', uploadState: const UploadState.success(), - extraData: const { + custom: const { 'duration': 15.0, 'waveform_data': [ 0.1, @@ -423,7 +423,7 @@ void main() { type: 'voiceRecording', assetUrl: 'https://example.com/recording.m4a', uploadState: const UploadState.success(), - extraData: const { + custom: const { 'duration': 15.0, 'waveform_data': [ 0.1, @@ -516,7 +516,7 @@ void main() { type: 'voiceRecording', assetUrl: 'https://example.com/recording.m4a', uploadState: const UploadState.success(), - extraData: const { + custom: const { 'duration': 15.0, 'waveform_data': [ 0.1, From 7f8ef0c42b22c53416982b2ce65c8b6dca9c417e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 20 Oct 2026 10:42:13 +0200 Subject: [PATCH 14/14] refactor(llc): align the message and reminder docs with the review rules Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 7 +++---- .../stream_chat/lib/src/core/models/attachment.dart | 5 +---- .../stream_chat/lib/src/core/models/draft_message.dart | 9 ++++----- packages/stream_chat/lib/src/core/models/message.dart | 10 ++++------ .../lib/src/core/models/message_reminder.dart | 10 ++++++---- packages/stream_chat/lib/src/core/models/reaction.dart | 7 ++++--- .../core/models/response/create_reminder_response.dart | 8 ++++++-- .../core/models/response/delete_reminder_response.dart | 8 ++++++-- .../core/models/response/query_reminders_response.dart | 7 +++++-- .../core/models/response/update_reminder_response.dart | 8 ++++++-- .../lib/src/repository/reminders_repository.dart | 2 +- .../stream_chat/test/src/core/models/message_test.dart | 9 +++++++++ 12 files changed, 55 insertions(+), 35 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index c4744d8331..f67a0c5bfa 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -101,7 +101,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**User Updates**](#user-updates) | `updateUser` and `updateUsers` return a `Result` instead of throwing; `partialUpdateUser(s)` becomes `updateUserPartial` / `updateUsersPartial` and `PartialUpdateUserRequest` becomes `UpdateUserPartialRequest`; `updateUser` no longer sends `role`, `teams` or `teamsRole`, and the returned users no longer carry their private fields in `extraData` | | [**Channel and Member Custom Data**](#channel-and-member-custom-data) | `extraData` is `custom` on `ChannelModel`, `Member` and `Channel`; the channel and member flags it carried are fields | | [**User Custom Data**](#user-custom-data) | `extraData` is `custom` on `User` and `OwnUser`; `name` is nullable and `nameOrId` falls back to the id; the user fields it carried are fields | -| [**Message Custom Data**](#message-custom-data) | `extraData` is `custom` on `Message`, `Attachment`, `Reaction` and `DraftMessage`, and holds the custom data verbatim; a message's channel id, HTML, markup and deleted reply count, and an attachment's Giphy renditions, are fields | +| [**Message Custom Data**](#message-custom-data) | `extraData` is `custom` on `Message`, `Attachment`, `Reaction` and `DraftMessage`; a message's channel id, HTML, markup and deleted reply count, and an attachment's Giphy renditions, are fields | | _(filled in per feature as PRs land)_ | | --- @@ -1157,9 +1157,8 @@ final html = message.extraData['html'] as String?; final html = message.html; ``` -**`custom` is the custom data exactly as the API returns it.** An entry named like one of the model's own fields -stays in `custom` and leaves the field alone; it is left out when the model is sent, so it cannot overwrite the -field. A reaction on a reminder's message keeps its emoji code in its `custom` as well as in `Reaction.emojiCode`. +**An entry of `custom` named like one of the model's own fields stays in `custom`** and leaves the field alone; it +is left out when the model is sent, so it cannot overwrite the field. A reaction on a reminder's message keeps its emoji code in its `custom` as well as in `Reaction.emojiCode`. > **Why:** the API calls it `custom`, and every migrated model takes that name. diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index 95fba10824..52043d67fe 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -160,9 +160,6 @@ class Attachment extends Equatable { final UploadState uploadState; /// This attachment's custom data. - /// - /// It may hold a key named like one of the attachment's own fields; [toJson] - /// leaves such an entry out so it never overwrites the field. @JsonKey(name: 'extra_data') final Map custom; @@ -229,7 +226,7 @@ class Attachment extends Equatable { ...dbSpecificTopLevelFields, ]; - /// Serialize to json + /// Serializes this attachment to JSON. /// /// Writes the entries of [custom] at the root, leaving out any named like one /// of [topLevelFields]. 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 457c460f18..10835f271c 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.dart @@ -93,14 +93,13 @@ class DraftMessage extends Equatable { final String? _pollId; /// This draft message's custom data. - /// - /// It may hold a key named like one of the draft message's own fields; - /// [toJson] leaves such an entry out so it never overwrites the field. @JsonKey(name: 'extra_data') final Map custom; /// The JSON keys [DraftMessage.fromJson] reads into named fields, or leaves - /// out; every other key is kept in [custom]. + /// out. + /// + /// Every other key is kept in [custom]. static const topLevelFields = [ 'id', 'text', @@ -117,7 +116,7 @@ class DraftMessage extends Equatable { 'extra_data', ]; - /// Serialize to json. + /// Serializes this draft message to JSON. /// /// Writes the entries of [custom] at the root, leaving out any named like one /// of [topLevelFields]. diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 0fc440e90e..da8198962c 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -347,9 +347,6 @@ class Message extends Equatable { final bool? deletedForMe; /// This message's custom data. - /// - /// It may hold a key named like one of the message's own fields; [toJson] - /// leaves such an entry out so it never overwrites the field. @JsonKey(name: 'extra_data') final Map custom; @@ -374,8 +371,9 @@ class Message extends Equatable { @JsonKey(includeToJson: false) final Map? i18n; - /// The JSON keys [Message.fromJson] reads into named fields, or leaves out; - /// every other key is kept in [custom]. + /// The JSON keys [Message.fromJson] reads into named fields, or leaves out. + /// + /// Every other key is kept in [custom]. static const topLevelFields = [ 'id', 'text', @@ -428,7 +426,7 @@ class Message extends Equatable { 'deleted_reply_count', ]; - /// Serialize to json. + /// Serializes this message to JSON. /// /// Writes the entries of [custom] at the root, leaving out any named like one /// of [topLevelFields]. 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 a9ab473996..c1ca0ea135 100644 --- a/packages/stream_chat/lib/src/core/models/message_reminder.dart +++ b/packages/stream_chat/lib/src/core/models/message_reminder.dart @@ -68,11 +68,13 @@ class MessageReminder with _$MessageReminder { @override final DateTime updatedAt; - /// Returns this reminder with the fields [other] sets. + /// A copy of this reminder updated with the fields [other] carries. /// - /// 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. + /// A nullable field [other] leaves null keeps its value here, except + /// [remindAt]: a null one clears it. + /// + /// Every other field takes the value of [other]. If [other] is null, this + /// reminder is returned unchanged. MessageReminder merge(MessageReminder? other) { if (other == null) return this; return copyWith( diff --git a/packages/stream_chat/lib/src/core/models/reaction.dart b/packages/stream_chat/lib/src/core/models/reaction.dart index 96a3f31034..479b0d9b06 100644 --- a/packages/stream_chat/lib/src/core/models/reaction.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.dart @@ -62,10 +62,11 @@ class Reaction with _$Reaction { @override final Map custom; - /// Returns a new [Reaction] that is a combination of this reaction and the - /// given [other] reaction. + /// A copy of this reaction updated with the fields [other] carries. /// - /// A field [other] leaves null keeps its current value. + /// A nullable field [other] leaves null keeps its value here. + /// + /// Every other field, [custom] included, takes the value of [other]. Reaction merge(Reaction other) => copyWith( messageId: other.messageId ?? messageId, type: other.type, 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 index 98b961801f..5101e31768 100644 --- 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 @@ -4,8 +4,12 @@ import '../message_reminder.dart'; part 'create_reminder_response.freezed.dart'; -/// The result of creating a reminder on a message, returned by -/// [StreamChatClient.createReminder] and [Channel.createReminder]. +/// The result of creating a reminder on a message. +/// +/// See also: +/// +/// * [StreamChatClient.createReminder] and [Channel.createReminder], which +/// return it. @freezed class CreateReminderResponse with _$CreateReminderResponse { /// Creates a new [CreateReminderResponse]. 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 index b34390f829..86369778e2 100644 --- 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 @@ -2,8 +2,12 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'delete_reminder_response.freezed.dart'; -/// The result of deleting the reminder on a message, returned by -/// [StreamChatClient.deleteReminder] and [Channel.deleteReminder]. +/// The result of deleting the reminder on a message. +/// +/// See also: +/// +/// * [StreamChatClient.deleteReminder] and [Channel.deleteReminder], which +/// return it. @freezed class DeleteReminderResponse with _$DeleteReminderResponse { /// Creates a new [DeleteReminderResponse]. 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 index d25baf866e..50029605a8 100644 --- 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 @@ -4,8 +4,11 @@ import '../message_reminder.dart'; part 'query_reminders_response.freezed.dart'; -/// One page of the current user's reminders matching a query, returned by -/// [StreamChatClient.queryReminders]. +/// One page of the current user's reminders matching a query. +/// +/// See also: +/// +/// * [StreamChatClient.queryReminders], which returns it. @freezed class QueryRemindersResponse with _$QueryRemindersResponse { /// Creates a new [QueryRemindersResponse]. 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 index ebdcf525de..d1f49b7859 100644 --- 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 @@ -4,8 +4,12 @@ import '../message_reminder.dart'; part 'update_reminder_response.freezed.dart'; -/// The result of updating the reminder on a message, returned by -/// [StreamChatClient.updateReminder] and [Channel.updateReminder]. +/// The result of updating the reminder on a message. +/// +/// See also: +/// +/// * [StreamChatClient.updateReminder] and [Channel.updateReminder], which +/// return it. @freezed class UpdateReminderResponse with _$UpdateReminderResponse { /// Creates a new [UpdateReminderResponse]. diff --git a/packages/stream_chat/lib/src/repository/reminders_repository.dart b/packages/stream_chat/lib/src/repository/reminders_repository.dart index 00ba7fef3a..0b9e8d0cf2 100644 --- a/packages/stream_chat/lib/src/repository/reminders_repository.dart +++ b/packages/stream_chat/lib/src/repository/reminders_repository.dart @@ -12,7 +12,7 @@ import 'mapper/sort_mapper.dart'; /// Repository dedicated to message reminder operations. class RemindersRepository { - /// Initialize a new reminders repository. + /// Creates a new reminders repository. const RemindersRepository(this._api); final api.DefaultApi _api; 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 2478de29d3..e54d9c8472 100644 --- a/packages/stream_chat/test/src/core/models/message_test.dart +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -518,6 +518,15 @@ void main() { expect(json['priority'], 'high'); }); + test('Message.toJson leaves out the fields only fromJson reads', () { + final message = Message(cid: 'messaging:general', html: '

Hi

', mml: 'Hi', deletedReplyCount: 2); + + expect( + message.toJson().keys, + isNot(anyOf(contains('cid'), contains('html'), contains('mml'), contains('deleted_reply_count'))), + ); + }); + test('Message.copyWith keeps the channel id, HTML, markup and deleted reply count', () { final message = Message(cid: 'messaging:general', html: '

Hi

', mml: 'Hi', deletedReplyCount: 2);