diff --git a/melos.yaml b/melos.yaml index 956fcfa7..b4ad6bef 100644 --- a/melos.yaml +++ b/melos.yaml @@ -48,7 +48,7 @@ command: shared_preferences: ^2.5.3 state_notifier: ^1.0.0 stream_feeds: ^0.4.0 - stream_core: ^0.3.0 + stream_core: ^0.3.1 video_player: ^2.10.0 uuid: ^4.5.1 diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index cfc7441d..91befff8 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,3 +1,7 @@ +## Upcoming +- [BREAKING] Unified `ThreadedCommentData` into `CommentData` to handle both flat and threaded comments. +- Add support for `enforceUnique` parameter while adding reactions. + ## 0.4.0 - [BREAKING] Change `queryFollowSuggestions` return type to `List`. - [BREAKING] Remove `activitySelectorOptions` from `FeedQuery`. diff --git a/packages/stream_feeds/lib/src/models.dart b/packages/stream_feeds/lib/src/models.dart index cdc51ae2..e4e469a7 100644 --- a/packages/stream_feeds/lib/src/models.dart +++ b/packages/stream_feeds/lib/src/models.dart @@ -21,5 +21,4 @@ export 'models/request/activity_update_comment_request.dart' show ActivityUpdateCommentRequest; export 'models/request/feed_add_activity_request.dart' show FeedAddActivityRequest; -export 'models/threaded_comment_data.dart'; export 'models/user_data.dart'; diff --git a/packages/stream_feeds/lib/src/models/activity_data.dart b/packages/stream_feeds/lib/src/models/activity_data.dart index e58bd844..baeefcb9 100644 --- a/packages/stream_feeds/lib/src/models/activity_data.dart +++ b/packages/stream_feeds/lib/src/models/activity_data.dart @@ -309,168 +309,161 @@ extension ActivityResponseMapper on ActivityResponse { /// Extension functions for [ActivityData] to handle common operations. extension ActivityDataMutations on ActivityData { - /// Adds a comment to the activity, updating the comment count and the list of comments. + /// Updates this activity with new data while preserving own data. /// - /// @param comment The comment to be added. - /// @return A new [ActivityData] instance with the updated comments and comment count. - ActivityData addComment(CommentData comment) { - final updatedComments = comments.upsert(comment, key: (it) => it.id); + /// Merges [updated] activity data with this instance, preserving [ownBookmarks] and + /// [ownReactions] from this instance when not provided. This ensures that user-specific + /// data is not lost when updating from WebSocket events. + /// + /// Returns a new [ActivityData] instance with the merged data. + ActivityData updateWith( + ActivityData updated, { + List? ownBookmarks, + List? ownReactions, + }) { + return updated.copyWith( + // Preserve own data from the current instance if not provided + // as they may not be reliable from WS events. + ownBookmarks: ownBookmarks ?? this.ownBookmarks, + ownReactions: ownReactions ?? this.ownReactions, + poll: updated.poll?.let((it) => poll?.updateWith(it) ?? it), + ); + } + + /// Adds or updates a comment in this activity. + /// + /// Updates the comments list by adding or updating [comment]. If the comment already + /// exists, it will be updated. The comment count is automatically recalculated. + /// + /// Returns a new [ActivityData] instance with the updated comments and comment count. + ActivityData upsertComment(CommentData comment) { + final currentComments = [...comments]; + final updatedComments = currentComments.upsert(comment, key: (it) => it.id); + + final difference = updatedComments.length - currentComments.length; + final updatedCommentCount = math.max(0, commentCount + difference); return copyWith( comments: updatedComments, - commentCount: math.max(0, commentCount + 1), + commentCount: updatedCommentCount, ); } - /// Removes a comment from the activity, updating the comment count and the list of comments. + /// Removes a comment from this activity. + /// + /// Updates the comments list by removing [comment]. The comment count is automatically + /// recalculated. /// - /// @param comment The comment to be removed. - /// @return A new [ActivityData] instance with the updated comments and comment count. + /// Returns a new [ActivityData] instance with the updated comments and comment count. ActivityData removeComment(CommentData comment) { - final updatedComments = comments.where((it) { + final currentComments = [...comments]; + final updatedComments = currentComments.where((it) { return it.id != comment.id; }).toList(); + final difference = updatedComments.length - currentComments.length; + final updatedCommentCount = math.max(0, commentCount + difference); + return copyWith( comments: updatedComments, - commentCount: math.max(0, commentCount - 1), + commentCount: updatedCommentCount, ); } - /// Adds a bookmark to the activity, updating the own bookmarks and bookmark count. + /// Adds or updates a bookmark in this activity. + /// + /// Updates the own bookmarks list by adding or updating [bookmark]. Only adds bookmarks + /// that belong to [currentUserId]. If the bookmark already exists, it will be updated. /// - /// @param bookmark The bookmark to be added. - /// @param currentUserId The ID of the current user, used to determine if the bookmark belongs to - /// them. - /// @return A new [ActivityData] instance with the updated own bookmarks and bookmark count. - ActivityData addBookmark( + /// Returns a new [ActivityData] instance with the updated own bookmarks and bookmark count. + ActivityData upsertBookmark( BookmarkData bookmark, String currentUserId, ) { - final updatedOwnBookmarks = switch (bookmark.user.id == currentUserId) { - true => ownBookmarks.upsert(bookmark, key: (it) => it.id), - false => ownBookmarks, - }; + final updatedOwnBookmarks = ownBookmarks.let((it) { + if (bookmark.user.id != currentUserId) return it; + return it.upsert(bookmark, key: (it) => it.id); + }); - return copyWith( - ownBookmarks: updatedOwnBookmarks, - bookmarkCount: math.max(0, bookmarkCount + 1), - ); + return updateWith(bookmark.activity, ownBookmarks: updatedOwnBookmarks); } - /// Removes a bookmark from the activity, updating the own bookmarks and bookmark count. + /// Removes a bookmark from this activity. /// - /// @param bookmark The bookmark to be deleted. - /// @param currentUserId The ID of the current user, used to determine if the bookmark belongs to - /// them. - /// @return A new [ActivityData] instance with the updated own bookmarks and bookmark count. + /// Updates the own bookmarks list by removing [bookmark]. Only removes bookmarks + /// that belong to [currentUserId]. + /// + /// Returns a new [ActivityData] instance with the updated own bookmarks and bookmark count. ActivityData removeBookmark( BookmarkData bookmark, String currentUserId, ) { - final updatedOwnBookmarks = switch (bookmark.user.id == currentUserId) { - true => ownBookmarks.where((it) => it.id != bookmark.id).toList(), - false => ownBookmarks, - }; + final updatedOwnBookmarks = ownBookmarks.let((it) { + if (bookmark.user.id != currentUserId) return it; + return it.where((it) => it.id != bookmark.id).toList(); + }); - return copyWith( - ownBookmarks: updatedOwnBookmarks, - bookmarkCount: math.max(0, bookmarkCount - 1), - ); + return updateWith(bookmark.activity, ownBookmarks: updatedOwnBookmarks); } - /// Adds a reaction to the activity, updating the latest reactions, reaction groups, - /// reaction count, and own reactions. + /// Adds or updates a reaction in this activity with unique enforcement. + /// + /// Updates the own reactions list by adding or updating [reaction]. Only adds reactions + /// that belong to [currentUserId]. When unique enforcement is enabled, replaces any + /// existing reaction from the same user. /// - /// @param reaction The reaction to be added. - /// @param currentUserId The ID of the current user, used to determine if the reaction belongs to. - /// @return A new [ActivityData] instance with the updated reactions and counts. - ActivityData addReaction( + /// Returns a new [ActivityData] instance with the updated own reactions. + ActivityData upsertUniqueReaction( + ActivityData updatedActivity, FeedsReactionData reaction, String currentUserId, ) { - final updatedOwnReactions = switch (reaction.user.id == currentUserId) { - true => ownReactions.upsert(reaction, key: (it) => it.id), - false => ownReactions, - }; - - final updatedLatestReactions = latestReactions.upsert( + return upsertReaction( + updatedActivity, reaction, - key: (reaction) => reaction.id, + currentUserId, + enforceUnique: true, ); + } - final reactionGroup = switch (reactionGroups[reaction.type]) { - final existingGroup? => existingGroup, - _ => ReactionGroupData( - count: 1, - firstReactionAt: reaction.createdAt, - lastReactionAt: reaction.createdAt, - ), - }; - - final updatedReactionGroups = { - ...reactionGroups, - reaction.type: reactionGroup.increment(reaction.createdAt), - }; - - final updatedReactionCount = updatedReactionGroups.values.sumOf( - (group) => group.count, - ); + /// Adds or updates a reaction in this activity. + /// + /// Updates the own reactions list by adding or updating [reaction]. Only adds reactions + /// that belong to [currentUserId]. When [enforceUnique] is true, replaces any existing + /// reaction from the same user; otherwise, allows multiple reactions from the same user. + /// + /// Returns a new [ActivityData] instance with the updated own reactions. + ActivityData upsertReaction( + ActivityData updatedActivity, + FeedsReactionData reaction, + String currentUserId, { + bool enforceUnique = false, + }) { + final updatedOwnReactions = ownReactions.let((it) { + if (reaction.user.id != currentUserId) return it; + return it.upsertReaction(reaction, enforceUnique: enforceUnique); + }); - return copyWith( - ownReactions: updatedOwnReactions, - latestReactions: updatedLatestReactions, - reactionGroups: updatedReactionGroups, - reactionCount: updatedReactionCount, - ); + return updateWith(updatedActivity, ownReactions: updatedOwnReactions); } - /// Removes a reaction from the activity, updating the latest reactions, reaction groups, - /// reaction count, and own reactions. + /// Removes a reaction from this activity. + /// + /// Updates the own reactions list by removing [reaction]. Only removes reactions + /// that belong to [currentUserId]. /// - /// @param reaction The reaction to be removed. - /// @param currentUserId The ID of the current user, used to determine if the reaction belongs to. - /// @return A new [ActivityData] instance with the updated reactions and counts. + /// Returns a new [ActivityData] instance with the updated own reactions. ActivityData removeReaction( + ActivityData updatedActivity, FeedsReactionData reaction, String currentUserId, ) { - final updatedOwnReactions = switch (reaction.user.id == currentUserId) { - true => ownReactions.where((it) => it.id != reaction.id).toList(), - false => ownReactions, - }; - - final updatedLatestReactions = latestReactions.where((it) { - return it.id != reaction.id; - }).toList(); - - final updatedReactionGroups = {...reactionGroups}; - final reactionGroup = updatedReactionGroups.remove(reaction.type); - - if (reactionGroup == null) { - // If there is no reaction group for this type, just update latest and own reactions. - // Note: This is only a hypothetical case, as we should always have a reaction group. - return copyWith( - latestReactions: updatedLatestReactions, - ownReactions: updatedOwnReactions, - ); - } - - final updatedReactionGroup = reactionGroup.decrement(reaction.createdAt); - if (updatedReactionGroup.count > 0) { - updatedReactionGroups[reaction.type] = updatedReactionGroup; - } - - final updatedReactionCount = updatedReactionGroups.values.sumOf( - (group) => group.count, - ); + final updatedOwnReactions = ownReactions.let((it) { + if (reaction.user.id != currentUserId) return it; + return it.where((it) => it.id != reaction.id).toList(); + }); - return copyWith( - ownReactions: updatedOwnReactions, - latestReactions: updatedLatestReactions, - reactionGroups: updatedReactionGroups, - reactionCount: updatedReactionCount, - ); + return updateWith(updatedActivity, ownReactions: updatedOwnReactions); } } diff --git a/packages/stream_feeds/lib/src/models/comment_data.dart b/packages/stream_feeds/lib/src/models/comment_data.dart index 5bd07b7d..ea7db20c 100644 --- a/packages/stream_feeds/lib/src/models/comment_data.dart +++ b/packages/stream_feeds/lib/src/models/comment_data.dart @@ -1,6 +1,9 @@ // ignore_for_file: avoid_redundant_argument_values +import 'dart:math' as math; + import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_core/stream_core.dart'; import '../generated/api/models.dart'; import '../state/query/comments_query.dart'; @@ -157,6 +160,146 @@ class CommentData with _$CommentData implements CommentsSortDataFields { bool get isThreaded => replies != null; } +/// Extension functions for [CommentData] to handle common operations. +extension CommentDataMutations on CommentData { + /// Updates this comment with new data while preserving own reactions. + /// + /// Merges [updated] comment data with this instance, preserving [ownReactions] from + /// this instance when not provided. For threaded comments, also preserves meta and + /// replies data. This ensures that user-specific data is not lost when updating + /// from WebSocket events. + /// + /// Returns a new [CommentData] instance with the merged data. + CommentData updateWith( + CommentData updated, { + List? ownReactions, + }) { + // If the comment is threaded, only preserve own reactions. + if (updated.isThreaded) { + return updated.copyWith( + // Preserve own reactions from the current instance if not provided + // as they may not be reliable from WS events. + ownReactions: ownReactions ?? this.ownReactions, + ); + } + + // For non-threaded comments, preserve meta and replies as well. + return updated.copyWith( + meta: meta, + replies: replies, + // Preserve own reactions from the current instance if not provided + // as they may not be reliable from WS events. + ownReactions: ownReactions ?? this.ownReactions, + ); + } + + /// Adds or updates a reply in this comment. + /// + /// Updates the replies list by adding or updating [reply]. If the reply already exists, + /// it will be updated. The reply count is automatically recalculated. + /// + /// Returns a new [CommentData] instance with the updated replies and reply count. + CommentData upsertReply( + CommentData reply, + Comparator compare, + ) { + final currentReplies = [...?replies]; + final updatedReplies = currentReplies.sortedUpsert( + reply, + key: (it) => it.id, + compare: compare, + update: (existing, updated) => existing.updateWith(updated), + ); + + final difference = updatedReplies.length - currentReplies.length; + final updatedReplyCount = math.max(0, replyCount + difference); + + return copyWith( + replies: updatedReplies, + replyCount: updatedReplyCount, + ); + } + + /// Removes a reply from this comment. + /// + /// Updates the replies list by removing [reply]. The reply count is automatically + /// recalculated. + /// + /// Returns a new [CommentData] instance with the updated replies and reply count. + CommentData removeReply(CommentData reply) { + final currentReplies = [...?replies]; + final updatedReplies = [...currentReplies.where((it) => it.id != reply.id)]; + + final difference = updatedReplies.length - currentReplies.length; + final updatedReplyCount = math.max(0, replyCount + difference); + + return copyWith( + replies: updatedReplies, + replyCount: updatedReplyCount, + ); + } + + /// Adds or updates a reaction in this comment with unique enforcement. + /// + /// Updates the own reactions list by adding or updating [reaction]. Only adds reactions + /// that belong to [currentUserId]. When unique enforcement is enabled, replaces any + /// existing reaction from the same user. + /// + /// Returns a new [CommentData] instance with the updated own reactions. + CommentData upsertUniqueReaction( + CommentData updatedComment, + FeedsReactionData reaction, + String currentUserId, + ) { + return upsertReaction( + updatedComment, + reaction, + currentUserId, + enforceUnique: true, + ); + } + + /// Adds or updates a reaction in this comment. + /// + /// Updates the own reactions list by adding or updating [reaction]. Only adds reactions + /// that belong to [currentUserId]. When [enforceUnique] is true, replaces any existing + /// reaction from the same user; otherwise, allows multiple reactions from the same user. + /// + /// Returns a new [CommentData] instance with the updated own reactions. + CommentData upsertReaction( + CommentData updatedComment, + FeedsReactionData reaction, + String currentUserId, { + bool enforceUnique = false, + }) { + final updatedOwnReactions = ownReactions.let((it) { + if (reaction.user.id != currentUserId) return it; + return it.upsertReaction(reaction, enforceUnique: enforceUnique); + }); + + return updateWith(updatedComment, ownReactions: updatedOwnReactions); + } + + /// Removes a reaction from this comment. + /// + /// Updates the own reactions list by removing [reaction]. Only removes reactions + /// that belong to [currentUserId]. + /// + /// Returns a new [CommentData] instance with the updated own reactions. + CommentData removeReaction( + CommentData updatedComment, + FeedsReactionData reaction, + String currentUserId, + ) { + final updatedOwnReactions = ownReactions.let((it) { + if (reaction.user.id != currentUserId) return it; + return it.where((it) => it.id != reaction.id).toList(); + }); + + return updateWith(updatedComment, ownReactions: updatedOwnReactions); + } +} + /// Extension function to convert a [CommentResponse] to a [CommentData] model. extension CommentResponseMapper on CommentResponse { /// Converts this API comment response to a domain [CommentData] instance. @@ -197,3 +340,43 @@ extension CommentResponseMapper on CommentResponse { ); } } + +extension ThreadedCommentResponseMapper on ThreadedCommentResponse { + /// Converts this API comment response to a domain [CommentData] instance. + /// + /// Returns a [CommentData] instance containing all the comment information + /// from the API response with proper type conversions and null handling. + CommentData toModel() { + return CommentData( + attachments: attachments, + confidenceScore: confidenceScore, + controversyScore: controversyScore, + createdAt: createdAt, + custom: custom, + deletedAt: deletedAt, + downvoteCount: downvoteCount, + id: id, + latestReactions: [...?latestReactions?.map((e) => e.toModel())], + mentionedUsers: [...mentionedUsers.map((e) => e.toModel())], + meta: meta, + moderation: moderation?.toModel(), + objectId: objectId, + objectType: objectType, + ownReactions: [...ownReactions.map((e) => e.toModel())], + parentId: parentId, + reactionCount: reactionCount, + reactionGroups: { + for (final entry in {...?reactionGroups?.entries}) + entry.key: entry.value.toModel(), + }, + replies: replies?.map((e) => e.toModel()).toList(), + replyCount: replyCount, + score: score, + status: status, + text: text, + updatedAt: updatedAt, + upvoteCount: upvoteCount, + user: user.toModel(), + ); + } +} diff --git a/packages/stream_feeds/lib/src/models/feeds_reaction_data.dart b/packages/stream_feeds/lib/src/models/feeds_reaction_data.dart index fef89675..12075fa2 100644 --- a/packages/stream_feeds/lib/src/models/feeds_reaction_data.dart +++ b/packages/stream_feeds/lib/src/models/feeds_reaction_data.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_core/stream_core.dart'; import '../generated/api/models.dart'; import 'user_data.dart'; @@ -50,10 +51,47 @@ class FeedsReactionData with _$FeedsReactionData { @override final Map? custom; - /// Unique identifier for the reaction, generated from the activity ID and user ID. - String get id { - if (commentId case final id?) return '${user.id}-$type-$id-$activityId'; - return '${user.id}-$type-$activityId'; + /// Unique identifier for the reaction. + /// + /// Combines the reaction type and user reactions group ID. + String get id => '$type-$userReactionsGroupId'; + + /// Identifier for grouping a user's reactions. + String get userReactionsGroupId { + if (commentId case final id?) return '${user.id}-$id-$activityId'; + return '${user.id}-$activityId'; + } +} + +/// Extension functions for managing reactions in a list. +extension FeedsReactionListMutation on List { + static int _alwaysEqualComparator(T a, T b) => 0; + + /// Adds or updates a reaction in this list. + /// + /// Updates this list by adding or updating [reaction]. When [enforceUnique] is true, + /// replaces any existing reaction from the same user reactions group; otherwise, + /// allows multiple reactions. Uses [compare] to determine the order when inserting. + /// + /// Returns a new list with the updated reactions. + List upsertReaction( + FeedsReactionData reaction, { + bool enforceUnique = false, + Comparator compare = _alwaysEqualComparator, + }) { + if (enforceUnique) { + return insertUnique( + reaction, + key: (r) => r.userReactionsGroupId, + compare: compare, + ); + } + + return sortedUpsert( + reaction, + key: (r) => r.id, + compare: compare, + ); } } diff --git a/packages/stream_feeds/lib/src/models/poll_data.dart b/packages/stream_feeds/lib/src/models/poll_data.dart index 1925750f..b928cf4a 100644 --- a/packages/stream_feeds/lib/src/models/poll_data.dart +++ b/packages/stream_feeds/lib/src/models/poll_data.dart @@ -150,7 +150,32 @@ class PollData with _$PollData { final Map? custom; } +/// Extension functions for [PollData] to handle common operations. extension PollDataMutations on PollData { + /// Updates this poll with new data while preserving own votes and answers. + /// + /// Merges [other] poll data with this instance, preserving [ownVotesAndAnswers] from + /// this instance when not provided. This ensures that user-specific data is not lost + /// when updating from WebSocket events. + /// + /// Returns a new [PollData] instance with the merged data. + PollData updateWith( + PollData other, { + List? ownVotesAndAnswers, + }) { + return other.copyWith( + // Preserve ownVotesAndAnswers from the current instance if not provided + // as they may not be reliable from WS events. + ownVotesAndAnswers: ownVotesAndAnswers ?? this.ownVotesAndAnswers, + ); + } + + /// Adds or updates an option in this poll. + /// + /// Updates the options list by adding or updating [option]. If the option already + /// exists (by ID), it will be updated. + /// + /// Returns a new [PollData] instance with the updated options. PollData addOption(PollOptionData option) { final updatedOptions = options.upsert( option, @@ -160,12 +185,23 @@ extension PollDataMutations on PollData { return copyWith(options: updatedOptions); } + /// Removes an option from this poll. + /// + /// Updates the options list by removing the option with [optionId]. + /// + /// Returns a new [PollData] instance with the updated options. PollData removeOption(String optionId) { final updatedOptions = options.where((it) => it.id != optionId).toList(); return copyWith(options: updatedOptions); } + /// Updates an existing option in this poll. + /// + /// Updates the options list by replacing the option with the same ID as [option] + /// with the new [option] data. + /// + /// Returns a new [PollData] instance with the updated options. PollData updateOption(PollOptionData option) { final updatedOptions = options.map((it) { if (it.id != option.id) return it; @@ -175,6 +211,12 @@ extension PollDataMutations on PollData { return copyWith(options: updatedOptions); } + /// Casts an answer to this poll. + /// + /// Updates the latest answers and own votes/answers lists by adding or updating [answer]. + /// Only adds answers that belong to [currentUserId] to the own votes/answers list. + /// + /// Returns a new [PollData] instance with the updated answers. PollData castAnswer(PollVoteData answer, String currentUserId) { final updatedLatestAnswers = latestAnswers.let((it) { return it.upsert(answer, key: (it) => it.id == answer.id); diff --git a/packages/stream_feeds/lib/src/models/threaded_comment_data.dart b/packages/stream_feeds/lib/src/models/threaded_comment_data.dart deleted file mode 100644 index 68224d30..00000000 --- a/packages/stream_feeds/lib/src/models/threaded_comment_data.dart +++ /dev/null @@ -1,425 +0,0 @@ -// ignore_for_file: avoid_redundant_argument_values - -import 'dart:math' as math; - -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:stream_core/stream_core.dart'; - -import '../generated/api/models.dart'; -import '../state/query/comments_query.dart'; -import 'comment_data.dart'; -import 'feeds_reaction_data.dart'; -import 'moderation.dart'; -import 'reaction_group_data.dart'; -import 'user_data.dart'; - -part 'threaded_comment_data.freezed.dart'; - -/// A threaded comment in the Stream Feeds system. -/// -/// Contains comment content, metadata, reactions, nested replies, and user -/// information for threaded discussion functionality. -/// It supports hierarchical comment structures with pagination metadata. -@freezed -class ThreadedCommentData - with _$ThreadedCommentData - implements CommentsSortDataFields { - /// Creates a new [ThreadedCommentData] instance. - const ThreadedCommentData({ - required this.confidenceScore, - required this.createdAt, - required this.downvoteCount, - required this.id, - required this.latestReactions, - required this.mentionedUsers, - required this.objectId, - required this.objectType, - required this.ownReactions, - required this.reactionCount, - required this.reactionGroups, - required this.replyCount, - required this.score, - required this.status, - required this.updatedAt, - required this.upvoteCount, - required this.user, - this.attachments, - this.controversyScore, - this.deletedAt, - this.meta, - this.moderation, - this.parentId, - this.replies, - this.text, - this.custom, - }); - - /// Creates a new instance of [ThreadedCommentData] from a [CommentData] object. - /// - /// This constructor converts a regular comment to a threaded comment, - /// preserving all the comment data while adding support for threaded replies. - factory ThreadedCommentData.fromComment(CommentData comment) { - return ThreadedCommentData( - attachments: comment.attachments, - confidenceScore: comment.confidenceScore, - controversyScore: comment.controversyScore, - createdAt: comment.createdAt, - custom: comment.custom, - deletedAt: comment.deletedAt, - downvoteCount: comment.downvoteCount, - id: comment.id, - latestReactions: comment.latestReactions, - mentionedUsers: comment.mentionedUsers, - meta: null, // Comments don't have meta loaded by default - moderation: comment.moderation, - objectId: comment.objectId, - objectType: comment.objectType, - ownReactions: comment.ownReactions, - parentId: comment.parentId, - reactionCount: comment.reactionCount, - reactionGroups: comment.reactionGroups, - replies: null, // Comments don't have replies loaded by default - replyCount: comment.replyCount, - score: comment.score, - status: comment.status, - text: comment.text, - updatedAt: comment.updatedAt, - upvoteCount: comment.upvoteCount, - user: comment.user, - ); - } - - /// File attachments associated with the comment. - @override - final List? attachments; - - /// A confidence score indicating the quality or relevance of the comment. - @override - final double confidenceScore; - - /// A controversy score indicating the potential controversy level of the comment. - @override - final double? controversyScore; - - /// The date and time when the comment was created. - @override - final DateTime createdAt; - - /// The date and time when the comment was deleted, if applicable. - @override - final DateTime? deletedAt; - - /// The number of downvotes received by the comment. - @override - final int downvoteCount; - - /// The unique identifier of the comment. - @override - final String id; - - /// The most recent reactions added to the comment. - @override - final List latestReactions; - - /// Users mentioned in the comment. - @override - final List mentionedUsers; - - /// Metadata about the comment's replies structure. - @override - final RepliesMeta? meta; - - /// Moderation state for the comment. - @override - final Moderation? moderation; - - /// The ID of the object this comment belongs to. - @override - final String objectId; - - /// The type of object this comment belongs to. - @override - final String objectType; - - /// All the reactions from the current user. - @override - final List ownReactions; - - /// The ID of the parent comment, if this is a reply. - @override - final String? parentId; - - /// The total number of reactions on the comment. - @override - final int reactionCount; - - /// Groups of reactions by type. - @override - final Map reactionGroups; - - /// The replies to this comment, if any. - @override - final List? replies; - - /// The number of replies to this comment. - @override - final int replyCount; - - /// A score assigned to the comment. - @override - final int score; - - /// The current status of the comment. - @override - final String status; - - /// The text content of the comment. - @override - final String? text; - - /// The date and time when the comment was last updated. - @override - final DateTime updatedAt; - - /// The number of upvotes received by the comment. - @override - final int upvoteCount; - - /// The user who created the comment. - @override - final UserData user; - - /// Custom data associated with the comment. - @override - final Map? custom; -} - -/// Extension functions for [ThreadedCommentData] to handle common operations. -extension ThreadedCommentDataMutations on ThreadedCommentData { - /// Adds a reaction to the comment, updating the latest reactions, reaction groups, reaction count, - /// and own reactions if applicable. - /// - /// @param reaction The reaction to add. - /// @param currentUserId The ID of the current user, used to update own reactions. - /// @return A new [ThreadedCommentData] instance with the updated reaction data. - ThreadedCommentData addReaction( - FeedsReactionData reaction, - String currentUserId, - ) { - final updatedOwnReactions = switch (reaction.user.id == currentUserId) { - true => ownReactions.upsert(reaction, key: (it) => it.id), - false => ownReactions, - }; - - final updatedLatestReactions = latestReactions.upsert( - reaction, - key: (reaction) => reaction.id, - ); - - final reactionGroup = switch (reactionGroups[reaction.type]) { - final existingGroup? => existingGroup, - _ => ReactionGroupData( - count: 1, - firstReactionAt: reaction.createdAt, - lastReactionAt: reaction.createdAt, - ), - }; - - final updatedReactionGroups = { - ...reactionGroups, - reaction.type: reactionGroup.increment(reaction.createdAt), - }; - - final updatedReactionCount = updatedReactionGroups.values.sumOf( - (group) => group.count, - ); - - return copyWith( - ownReactions: updatedOwnReactions, - latestReactions: updatedLatestReactions, - reactionGroups: updatedReactionGroups, - reactionCount: updatedReactionCount, - ); - } - - /// Removes a reaction from the comment, updating the latest reactions, reaction groups, reaction - /// count, and own reactions if applicable. - /// - /// @param reaction The reaction to remove. - /// @param currentUserId The ID of the current user, used to update own reactions. - /// @return A new [ThreadedCommentData] instance with the updated reaction data. - ThreadedCommentData removeReaction( - FeedsReactionData reaction, - String currentUserId, - ) { - final updatedOwnReactions = switch (reaction.user.id == currentUserId) { - true => ownReactions.where((it) => it.id != reaction.id).toList(), - false => ownReactions, - }; - - final updatedLatestReactions = latestReactions.where((it) { - return it.id != reaction.id; - }).toList(growable: false); - - final updatedReactionGroups = {...reactionGroups}; - final reactionGroup = updatedReactionGroups.remove(reaction.type); - - if (reactionGroup == null) { - // If there is no reaction group for this type, just update latest and own reactions. - // Note: This is only a hypothetical case, as we should always have a reaction group. - return copyWith( - latestReactions: updatedLatestReactions, - ownReactions: updatedOwnReactions, - ); - } - - final updatedReactionGroup = reactionGroup.decrement(reaction.createdAt); - if (updatedReactionGroup.count > 0) { - updatedReactionGroups[reaction.type] = updatedReactionGroup; - } - - final updatedReactionCount = updatedReactionGroups.values.sumOf( - (group) => group.count, - ); - - return copyWith( - ownReactions: updatedOwnReactions, - latestReactions: updatedLatestReactions, - reactionGroups: updatedReactionGroups, - reactionCount: updatedReactionCount, - ); - } - - /// Adds a reply to the comment, updating the replies list and reply count. - /// - /// @param comment The reply comment to add. - /// @return A new [ThreadedCommentData] instance with the updated replies and reply count. - ThreadedCommentData addReply( - ThreadedCommentData reply, - Comparator comparator, - ) { - final currentReplies = replies ?? []; - final updatedReplies = currentReplies.sortedUpsert( - reply, - key: (it) => it.id, - compare: comparator, - ); - - return copyWith( - replies: updatedReplies, - replyCount: math.max(0, replyCount + 1), - ); - } - - /// Removes a reply from the comment, updating the replies list and reply count. - /// - /// @param comment The reply comment to remove. - /// @return A new [ThreadedCommentData] instance with the updated replies and reply count. - ThreadedCommentData removeReply(ThreadedCommentData reply) { - final currentReplies = replies ?? []; - final updatedReplies = currentReplies.where((it) { - return it.id != reply.id; - }).toList(); - - return copyWith( - replies: updatedReplies, - replyCount: math.max(0, replyCount - 1), - ); - } - - /// Replaces an existing reply in the comment with a new one, updating the replies list. - /// - /// @param comment The new reply comment to replace the existing one. - /// @return A new [ThreadedCommentData] instance with the updated replies. - ThreadedCommentData replaceReply( - ThreadedCommentData reply, - Comparator comparator, - ) { - final currentReplies = replies ?? []; - final updatedReplies = currentReplies.sortedUpsert( - reply, - key: (it) => it.id, - compare: comparator, - ); - - return copyWith(replies: updatedReplies); - } - - /// Sets the comment data for this threaded comment, replacing its properties with those from the - /// provided [CommentData]. The [ThreadedCommentData.meta] and [ThreadedCommentData.replies] - /// properties are preserved from the original instance. - /// - /// @param comment The [CommentData] to set for this threaded comment. - /// @return A new [ThreadedCommentData] instance with the updated comment data. - ThreadedCommentData setCommentData(CommentData comment) { - return copyWith( - attachments: comment.attachments, - confidenceScore: comment.confidenceScore, - controversyScore: comment.controversyScore, - createdAt: comment.createdAt, - custom: comment.custom, - deletedAt: comment.deletedAt, - downvoteCount: comment.downvoteCount, - id: comment.id, - latestReactions: comment.latestReactions, - mentionedUsers: comment.mentionedUsers, - meta: meta, // Keep existing meta - moderation: comment.moderation, - objectId: comment.objectId, - objectType: comment.objectType, - ownReactions: comment.ownReactions, - parentId: comment.parentId, - reactionCount: comment.reactionCount, - reactionGroups: comment.reactionGroups, - replies: replies, // Keep existing replies - replyCount: comment.replyCount, - score: comment.score, - status: comment.status, - text: comment.text, - updatedAt: comment.updatedAt, - upvoteCount: comment.upvoteCount, - user: comment.user, - ); - } -} - -/// Extension function to convert a [ThreadedCommentResponse] to a [ThreadedCommentData] model. -extension ThreadedCommentResponseMapper on ThreadedCommentResponse { - /// Converts this API comment response to a domain [ThreadedCommentData] instance. - /// - /// This creates a threaded comment from a regular comment response, - /// suitable for use in threaded comment displays. - ThreadedCommentData toModel() { - return ThreadedCommentData( - attachments: attachments, - confidenceScore: confidenceScore, - controversyScore: controversyScore, - createdAt: createdAt, - custom: custom, - deletedAt: deletedAt, - downvoteCount: downvoteCount, - id: id, - latestReactions: [...?latestReactions?.map((e) => e.toModel())], - mentionedUsers: [...mentionedUsers.map((e) => e.toModel())], - meta: meta, - moderation: moderation?.toModel(), - objectId: objectId, - objectType: objectType, - ownReactions: [...ownReactions.map((e) => e.toModel())], - parentId: parentId, - reactionCount: reactionCount, - reactionGroups: { - for (final entry in {...?reactionGroups?.entries}) - entry.key: entry.value.toModel(), - }, - replies: [...?replies?.map((e) => e.toModel())], - replyCount: replyCount, - score: score, - status: status, - text: text, - updatedAt: updatedAt, - upvoteCount: upvoteCount, - user: user.toModel(), - ); - } -} diff --git a/packages/stream_feeds/lib/src/models/threaded_comment_data.freezed.dart b/packages/stream_feeds/lib/src/models/threaded_comment_data.freezed.dart deleted file mode 100644 index 7dee54e3..00000000 --- a/packages/stream_feeds/lib/src/models/threaded_comment_data.freezed.dart +++ /dev/null @@ -1,325 +0,0 @@ -// dart format width=80 -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// 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 'threaded_comment_data.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; - -/// @nodoc -mixin _$ThreadedCommentData { - List? get attachments; - double get confidenceScore; - double? get controversyScore; - DateTime get createdAt; - DateTime? get deletedAt; - int get downvoteCount; - String get id; - List get latestReactions; - List get mentionedUsers; - RepliesMeta? get meta; - Moderation? get moderation; - String get objectId; - String get objectType; - List get ownReactions; - String? get parentId; - int get reactionCount; - Map get reactionGroups; - List? get replies; - int get replyCount; - int get score; - String get status; - String? get text; - DateTime get updatedAt; - int get upvoteCount; - UserData get user; - Map? get custom; - - /// Create a copy of ThreadedCommentData - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @pragma('vm:prefer-inline') - $ThreadedCommentDataCopyWith get copyWith => - _$ThreadedCommentDataCopyWithImpl( - this as ThreadedCommentData, _$identity); - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is ThreadedCommentData && - const DeepCollectionEquality() - .equals(other.attachments, attachments) && - (identical(other.confidenceScore, confidenceScore) || - other.confidenceScore == confidenceScore) && - (identical(other.controversyScore, controversyScore) || - other.controversyScore == controversyScore) && - (identical(other.createdAt, createdAt) || - other.createdAt == createdAt) && - (identical(other.deletedAt, deletedAt) || - other.deletedAt == deletedAt) && - (identical(other.downvoteCount, downvoteCount) || - other.downvoteCount == downvoteCount) && - (identical(other.id, id) || other.id == id) && - const DeepCollectionEquality() - .equals(other.latestReactions, latestReactions) && - const DeepCollectionEquality() - .equals(other.mentionedUsers, mentionedUsers) && - (identical(other.meta, meta) || other.meta == meta) && - (identical(other.moderation, moderation) || - other.moderation == moderation) && - (identical(other.objectId, objectId) || - other.objectId == objectId) && - (identical(other.objectType, objectType) || - other.objectType == objectType) && - const DeepCollectionEquality() - .equals(other.ownReactions, ownReactions) && - (identical(other.parentId, parentId) || - other.parentId == parentId) && - (identical(other.reactionCount, reactionCount) || - other.reactionCount == reactionCount) && - const DeepCollectionEquality() - .equals(other.reactionGroups, reactionGroups) && - const DeepCollectionEquality().equals(other.replies, replies) && - (identical(other.replyCount, replyCount) || - other.replyCount == replyCount) && - (identical(other.score, score) || other.score == score) && - (identical(other.status, status) || other.status == status) && - (identical(other.text, text) || other.text == text) && - (identical(other.updatedAt, updatedAt) || - other.updatedAt == updatedAt) && - (identical(other.upvoteCount, upvoteCount) || - other.upvoteCount == upvoteCount) && - (identical(other.user, user) || other.user == user) && - const DeepCollectionEquality().equals(other.custom, custom)); - } - - @override - int get hashCode => Object.hashAll([ - runtimeType, - const DeepCollectionEquality().hash(attachments), - confidenceScore, - controversyScore, - createdAt, - deletedAt, - downvoteCount, - id, - const DeepCollectionEquality().hash(latestReactions), - const DeepCollectionEquality().hash(mentionedUsers), - meta, - moderation, - objectId, - objectType, - const DeepCollectionEquality().hash(ownReactions), - parentId, - reactionCount, - const DeepCollectionEquality().hash(reactionGroups), - const DeepCollectionEquality().hash(replies), - replyCount, - score, - status, - text, - updatedAt, - upvoteCount, - user, - const DeepCollectionEquality().hash(custom) - ]); - - @override - String toString() { - return 'ThreadedCommentData(attachments: $attachments, confidenceScore: $confidenceScore, controversyScore: $controversyScore, createdAt: $createdAt, deletedAt: $deletedAt, downvoteCount: $downvoteCount, id: $id, latestReactions: $latestReactions, mentionedUsers: $mentionedUsers, meta: $meta, moderation: $moderation, objectId: $objectId, objectType: $objectType, ownReactions: $ownReactions, parentId: $parentId, reactionCount: $reactionCount, reactionGroups: $reactionGroups, replies: $replies, replyCount: $replyCount, score: $score, status: $status, text: $text, updatedAt: $updatedAt, upvoteCount: $upvoteCount, user: $user, custom: $custom)'; - } -} - -/// @nodoc -abstract mixin class $ThreadedCommentDataCopyWith<$Res> { - factory $ThreadedCommentDataCopyWith( - ThreadedCommentData value, $Res Function(ThreadedCommentData) _then) = - _$ThreadedCommentDataCopyWithImpl; - @useResult - $Res call( - {double confidenceScore, - DateTime createdAt, - int downvoteCount, - String id, - List latestReactions, - List mentionedUsers, - String objectId, - String objectType, - List ownReactions, - int reactionCount, - Map reactionGroups, - int replyCount, - int score, - String status, - DateTime updatedAt, - int upvoteCount, - UserData user, - List? attachments, - double? controversyScore, - DateTime? deletedAt, - RepliesMeta? meta, - Moderation? moderation, - String? parentId, - List? replies, - String? text, - Map? custom}); -} - -/// @nodoc -class _$ThreadedCommentDataCopyWithImpl<$Res> - implements $ThreadedCommentDataCopyWith<$Res> { - _$ThreadedCommentDataCopyWithImpl(this._self, this._then); - - final ThreadedCommentData _self; - final $Res Function(ThreadedCommentData) _then; - - /// Create a copy of ThreadedCommentData - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? confidenceScore = null, - Object? createdAt = null, - Object? downvoteCount = null, - Object? id = null, - Object? latestReactions = null, - Object? mentionedUsers = null, - Object? objectId = null, - Object? objectType = null, - Object? ownReactions = null, - Object? reactionCount = null, - Object? reactionGroups = null, - Object? replyCount = null, - Object? score = null, - Object? status = null, - Object? updatedAt = null, - Object? upvoteCount = null, - Object? user = null, - Object? attachments = freezed, - Object? controversyScore = freezed, - Object? deletedAt = freezed, - Object? meta = freezed, - Object? moderation = freezed, - Object? parentId = freezed, - Object? replies = freezed, - Object? text = freezed, - Object? custom = freezed, - }) { - return _then(ThreadedCommentData( - confidenceScore: null == confidenceScore - ? _self.confidenceScore - : confidenceScore // ignore: cast_nullable_to_non_nullable - as double, - createdAt: null == createdAt - ? _self.createdAt - : createdAt // ignore: cast_nullable_to_non_nullable - as DateTime, - downvoteCount: null == downvoteCount - ? _self.downvoteCount - : downvoteCount // ignore: cast_nullable_to_non_nullable - as int, - id: null == id - ? _self.id - : id // ignore: cast_nullable_to_non_nullable - as String, - latestReactions: null == latestReactions - ? _self.latestReactions - : latestReactions // ignore: cast_nullable_to_non_nullable - as List, - mentionedUsers: null == mentionedUsers - ? _self.mentionedUsers - : mentionedUsers // ignore: cast_nullable_to_non_nullable - as List, - objectId: null == objectId - ? _self.objectId - : objectId // ignore: cast_nullable_to_non_nullable - as String, - objectType: null == objectType - ? _self.objectType - : objectType // ignore: cast_nullable_to_non_nullable - as String, - ownReactions: null == ownReactions - ? _self.ownReactions - : ownReactions // ignore: cast_nullable_to_non_nullable - as List, - reactionCount: null == reactionCount - ? _self.reactionCount - : reactionCount // ignore: cast_nullable_to_non_nullable - as int, - reactionGroups: null == reactionGroups - ? _self.reactionGroups - : reactionGroups // ignore: cast_nullable_to_non_nullable - as Map, - replyCount: null == replyCount - ? _self.replyCount - : replyCount // ignore: cast_nullable_to_non_nullable - as int, - score: null == score - ? _self.score - : score // ignore: cast_nullable_to_non_nullable - as int, - status: null == status - ? _self.status - : status // ignore: cast_nullable_to_non_nullable - as String, - updatedAt: null == updatedAt - ? _self.updatedAt - : updatedAt // ignore: cast_nullable_to_non_nullable - as DateTime, - upvoteCount: null == upvoteCount - ? _self.upvoteCount - : upvoteCount // ignore: cast_nullable_to_non_nullable - as int, - user: null == user - ? _self.user - : user // ignore: cast_nullable_to_non_nullable - as UserData, - attachments: freezed == attachments - ? _self.attachments - : attachments // ignore: cast_nullable_to_non_nullable - as List?, - controversyScore: freezed == controversyScore - ? _self.controversyScore - : controversyScore // ignore: cast_nullable_to_non_nullable - as double?, - deletedAt: freezed == deletedAt - ? _self.deletedAt - : deletedAt // ignore: cast_nullable_to_non_nullable - as DateTime?, - meta: freezed == meta - ? _self.meta - : meta // ignore: cast_nullable_to_non_nullable - as RepliesMeta?, - moderation: freezed == moderation - ? _self.moderation - : moderation // ignore: cast_nullable_to_non_nullable - as Moderation?, - parentId: freezed == parentId - ? _self.parentId - : parentId // ignore: cast_nullable_to_non_nullable - as String?, - replies: freezed == replies - ? _self.replies - : replies // ignore: cast_nullable_to_non_nullable - as List?, - text: freezed == text - ? _self.text - : text // ignore: cast_nullable_to_non_nullable - as String?, - custom: freezed == custom - ? _self.custom - : custom // ignore: cast_nullable_to_non_nullable - as Map?, - )); - } -} - -// dart format on diff --git a/packages/stream_feeds/lib/src/repository/comments_repository.dart b/packages/stream_feeds/lib/src/repository/comments_repository.dart index 5ce2c260..035bface 100644 --- a/packages/stream_feeds/lib/src/repository/comments_repository.dart +++ b/packages/stream_feeds/lib/src/repository/comments_repository.dart @@ -5,7 +5,6 @@ import '../models/comment_data.dart'; import '../models/feeds_reaction_data.dart'; import '../models/pagination_data.dart'; import '../models/request/activity_add_comment_request.dart'; -import '../models/threaded_comment_data.dart'; import '../state/query/activity_comments_query.dart'; import '../state/query/comment_reactions_query.dart'; import '../state/query/comment_replies_query.dart'; @@ -56,8 +55,8 @@ class CommentsRepository { /// /// Fetches threaded comments for an activity using the specified [query] parameters. /// - /// Returns a [Result] containing a [PaginationResult] of [ThreadedCommentData] or an error. - Future>> getComments( + /// Returns a [Result] containing a [PaginationResult] of [CommentData] or an error. + Future>> getComments( ActivityCommentsQuery query, ) async { final result = await _api.getComments( @@ -85,7 +84,7 @@ class CommentsRepository { /// /// Creates a new comment using the provided [request] data. /// - /// Returns a [Result] containing the newly created [CommentData] or an error. + /// Returns a [Result] containing the created [CommentData] or an error. Future> addComment( ActivityAddCommentRequest request, ) async { @@ -100,7 +99,7 @@ class CommentsRepository { }); } - /// Adds multiple comments. + /// Adds multiple comments in a single batch operation. /// /// Creates multiple comments in a single batch operation using the provided [requests] data. /// @@ -131,11 +130,16 @@ class CommentsRepository { /// If [hardDelete] is true, the comment is permanently deleted; otherwise, it may be soft-deleted. /// /// Returns a [Result] containing void or an error. - Future> deleteComment( + Future> deleteComment( String commentId, { bool? hardDelete, - }) { - return _api.deleteComment(id: commentId, hardDelete: hardDelete); + }) async { + final result = await _api.deleteComment( + id: commentId, + hardDelete: hardDelete, + ); + + return result.map((response) => response.comment.toModel()); } /// Retrieves a specific comment. @@ -170,8 +174,9 @@ class CommentsRepository { /// /// Creates a new reaction on the comment with [commentId] using the provided [request] data. /// - /// Returns a [Result] containing the [FeedsReactionData] or an error. - Future> + /// Returns a [Result] containing a record with the updated [CommentData] and created + /// [FeedsReactionData] or an error. + Future> addCommentReaction( String commentId, api.AddCommentReactionRequest request, @@ -183,8 +188,8 @@ class CommentsRepository { return result.map( (response) => ( + comment: response.comment.toModel(), reaction: response.reaction.toModel(), - commentId: response.comment.id, ), ); } @@ -193,8 +198,9 @@ class CommentsRepository { /// /// Removes the reaction of [type] from the comment with [commentId]. /// - /// Returns a [Result] containing the deleted [FeedsReactionData] or an error. - Future> + /// Returns a [Result] containing a record with the updated [CommentData] and deleted + /// [FeedsReactionData] or an error. + Future> deleteCommentReaction( String commentId, String type, @@ -203,8 +209,8 @@ class CommentsRepository { return result.map( (response) => ( + comment: response.comment.toModel(), reaction: response.reaction.toModel(), - commentId: response.comment.id, ), ); } @@ -239,8 +245,8 @@ class CommentsRepository { /// /// Fetches threaded replies for a comment using the specified [query] parameters. /// - /// Returns a [Result] containing a [PaginationResult] of [ThreadedCommentData] or an error. - Future>> getCommentReplies( + /// Returns a [Result] containing a [PaginationResult] of [CommentData] or an error. + Future>> getCommentReplies( CommentRepliesQuery query, ) async { final result = await _api.getCommentReplies( diff --git a/packages/stream_feeds/lib/src/state/activity.dart b/packages/stream_feeds/lib/src/state/activity.dart index 18cdbfc7..6aaf2983 100644 --- a/packages/stream_feeds/lib/src/state/activity.dart +++ b/packages/stream_feeds/lib/src/state/activity.dart @@ -14,7 +14,6 @@ import '../models/poll_option_data.dart'; import '../models/poll_vote_data.dart'; import '../models/request/activity_add_comment_request.dart'; import '../models/request/activity_update_comment_request.dart'; -import '../models/threaded_comment_data.dart'; import '../repository/activities_repository.dart'; import '../repository/capabilities_repository.dart'; import '../repository/comments_repository.dart'; @@ -139,8 +138,8 @@ class Activity with Disposable { /// Queries the comments for this activity. /// - /// Returns a [Result] containing a list of [ThreadedCommentData] or an error. - Future>> queryComments() { + /// Returns a [Result] containing a list of [CommentData] or an error. + Future>> queryComments() { return _commentsList.get(); } @@ -148,7 +147,7 @@ class Activity with Disposable { /// /// Optionally accepts a [limit] parameter to specify the maximum number of /// comments to return. - Future>> queryMoreComments({int? limit}) { + Future>> queryMoreComments({int? limit}) { return _commentsList.queryMoreComments(limit: limit); } @@ -172,9 +171,7 @@ class Activity with Disposable { final result = await commentsRepository.addComment(request); result.onSuccess( - (comment) => _commentsList.notifier.onCommentAdded( - ThreadedCommentData.fromComment(comment), - ), + (comment) => _commentsList.notifier.onCommentAdded(comment), ); return result; @@ -188,10 +185,9 @@ class Activity with Disposable { ) async { final result = await commentsRepository.addCommentsBatch(requests); - result.onSuccess((comments) { - final threadedComments = comments.map(ThreadedCommentData.fromComment); - threadedComments.forEach(_commentsList.notifier.onCommentAdded); - }); + result.onSuccess( + (comments) => comments.forEach(_commentsList.notifier.onCommentAdded), + ); return result; } @@ -210,7 +206,7 @@ class Activity with Disposable { hardDelete: hardDelete, ); - result.onSuccess((_) => _commentsList.notifier.onCommentRemoved(commentId)); + result.onSuccess(_commentsList.notifier.onCommentRemoved); return result; } @@ -222,8 +218,10 @@ class Activity with Disposable { String commentId, ActivityUpdateCommentRequest request, ) async { - final result = - await commentsRepository.updateComment(commentId, request.toRequest()); + final result = await commentsRepository.updateComment( + commentId, + request.toRequest(), + ); result.onSuccess(_commentsList.notifier.onCommentUpdated); @@ -243,10 +241,19 @@ class Activity with Disposable { ); result.onSuccess( - (pair) => _commentsList.notifier.onCommentReactionAdded( - pair.commentId, - pair.reaction, - ), + (pair) { + if (request.enforceUnique ?? false) { + return _commentsList.notifier.onCommentReactionUpdated( + pair.comment, + pair.reaction, + ); + } + + return _commentsList.notifier.onCommentReactionAdded( + pair.comment, + pair.reaction, + ); + }, ); return result.map((pair) => pair.reaction); @@ -266,7 +273,7 @@ class Activity with Disposable { result.onSuccess( (pair) => _commentsList.notifier.onCommentReactionRemoved( - pair.commentId, + pair.comment, pair.reaction, ), ); diff --git a/packages/stream_feeds/lib/src/state/activity_comment_list.dart b/packages/stream_feeds/lib/src/state/activity_comment_list.dart index ae8bd890..bf7bfa41 100644 --- a/packages/stream_feeds/lib/src/state/activity_comment_list.dart +++ b/packages/stream_feeds/lib/src/state/activity_comment_list.dart @@ -4,7 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:state_notifier/state_notifier.dart'; import 'package:stream_core/stream_core.dart'; -import '../models/threaded_comment_data.dart'; +import '../models/comment_data.dart'; import '../repository/comments_repository.dart'; import 'activity_comment_list_state.dart'; import 'event/activity_comment_list_event_handler.dart'; @@ -58,8 +58,8 @@ class ActivityCommentList extends Disposable { /// Queries the initial list of activity comments based on the provided [ActivityCommentsQuery]. /// - /// Returns a [Result] containing a list of [ThreadedCommentData] or an error. - Future>> get() => _queryComments(query); + /// Returns a [Result] containing a list of [CommentData] or an error. + Future>> get() => _queryComments(query); /// Loads more activity comments based on the current pagination state. /// @@ -67,7 +67,7 @@ class ActivityCommentList extends Disposable { /// /// Optionally accepts a [limit] parameter to specify the maximum number of /// comments to return. - Future>> queryMoreComments({ + Future>> queryMoreComments({ int? limit, }) async { // Build the query with the current pagination state (with next page token) @@ -87,7 +87,7 @@ class ActivityCommentList extends Disposable { } // Internal method to query comments and update state. - Future>> _queryComments( + Future>> _queryComments( ActivityCommentsQuery query, ) async { final result = await commentsRepository.getComments(query); diff --git a/packages/stream_feeds/lib/src/state/activity_comment_list_state.dart b/packages/stream_feeds/lib/src/state/activity_comment_list_state.dart index 4e4a64ce..12a2545a 100644 --- a/packages/stream_feeds/lib/src/state/activity_comment_list_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_comment_list_state.dart @@ -5,7 +5,6 @@ import 'package:stream_core/stream_core.dart'; import '../models/comment_data.dart'; import '../models/feeds_reaction_data.dart'; import '../models/pagination_data.dart'; -import '../models/threaded_comment_data.dart'; import 'query/comments_query.dart'; part 'activity_comment_list_state.freezed.dart'; @@ -28,7 +27,7 @@ class ActivityCommentListStateNotifier /// Handles the result of a query for more comments. void onQueryMoreComments( - PaginationResult result, { + PaginationResult result, { CommentsSort? sort, }) { _commentSort = sort; @@ -47,25 +46,28 @@ class ActivityCommentListStateNotifier } /// Handles the addition of a new comment. - void onCommentAdded(ThreadedCommentData comment) { - // If the comment is a reply, find the parent and add it to the parent's replies - if (comment.parentId case final parentId?) { - final updatedComments = state.comments.updateNested( - (comment) => comment.id == parentId, - children: (it) => it.replies ?? [], - update: (found) => found.addReply(comment, commentSort.compare), - updateChildren: (parent, replies) => parent.copyWith(replies: replies), + void onCommentAdded(CommentData comment) { + final parentId = comment.parentId; + + // If there's no parentId, it's a top-level comment + if (parentId == null) { + final updatedComments = state.comments.sortedUpsert( + comment, + key: (comment) => comment.id, compare: commentSort.compare, + update: (existing, updated) => existing.updateWith(updated), ); state = state.copyWith(comments: updatedComments); return; } - // Otherwise, just update the top-level comments list - final updatedComments = state.comments.sortedUpsert( - comment, - key: (comment) => comment.id, + // Otherwise, it's a reply to an existing comment + final updatedComments = state.comments.updateNested( + (it) => it.id == parentId, + children: (it) => it.replies ?? [], + update: (found) => found.upsertReply(comment, commentSort.compare), + updateChildren: (parent, replies) => parent.copyWith(replies: replies), compare: commentSort.compare, ); @@ -74,10 +76,11 @@ class ActivityCommentListStateNotifier /// Handles updates to a specific comment. void onCommentUpdated(CommentData comment) { + // Update nested replies (handles both top-level and nested replies) final updatedComments = state.comments.updateNested( (it) => it.id == comment.id, children: (it) => it.replies ?? [], - update: (found) => found.setCommentData(comment), + update: (found) => found.updateWith(comment), updateChildren: (parent, replies) => parent.copyWith(replies: replies), ); @@ -85,10 +88,24 @@ class ActivityCommentListStateNotifier } /// Handles the removal of a comment by ID. - void onCommentRemoved(String commentId, {bool hardDelete = false}) { - final updatedComments = state.comments.removeNested( - (it) => it.id == commentId, + void onCommentRemoved(CommentData comment) { + final removeIndex = state.comments.indexWhere((it) => it.id == comment.id); + + // If found at the top level, remove it directly + if (removeIndex >= 0) { + final updatedComments = [...state.comments].apply( + (it) => it.removeAt(removeIndex), + ); + + state = state.copyWith(comments: updatedComments); + return; + } + + // Otherwise, it might be a direct reply to a top-level comment + final updatedComments = state.comments.updateNested( + (it) => it.id == comment.parentId, children: (it) => it.replies ?? [], + update: (found) => found.removeReply(comment), updateChildren: (parent, replies) => parent.copyWith(replies: replies), ); @@ -96,11 +113,31 @@ class ActivityCommentListStateNotifier } /// Handles the addition of a reaction to a comment. - void onCommentReactionAdded(String commentId, FeedsReactionData reaction) { + void onCommentReactionAdded( + CommentData comment, + FeedsReactionData reaction, + ) { final updatedComments = state.comments.updateNested( - (comment) => comment.id == commentId, + (it) => it.id == comment.id, children: (it) => it.replies ?? [], - update: (found) => found.addReaction(reaction, currentUserId), + update: (found) => found.upsertReaction(comment, reaction, currentUserId), + updateChildren: (parent, replies) => parent.copyWith(replies: replies), + compare: commentSort.compare, + ); + + state = state.copyWith(comments: updatedComments); + } + + /// Handles the update of a reaction on a comment. + void onCommentReactionUpdated( + CommentData comment, + FeedsReactionData reaction, + ) { + final updatedComments = state.comments.updateNested( + (it) => it.id == comment.id, + children: (it) => it.replies ?? [], + update: (found) => + found.upsertUniqueReaction(comment, reaction, currentUserId), updateChildren: (parent, replies) => parent.copyWith(replies: replies), compare: commentSort.compare, ); @@ -109,11 +146,14 @@ class ActivityCommentListStateNotifier } /// Handles the removal of a reaction from a comment. - void onCommentReactionRemoved(String commentId, FeedsReactionData reaction) { + void onCommentReactionRemoved( + CommentData comment, + FeedsReactionData reaction, + ) { final updatedComments = state.comments.updateNested( - (comment) => comment.id == commentId, + (it) => it.id == comment.id, children: (it) => it.replies ?? [], - update: (found) => found.removeReaction(reaction, currentUserId), + update: (found) => found.removeReaction(comment, reaction, currentUserId), updateChildren: (parent, replies) => parent.copyWith(replies: replies), compare: commentSort.compare, ); @@ -139,7 +179,7 @@ class ActivityCommentListState with _$ActivityCommentListState { /// pagination requests. The comments are automatically sorted according to /// the current sorting configuration. @override - final List comments; + final List comments; /// Last pagination information from the most recent request. /// diff --git a/packages/stream_feeds/lib/src/state/activity_comment_list_state.freezed.dart b/packages/stream_feeds/lib/src/state/activity_comment_list_state.freezed.dart index 48d8edf6..8b4c3a53 100644 --- a/packages/stream_feeds/lib/src/state/activity_comment_list_state.freezed.dart +++ b/packages/stream_feeds/lib/src/state/activity_comment_list_state.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$ActivityCommentListState { - List get comments; + List get comments; PaginationData? get pagination; /// Create a copy of ActivityCommentListState @@ -52,7 +52,7 @@ abstract mixin class $ActivityCommentListStateCopyWith<$Res> { $Res Function(ActivityCommentListState) _then) = _$ActivityCommentListStateCopyWithImpl; @useResult - $Res call({List comments, PaginationData? pagination}); + $Res call({List comments, PaginationData? pagination}); } /// @nodoc @@ -75,7 +75,7 @@ class _$ActivityCommentListStateCopyWithImpl<$Res> comments: null == comments ? _self.comments : comments // ignore: cast_nullable_to_non_nullable - as List, + as List, pagination: freezed == pagination ? _self.pagination : pagination // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_feeds/lib/src/state/activity_list_state.dart b/packages/stream_feeds/lib/src/state/activity_list_state.dart index de56f343..68cce98c 100644 --- a/packages/stream_feeds/lib/src/state/activity_list_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_list_state.dart @@ -88,7 +88,7 @@ class ActivityListStateNotifier extends StateNotifier { final updatedActivities = state.activities.map((activity) { if (activity.id != bookmark.activity.id) return activity; // Add the bookmark to the activity - return activity.addBookmark(bookmark, currentUserId); + return activity.upsertBookmark(bookmark, currentUserId); }).toList(); state = state.copyWith(activities: updatedActivities); @@ -107,10 +107,15 @@ class ActivityListStateNotifier extends StateNotifier { /// Handles the addition of a comment. void onCommentAdded(CommentData comment) { + return onCommentUpdated(comment); + } + + /// Handles the update of a comment. + void onCommentUpdated(CommentData comment) { final updatedActivities = state.activities.map((activity) { if (activity.id != comment.objectId) return activity; // Add the comment to the activity - return activity.addComment(comment); + return activity.upsertComment(comment); }).toList(); state = state.copyWith(activities: updatedActivities); @@ -128,22 +133,41 @@ class ActivityListStateNotifier extends StateNotifier { } /// Handles the addition of a reaction. - void onReactionAdded(FeedsReactionData reaction) { - final updatedActivities = state.activities.map((activity) { - if (activity.id != reaction.activityId) return activity; + void onReactionAdded( + ActivityData activity, + FeedsReactionData reaction, + ) { + final updatedActivities = state.activities.map((it) { + if (it.id != reaction.activityId) return it; // Add the reaction to the activity - return activity.addReaction(reaction, currentUserId); + return it.upsertReaction(activity, reaction, currentUserId); + }).toList(); + + state = state.copyWith(activities: updatedActivities); + } + + void onReactionUpdated( + ActivityData activity, + FeedsReactionData reaction, + ) { + final updatedActivities = state.activities.map((it) { + if (it.id != reaction.activityId) return it; + // Update the reaction in the activity + return it.upsertUniqueReaction(activity, reaction, currentUserId); }).toList(); state = state.copyWith(activities: updatedActivities); } /// Handles the removal of a reaction. - void onReactionRemoved(FeedsReactionData reaction) { - final updatedActivities = state.activities.map((activity) { - if (activity.id != reaction.activityId) return activity; + void onReactionRemoved( + ActivityData activity, + FeedsReactionData reaction, + ) { + final updatedActivities = state.activities.map((it) { + if (it.id != reaction.activityId) return it; // Remove the reaction from the activity - return activity.removeReaction(reaction, currentUserId); + return it.removeReaction(activity, reaction, currentUserId); }).toList(); state = state.copyWith(activities: updatedActivities); diff --git a/packages/stream_feeds/lib/src/state/activity_state.dart b/packages/stream_feeds/lib/src/state/activity_state.dart index 09e61cd9..4b9bbb8c 100644 --- a/packages/stream_feeds/lib/src/state/activity_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_state.dart @@ -3,10 +3,10 @@ import 'package:state_notifier/state_notifier.dart'; import 'package:stream_core/stream_core.dart'; import '../models/activity_data.dart'; +import '../models/comment_data.dart'; import '../models/pagination_data.dart'; import '../models/poll_data.dart'; import '../models/poll_vote_data.dart'; -import '../models/threaded_comment_data.dart'; import 'activity_comment_list_state.dart'; part 'activity_state.freezed.dart'; @@ -203,7 +203,7 @@ class ActivityState with _$ActivityState { /// /// Contains a list of threaded comments related to the activity. @override - final List comments; + final List comments; /// Pagination information for [comments]. @override diff --git a/packages/stream_feeds/lib/src/state/activity_state.freezed.dart b/packages/stream_feeds/lib/src/state/activity_state.freezed.dart index dc6f374e..ed16bed1 100644 --- a/packages/stream_feeds/lib/src/state/activity_state.freezed.dart +++ b/packages/stream_feeds/lib/src/state/activity_state.freezed.dart @@ -16,7 +16,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$ActivityState { ActivityData? get activity; - List get comments; + List get comments; PaginationData? get commentsPagination; PollData? get poll; @@ -59,7 +59,7 @@ abstract mixin class $ActivityStateCopyWith<$Res> { @useResult $Res call( {ActivityData? activity, - List comments, + List comments, PaginationData? commentsPagination, PollData? poll}); } @@ -90,7 +90,7 @@ class _$ActivityStateCopyWithImpl<$Res> comments: null == comments ? _self.comments : comments // ignore: cast_nullable_to_non_nullable - as List, + as List, commentsPagination: freezed == commentsPagination ? _self.commentsPagination : commentsPagination // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_feeds/lib/src/state/comment_reply_list.dart b/packages/stream_feeds/lib/src/state/comment_reply_list.dart index 349a5d07..f56c2f94 100644 --- a/packages/stream_feeds/lib/src/state/comment_reply_list.dart +++ b/packages/stream_feeds/lib/src/state/comment_reply_list.dart @@ -4,7 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:state_notifier/state_notifier.dart'; import 'package:stream_core/stream_core.dart'; -import '../models/threaded_comment_data.dart'; +import '../models/comment_data.dart'; import '../repository/comments_repository.dart'; import 'comment_reply_list_state.dart'; import 'event/comment_reply_list_event_handler.dart'; @@ -30,6 +30,7 @@ class CommentReplyList with Disposable { _stateNotifier = CommentReplyListStateNotifier( initialState: const CommentReplyListState(), currentUserId: currentUserId, + parentCommentId: query.commentId, ); // Attach event handlers for real-time updates @@ -60,13 +61,13 @@ class CommentReplyList with Disposable { /// Queries the initial list of replies based on the provided query. /// /// Returns a [Result] containing a list of replies or an error. - Future>> get() => _queryReplies(query); + Future>> get() => _queryReplies(query); /// Loads more replies if available. /// /// Optionally accepts a [limit] parameter to specify the maximum number of /// replies to return. - Future>> queryMoreReplies({ + Future>> queryMoreReplies({ int? limit, }) async { // Build the query with the current pagination state (with next page token) @@ -86,7 +87,7 @@ class CommentReplyList with Disposable { } // Internal method to query replies and update state. - Future>> _queryReplies( + Future>> _queryReplies( CommentRepliesQuery query, ) async { final result = await commentsRepository.getCommentReplies(query); diff --git a/packages/stream_feeds/lib/src/state/comment_reply_list_state.dart b/packages/stream_feeds/lib/src/state/comment_reply_list_state.dart index e8867090..a56d3459 100644 --- a/packages/stream_feeds/lib/src/state/comment_reply_list_state.dart +++ b/packages/stream_feeds/lib/src/state/comment_reply_list_state.dart @@ -5,7 +5,6 @@ import 'package:stream_core/stream_core.dart'; import '../models/comment_data.dart'; import '../models/feeds_reaction_data.dart'; import '../models/pagination_data.dart'; -import '../models/threaded_comment_data.dart'; import 'query/comments_query.dart'; part 'comment_reply_list_state.freezed.dart'; @@ -19,16 +18,18 @@ class CommentReplyListStateNotifier CommentReplyListStateNotifier({ required CommentReplyListState initialState, required this.currentUserId, + required this.parentCommentId, }) : super(initialState); final String currentUserId; + final String parentCommentId; CommentsSort? _sort; CommentsSort get commentSort => _sort ?? CommentsSort.last; /// Handles the result of a query for replies to a comment. void onQueryMoreReplies( - PaginationResult result, + PaginationResult result, CommentsSort? sort, ) { _sort = sort; @@ -47,15 +48,43 @@ class CommentReplyListStateNotifier } /// Handles the addition of a new comment reply. - void onCommentAdded(ThreadedCommentData comment) { - final parentId = comment.parentId; + void onReplyAdded(CommentData reply) { + final parentId = reply.parentId; // If there's no parentId, it's not a reply; ignore it if (parentId == null) return; + // If this is a top-level reply to the parent comment, add it directly + if (parentId == parentCommentId) { + final updatedReplies = state.replies.sortedUpsert( + reply, + key: (it) => it.id, + compare: commentSort.compare, + update: (existing, updated) => existing.updateWith(updated), + ); + + state = state.copyWith(replies: updatedReplies); + return; + } + + // Otherwise, it's a nested reply - find the parent reply and add it final updatedReplies = state.replies.updateNested( (reply) => reply.id == parentId, children: (it) => it.replies ?? [], - update: (found) => found.addReply(comment, commentSort.compare), + update: (found) => found.upsertReply(reply, commentSort.compare), + updateChildren: (parent, replies) => parent.copyWith(replies: replies), + compare: commentSort.compare, + ); + + state = state.copyWith(replies: updatedReplies); + } + + /// Handles the update of an existing comment reply. + void onReplyUpdated(CommentData reply) { + // Update nested replies (handles both top-level and nested replies) + final updatedReplies = state.replies.updateNested( + (it) => it.id == reply.id, + children: (it) => it.replies ?? [], + update: (found) => found.updateWith(reply), updateChildren: (parent, replies) => parent.copyWith(replies: replies), compare: commentSort.compare, ); @@ -64,22 +93,40 @@ class CommentReplyListStateNotifier } /// Handles the removal of a comment reply. - void onCommentRemoved(String commentId) { - final updatedReplies = state.replies.removeNested( - (reply) => reply.id == commentId, + void onReplyRemoved(CommentData reply) { + final removeIndex = state.replies.indexWhere((it) => it.id == reply.id); + + // If found at the top level, remove it directly + if (removeIndex >= 0) { + final updatedReplies = [...state.replies].apply( + (it) => it.removeAt(removeIndex), + ); + + state = state.copyWith(replies: updatedReplies); + return; + } + + // Otherwise, it's a nested reply - find the parent reply and remove it + final updatedReplies = state.replies.updateNested( + (it) => it.id == reply.parentId, children: (it) => it.replies ?? [], + update: (found) => found.removeReply(reply), updateChildren: (parent, replies) => parent.copyWith(replies: replies), ); state = state.copyWith(replies: updatedReplies); } - /// Handles the update of an existing comment reply. - void onCommentUpdated(CommentData comment) { + /// Handles the addition of a reaction to a comment reply. + void onReplyReactionAdded( + CommentData reply, + FeedsReactionData reaction, + ) { + // Update nested replies (handles both top-level and nested replies) final updatedReplies = state.replies.updateNested( - (it) => it.id == comment.id, + (it) => it.id == reply.id, children: (it) => it.replies ?? [], - update: (found) => found.setCommentData(comment), + update: (found) => found.upsertReaction(reply, reaction, currentUserId), updateChildren: (parent, replies) => parent.copyWith(replies: replies), compare: commentSort.compare, ); @@ -87,12 +134,17 @@ class CommentReplyListStateNotifier state = state.copyWith(replies: updatedReplies); } - /// Handles the addition of a reaction to a comment reply. - void onCommentReactionAdded(String commentId, FeedsReactionData reaction) { + /// Handles the update of a reaction on a comment reply. + void onReplyReactionUpdated( + CommentData reply, + FeedsReactionData reaction, + ) { + // Update nested replies (handles both top-level and nested replies) final updatedReplies = state.replies.updateNested( - (it) => it.id == commentId, + (it) => it.id == reply.id, children: (it) => it.replies ?? [], - update: (found) => found.addReaction(reaction, currentUserId), + update: (found) => + found.upsertUniqueReaction(reply, reaction, currentUserId), updateChildren: (parent, replies) => parent.copyWith(replies: replies), compare: commentSort.compare, ); @@ -101,11 +153,15 @@ class CommentReplyListStateNotifier } /// Handles the removal of a reaction from a comment reply. - void onCommentReactionRemoved(String commentId, FeedsReactionData reaction) { + void onReplyReactionRemoved( + CommentData reply, + FeedsReactionData reaction, + ) { + // Update nested replies (handles both top-level and nested replies) final updatedReplies = state.replies.updateNested( - (it) => it.id == commentId, + (it) => it.id == reply.id, children: (it) => it.replies ?? [], - update: (found) => found.removeReaction(reaction, currentUserId), + update: (found) => found.removeReaction(reply, reaction, currentUserId), updateChildren: (parent, replies) => parent.copyWith(replies: replies), compare: commentSort.compare, ); @@ -131,7 +187,7 @@ class CommentReplyListState with _$CommentReplyListState { /// pagination requests. The replies are automatically sorted according to /// the current sorting configuration. @override - final List replies; + final List replies; /// Last pagination information. /// diff --git a/packages/stream_feeds/lib/src/state/comment_reply_list_state.freezed.dart b/packages/stream_feeds/lib/src/state/comment_reply_list_state.freezed.dart index cadf9d71..b9a762e2 100644 --- a/packages/stream_feeds/lib/src/state/comment_reply_list_state.freezed.dart +++ b/packages/stream_feeds/lib/src/state/comment_reply_list_state.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$CommentReplyListState { - List get replies; + List get replies; PaginationData? get pagination; /// Create a copy of CommentReplyListState @@ -52,7 +52,7 @@ abstract mixin class $CommentReplyListStateCopyWith<$Res> { $Res Function(CommentReplyListState) _then) = _$CommentReplyListStateCopyWithImpl; @useResult - $Res call({List replies, PaginationData? pagination}); + $Res call({List replies, PaginationData? pagination}); } /// @nodoc @@ -75,7 +75,7 @@ class _$CommentReplyListStateCopyWithImpl<$Res> replies: null == replies ? _self.replies : replies // ignore: cast_nullable_to_non_nullable - as List, + as List, pagination: freezed == pagination ? _self.pagination : pagination // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_feeds/lib/src/state/event/activity_comment_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/activity_comment_list_event_handler.dart index cba5146c..9384821b 100644 --- a/packages/stream_feeds/lib/src/state/event/activity_comment_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/activity_comment_list_event_handler.dart @@ -3,7 +3,6 @@ import 'package:stream_core/stream_core.dart'; import '../../generated/api/models.dart' as api; import '../../models/comment_data.dart'; import '../../models/feeds_reaction_data.dart'; -import '../../models/threaded_comment_data.dart'; import '../activity_comment_list_state.dart'; import 'state_event_handler.dart'; @@ -25,44 +24,60 @@ class ActivityCommentListEventHandler implements StateEventHandler { @override void handleEvent(WsEvent event) { if (event is api.CommentAddedEvent) { + final comment = event.comment.toModel(); // Only handle comments for this specific activity - if (event.comment.objectId != objectId) return; - if (event.comment.objectType != objectType) return; + if (comment.objectId != objectId) return; + if (comment.objectType != objectType) return; - final comment = event.comment.toModel(); - return state.onCommentAdded(ThreadedCommentData.fromComment(comment)); + return state.onCommentAdded(comment); } if (event is api.CommentUpdatedEvent) { + final comment = event.comment.toModel(); // Only handle comments for this specific activity - if (event.comment.objectId != objectId) return; - if (event.comment.objectType != objectType) return; - return state.onCommentUpdated(event.comment.toModel()); + if (comment.objectId != objectId) return; + if (comment.objectType != objectType) return; + + return state.onCommentUpdated(comment); } if (event is api.CommentDeletedEvent) { + final comment = event.comment.toModel(); // Only handle comments for this specific activity - if (event.comment.objectId != objectId) return; - if (event.comment.objectType != objectType) return; - return state.onCommentRemoved(event.comment.id); + if (comment.objectId != objectId) return; + if (comment.objectType != objectType) return; + + return state.onCommentRemoved(comment); } if (event is api.CommentReactionAddedEvent) { + final comment = event.comment.toModel(); // Only handle reactions for comments on this specific activity - if (event.comment.objectId != objectId) return; - if (event.comment.objectType != objectType) return; + if (comment.objectId != objectId) return; + if (comment.objectType != objectType) return; final reaction = event.reaction.toModel(); - return state.onCommentReactionAdded(event.comment.id, reaction); + return state.onCommentReactionAdded(comment, reaction); + } + + if (event is api.CommentReactionUpdatedEvent) { + final comment = event.comment.toModel(); + // Only handle reactions for comments on this specific activity + if (comment.objectId != objectId) return; + if (comment.objectType != objectType) return; + + final reaction = event.reaction.toModel(); + return state.onCommentReactionUpdated(comment, reaction); } if (event is api.CommentReactionDeletedEvent) { + final comment = event.comment.toModel(); // Only handle reactions for comments on this specific activity - if (event.comment.objectId != objectId) return; - if (event.comment.objectType != objectType) return; + if (comment.objectId != objectId) return; + if (comment.objectType != objectType) return; final reaction = event.reaction.toModel(); - return state.onCommentReactionRemoved(event.comment.id, reaction); + return state.onCommentReactionRemoved(comment, reaction); } // Handle other comment-related events if needed diff --git a/packages/stream_feeds/lib/src/state/event/activity_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/activity_list_event_handler.dart index 59928570..7438fe0a 100644 --- a/packages/stream_feeds/lib/src/state/event/activity_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/activity_list_event_handler.dart @@ -40,6 +40,18 @@ class ActivityListEventHandler return filter.matches(activity); } + if (event is api.ActivityAddedEvent) { + final activity = event.activity.toModel(); + if (!matchesQueryFilter(activity)) return; + + state.onActivityUpdated(activity); + + final updatedActivity = await withUpdatedFeedCapabilities(activity); + if (updatedActivity != null) state.onActivityUpdated(updatedActivity); + + return; + } + if (event is api.ActivityUpdatedEvent) { final activity = event.activity.toModel(); if (!matchesQueryFilter(activity)) { @@ -66,7 +78,19 @@ class ActivityListEventHandler return state.onActivityRemoved(activity); } - return state.onReactionAdded(event.reaction.toModel()); + final reaction = event.reaction.toModel(); + return state.onReactionAdded(activity, reaction); + } + + if (event is api.ActivityReactionUpdatedEvent) { + final activity = event.activity.toModel(); + if (!matchesQueryFilter(activity)) { + // If the reaction's activity no longer matches the filter, remove it + return state.onActivityRemoved(activity); + } + + final reaction = event.reaction.toModel(); + return state.onReactionUpdated(activity, reaction); } if (event is api.ActivityReactionDeletedEvent) { @@ -76,7 +100,8 @@ class ActivityListEventHandler return state.onActivityRemoved(activity); } - return state.onReactionRemoved(event.reaction.toModel()); + final reaction = event.reaction.toModel(); + return state.onReactionRemoved(activity, reaction); } if (event is api.BookmarkAddedEvent) { @@ -109,6 +134,11 @@ class ActivityListEventHandler return state.onCommentAdded(event.comment.toModel()); } + if (event is api.CommentUpdatedEvent) { + // TODO: Match event activity against filter once available in the event + return state.onCommentUpdated(event.comment.toModel()); + } + if (event is api.CommentDeletedEvent) { // TODO: Match event activity against filter once available in the event return state.onCommentRemoved(event.comment.toModel()); diff --git a/packages/stream_feeds/lib/src/state/event/comment_reply_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/comment_reply_list_event_handler.dart index 352e2c36..1841bbf1 100644 --- a/packages/stream_feeds/lib/src/state/event/comment_reply_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/comment_reply_list_event_handler.dart @@ -3,7 +3,6 @@ import 'package:stream_core/stream_core.dart'; import '../../generated/api/models.dart' as api; import '../../models/comment_data.dart'; import '../../models/feeds_reaction_data.dart'; -import '../../models/threaded_comment_data.dart'; import '../comment_reply_list_state.dart'; import 'state_event_handler.dart'; @@ -22,25 +21,47 @@ class CommentReplyListEventHandler implements StateEventHandler { void handleEvent(WsEvent event) { if (event is api.CommentAddedEvent) { final comment = event.comment.toModel(); - return state.onCommentAdded(ThreadedCommentData.fromComment(comment)); + if (comment.parentId == null) return; + + return state.onReplyAdded(comment); } if (event is api.CommentDeletedEvent) { - return state.onCommentRemoved(event.comment.id); + final comment = event.comment.toModel(); + if (comment.parentId == null) return; + + return state.onReplyRemoved(comment); } if (event is api.CommentUpdatedEvent) { - return state.onCommentUpdated(event.comment.toModel()); + final comment = event.comment.toModel(); + if (comment.parentId == null) return; + + return state.onReplyUpdated(comment); } if (event is api.CommentReactionAddedEvent) { + final comment = event.comment.toModel(); + if (comment.parentId == null) return; + final reaction = event.reaction.toModel(); - return state.onCommentReactionAdded(event.comment.id, reaction); + return state.onReplyReactionAdded(comment, reaction); + } + + if (event is api.CommentReactionUpdatedEvent) { + final comment = event.comment.toModel(); + if (comment.parentId == null) return; + + final reaction = event.reaction.toModel(); + return state.onReplyReactionUpdated(comment, reaction); } if (event is api.CommentReactionDeletedEvent) { + final comment = event.comment.toModel(); + if (comment.parentId == null) return; + final reaction = event.reaction.toModel(); - return state.onCommentReactionRemoved(event.comment.id, reaction); + return state.onReplyReactionRemoved(comment, reaction); } // Handle other comment reply list events here as needed diff --git a/packages/stream_feeds/lib/src/state/event/feed_event_handler.dart b/packages/stream_feeds/lib/src/state/event/feed_event_handler.dart index 953becef..dea127da 100644 --- a/packages/stream_feeds/lib/src/state/event/feed_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/feed_event_handler.dart @@ -83,7 +83,21 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { return state.onActivityRemoved(activity); } - return state.onReactionAdded(event.reaction.toModel()); + final reaction = event.reaction.toModel(); + return state.onReactionAdded(activity, reaction); + } + + if (event is api.ActivityReactionUpdatedEvent) { + if (event.fid != fid.rawValue) return; + + final activity = event.activity.toModel(); + if (!matchesQueryFilter(activity)) { + // If the reaction's activity no longer matches the filter, remove it + return state.onActivityRemoved(activity); + } + + final reaction = event.reaction.toModel(); + return state.onReactionUpdated(activity, reaction); } if (event is api.ActivityReactionDeletedEvent) { @@ -95,7 +109,8 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { return state.onActivityRemoved(activity); } - return state.onReactionRemoved(event.reaction.toModel()); + final reaction = event.reaction.toModel(); + return state.onReactionRemoved(activity, reaction); } if (event is api.ActivityPinnedEvent) { @@ -136,26 +151,10 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { } if (event is api.BookmarkAddedEvent) { - final activity = event.bookmark.activity.toModel(); - if (!activity.feeds.contains(fid.rawValue)) return; - - if (!matchesQueryFilter(activity)) { - // If the bookmark's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - return state.onBookmarkAdded(event.bookmark.toModel()); } if (event is api.BookmarkDeletedEvent) { - final activity = event.bookmark.activity.toModel(); - if (!activity.feeds.contains(fid.rawValue)) return; - - if (!matchesQueryFilter(activity)) { - // If the bookmark's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - return state.onBookmarkRemoved(event.bookmark.toModel()); } @@ -171,6 +170,12 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { return state.onCommentAdded(event.comment.toModel()); } + if (event is api.CommentUpdatedEvent) { + if (event.fid != fid.rawValue) return; + // TODO: Match event activity against filter once available in the event + return state.onCommentUpdated(event.comment.toModel()); + } + if (event is api.CommentDeletedEvent) { if (event.fid != fid.rawValue) return; // TODO: Match event activity against filter once available in the event diff --git a/packages/stream_feeds/lib/src/state/feed.dart b/packages/stream_feeds/lib/src/state/feed.dart index 09c00ccf..57094000 100644 --- a/packages/stream_feeds/lib/src/state/feed.dart +++ b/packages/stream_feeds/lib/src/state/feed.dart @@ -251,10 +251,10 @@ class Feed with Disposable { /// Adds an activity to the user's bookmarks. /// - /// The [activityId] is the unique identifier of the activity to bookmark. - /// The [request] contains additional details for the bookmark. - /// Returns a [Result] containing the created [BookmarkData] if successful, or an error if the - /// operation fails. + /// Creates a bookmark for the activity with [activityId]. When [request] is provided, + /// can specify a folder ID or create a new folder for the bookmark. + /// + /// Returns a [Result] containing the created [BookmarkData] or an error. Future> addBookmark({ required String activityId, api.AddBookmarkRequest request = const api.AddBookmarkRequest(), @@ -266,8 +266,8 @@ class Feed with Disposable { /// Updates an existing bookmark for an activity. /// - /// This method allows you to modify the properties of an existing bookmark, such as changing the - /// folder it belongs to or updating custom data associated with the bookmark. + /// Modifies the bookmark for the activity with [activityId] using the provided [request]. + /// Can change the folder, update custom data, or move the bookmark to a new folder. /// /// Example: /// ```dart @@ -283,10 +283,7 @@ class Feed with Disposable { /// final bookmark = feed.updateBookmark('activity-456', customUpdateRequest); /// ``` /// - /// The [activityId] is the unique identifier of the activity whose bookmark should be updated. - /// The [request] contains the new bookmark properties to apply. - /// Returns a [Result] containing the updated [BookmarkData] if successful, or an error if the - /// operation fails. + /// Returns a [Result] containing the updated [BookmarkData] or an error. Future> updateBookmark({ required String activityId, required api.UpdateBookmarkRequest request, @@ -296,11 +293,10 @@ class Feed with Disposable { /// Removes an activity from the user's bookmarks. /// - /// The [activityId] is the unique identifier of the activity to remove from bookmarks. - /// The [folderId] is an optional folder identifier. If provided, removes the bookmark from the - /// specific folder. - /// Returns a [Result] containing the deleted [BookmarkData] if successful, or an error if the - /// operation fails. + /// Deletes the bookmark for the activity with [activityId]. When [folderId] is provided, + /// removes the bookmark from that specific folder only. + /// + /// Returns a [Result] containing the deleted [BookmarkData] or an error. Future> deleteBookmark({ required String activityId, String? folderId, diff --git a/packages/stream_feeds/lib/src/state/feed_state.dart b/packages/stream_feeds/lib/src/state/feed_state.dart index eda0dc4b..816b26ee 100644 --- a/packages/stream_feeds/lib/src/state/feed_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_state.dart @@ -125,11 +125,12 @@ class FeedStateNotifier extends StateNotifier { activity, key: (it) => it.id, compare: activitiesSort.compare, + update: (existing, updated) => existing.updateWith(updated), ); final updatedPinnedActivities = state.pinnedActivities.map((pin) { if (pin.activity.id != activity.id) return pin; - return pin.copyWith(activity: activity); + return pin.copyWith(activity: pin.activity.updateWith(activity)); }).toList(); state = state.copyWith( @@ -251,20 +252,26 @@ class FeedStateNotifier extends StateNotifier { state = state.copyWith(aggregatedActivities: updatedAggregatedActivities); } - /// Handles updates to the feed state when a bookmark is added or removed. + /// Handles updates to the feed state when a bookmark is added. + /// + /// Updates the activity matching [bookmark]'s activity ID by adding or updating + /// the bookmark in its own bookmarks list. Only adds bookmarks that belong to + /// the current user. void onBookmarkAdded(BookmarkData bookmark) { - // Add or update the bookmark in the activity final updatedActivities = state.activities.map((activity) { if (activity.id != bookmark.activity.id) return activity; - return activity.addBookmark(bookmark, currentUserId); + return activity.upsertBookmark(bookmark, currentUserId); }).toList(); state = state.copyWith(activities: updatedActivities); } /// Handles updates to the feed state when a bookmark is removed. + /// + /// Updates the activity matching [bookmark]'s activity ID by removing + /// the bookmark from its own bookmarks list. Only removes bookmarks that + /// belong to the current user. void onBookmarkRemoved(BookmarkData bookmark) { - // Remove the bookmark from the activity final updatedActivities = state.activities.map((activity) { if (activity.id != bookmark.activity.id) return activity; return activity.removeBookmark(bookmark, currentUserId); @@ -275,10 +282,15 @@ class FeedStateNotifier extends StateNotifier { /// Handles updates to the feed state when a comment is added or removed. void onCommentAdded(CommentData comment) { + return onCommentUpdated(comment); + } + + /// Handles updates to the feed state when a comment is updated. + void onCommentUpdated(CommentData comment) { // Add or update the comment in the activity final updatedActivities = state.activities.map((activity) { if (activity.id != comment.objectId) return activity; - return activity.addComment(comment); + return activity.upsertComment(comment); }).toList(); state = state.copyWith(activities: updatedActivities); @@ -360,22 +372,42 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when a reaction is added. - void onReactionAdded(FeedsReactionData reaction) { + void onReactionAdded( + ActivityData activity, + FeedsReactionData reaction, + ) { // Add or update the reaction in the activity - final updatedActivities = state.activities.map((activity) { - if (activity.id != reaction.activityId) return activity; - return activity.addReaction(reaction, currentUserId); + final updatedActivities = state.activities.map((it) { + if (it.id != reaction.activityId) return it; + return it.upsertReaction(activity, reaction, currentUserId); + }).toList(); + + state = state.copyWith(activities: updatedActivities); + } + + /// Handles updates to the feed state when a reaction is updated. + void onReactionUpdated( + ActivityData activity, + FeedsReactionData reaction, + ) { + // Update the reaction in the activity + final updatedActivities = state.activities.map((it) { + if (it.id != reaction.activityId) return it; + return it.upsertUniqueReaction(activity, reaction, currentUserId); }).toList(); state = state.copyWith(activities: updatedActivities); } /// Handles updates to the feed state when a reaction is removed. - void onReactionRemoved(FeedsReactionData reaction) { + void onReactionRemoved( + ActivityData activity, + FeedsReactionData reaction, + ) { // Remove the reaction from the activity - final updatedActivities = state.activities.map((activity) { - if (activity.id != reaction.activityId) return activity; - return activity.removeReaction(reaction, currentUserId); + final updatedActivities = state.activities.map((it) { + if (it.id != reaction.activityId) return it; + return it.removeReaction(activity, reaction, currentUserId); }).toList(); state = state.copyWith(activities: updatedActivities); diff --git a/packages/stream_feeds/pubspec.yaml b/packages/stream_feeds/pubspec.yaml index 6d6ac5cd..997e7f90 100644 --- a/packages/stream_feeds/pubspec.yaml +++ b/packages/stream_feeds/pubspec.yaml @@ -30,7 +30,7 @@ dependencies: retrofit: ">=4.6.0 <=4.9.0" rxdart: ^0.28.0 state_notifier: ^1.0.0 - stream_core: ^0.3.0 + stream_core: ^0.3.1 uuid: ^4.5.1 dev_dependencies: diff --git a/packages/stream_feeds/test/state/activity_comment_list_test.dart b/packages/stream_feeds/test/state/activity_comment_list_test.dart new file mode 100644 index 00000000..36f3dc76 --- /dev/null +++ b/packages/stream_feeds/test/state/activity_comment_list_test.dart @@ -0,0 +1,755 @@ +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart'; + +import '../test_utils.dart'; + +void main() { + const activityId = 'activity-1'; + const commentId = 'comment-test-1'; + const userId = 'luke_skywalker'; + const reactionType = 'like'; + + const query = ActivityCommentsQuery( + objectId: activityId, + objectType: 'activity', + ); + + // ============================================================ + // FEATURE: Activity Comment List - Query Operations + // ============================================================ + + group('Activity Comment List - Query Operations', () { + activityCommentListTest( + 'get - should query initial comments via API', + build: (client) => client.activityCommentList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final comments = result.getOrThrow(); + + expect(comments, isA>()); + expect(comments, hasLength(3)); + expect(comments[0].id, 'comment-1'); + expect(comments[1].id, 'comment-2'); + expect(comments[2].id, 'comment-3'); + }, + ); + + activityCommentListTest( + 'queryMoreComments - should load more comments via API', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityCommentListState.comments, hasLength(1)); + + final nextPageQuery = tester.activityCommentList.query.copyWith( + next: tester.activityCommentListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.getComments( + objectId: nextPageQuery.objectId, + objectType: nextPageQuery.objectType, + depth: nextPageQuery.depth, + sort: nextPageQuery.sort, + limit: nextPageQuery.limit, + next: nextPageQuery.next, + prev: nextPageQuery.previous, + ), + result: createDefaultCommentsResponse( + prev: 'prev-cursor', + comments: [ + createDefaultThreadedCommentResponse( + id: 'comment-test-2', + objectId: activityId, + objectType: 'activity', + text: 'Second comment', + userId: userId, + ), + ], + ), + ); + + // Query more comments + final result = await tester.activityCommentList.queryMoreComments(); + + expect(result.isSuccess, isTrue); + final comments = result.getOrNull(); + expect(comments, isNotNull); + expect(comments, hasLength(1)); + + // Verify state was updated with merged comments + expect(tester.activityCommentListState.comments, hasLength(2)); + expect(tester.activityCommentListState.pagination?.next, isNull); + expect( + tester.activityCommentListState.pagination?.previous, + 'prev-cursor', + ); + }, + verify: (tester) { + final nextPageQuery = tester.activityCommentList.query.copyWith( + next: tester.activityCommentListState.pagination?.next, + ); + + tester.verifyApi( + (api) => api.getComments( + objectId: nextPageQuery.objectId, + objectType: nextPageQuery.objectType, + depth: nextPageQuery.depth, + sort: nextPageQuery.sort, + limit: nextPageQuery.limit, + next: nextPageQuery.next, + prev: nextPageQuery.previous, + ), + ); + }, + ); + + activityCommentListTest( + 'queryMoreComments - should return empty list when no more comments', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment but no pagination + expect(tester.activityCommentListState.comments, hasLength(1)); + expect(tester.activityCommentListState.pagination?.next, isNull); + + // Query more comments (should return empty immediately) + final result = await tester.activityCommentList.queryMoreComments(); + + expect(result.isSuccess, isTrue); + final comments = result.getOrNull(); + expect(comments, isNotNull); + expect(comments, isEmpty); + + // State should remain unchanged + expect(tester.activityCommentListState.comments, hasLength(1)); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity Comment List - Event Handling + // ============================================================ + + group('Activity Comment List - Event Handling', () { + activityCommentListTest( + 'should handle CommentAddedEvent and update comments', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(comments: const []), + ), + body: (tester) async { + // Initial state - no comments + expect(tester.activityCommentListState.comments, isEmpty); + + // Emit event + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ), + ); + + // Verify state has comment + expect(tester.activityCommentListState.comments, hasLength(1)); + expect(tester.activityCommentListState.comments.first.id, commentId); + expect( + tester.activityCommentListState.comments.first.text, + 'Test comment', + ); + expect(tester.activityCommentListState.comments.first.user.id, userId); + }, + ); + + activityCommentListTest( + 'should handle CommentAddedEvent and add nested reply', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Top-level comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityCommentListState.comments, hasLength(1)); + final initialComment = tester.activityCommentListState.comments.first; + expect(initialComment.id, commentId); + expect(initialComment.replies, isNull); + expect(initialComment.replyCount, 0); + + // Emit event for nested reply (parentId matches existing comment's ID) + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: 'nested-reply-1', + objectId: activityId, + objectType: 'activity', + text: 'Nested reply', + userId: userId, + ).copyWith(parentId: commentId), + ), + ); + + // Verify nested reply was added + expect(tester.activityCommentListState.comments, hasLength(1)); + final topLevelComment = tester.activityCommentListState.comments.first; + expect(topLevelComment.id, commentId); + expect(topLevelComment.replies, isNotNull); + expect(topLevelComment.replies, hasLength(1)); + expect(topLevelComment.replies!.first.id, 'nested-reply-1'); + expect(topLevelComment.replies!.first.text, 'Nested reply'); + expect(topLevelComment.replyCount, 1); + }, + ); + + activityCommentListTest( + 'should handle CommentUpdatedEvent and update comment', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Original comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityCommentListState.comments, hasLength(1)); + expect( + tester.activityCommentListState.comments.first.text, + 'Original comment', + ); + + // Emit event + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Updated comment', + userId: userId, + ), + ), + ); + + // Verify state has updated comment + expect(tester.activityCommentListState.comments, hasLength(1)); + expect( + tester.activityCommentListState.comments.first.text, + 'Updated comment', + ); + }, + ); + + activityCommentListTest( + 'should handle CommentDeletedEvent and remove comment', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityCommentListState.comments, hasLength(1)); + expect(tester.activityCommentListState.comments.first.id, commentId); + + // Emit event + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + ), + ); + + // Verify state has no comments + expect(tester.activityCommentListState.comments, isEmpty); + }, + ); + + activityCommentListTest( + 'should handle CommentDeletedEvent and remove nested reply', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Top-level comment', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: activityId, + objectType: 'activity', + text: 'Nested reply', + userId: userId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment with nested reply + final topLevelComment = tester.activityCommentListState.comments.first; + expect(topLevelComment.replies, hasLength(1)); + expect(topLevelComment.replies!.first.id, 'nested-reply-1'); + expect(topLevelComment.replyCount, 1); + + // Emit event to delete nested reply + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'nested-reply-1', + objectId: activityId, + objectType: 'activity', + userId: userId, + parentId: commentId, + ), + ), + ); + + // Verify nested reply was removed + final updatedTopLevelComment = + tester.activityCommentListState.comments.first; + expect(updatedTopLevelComment.replies, isEmpty); + expect(updatedTopLevelComment.replyCount, 0); + // Top-level comment should still exist + expect(tester.activityCommentListState.comments, hasLength(1)); + expect(tester.activityCommentListState.comments.first.id, commentId); + }, + ); + + activityCommentListTest( + 'should handle CommentDeletedEvent and remove deep nested reply', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Top-level comment', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: activityId, + objectType: 'activity', + text: 'Nested reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'deep-nested-reply-1', + objectId: activityId, + objectType: 'activity', + text: 'Deep nested reply', + userId: userId, + ), + ], + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment with deep nested reply + final topLevelComment = tester.activityCommentListState.comments.first; + final secondLevelComment = topLevelComment.replies!.first; + expect(secondLevelComment.replies, hasLength(1)); + expect(secondLevelComment.replies!.first.id, 'deep-nested-reply-1'); + expect(secondLevelComment.replyCount, 1); + + // Emit event to delete deep nested reply + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'deep-nested-reply-1', + objectId: activityId, + objectType: 'activity', + userId: userId, + parentId: 'nested-reply-1', + ), + ), + ); + + // Verify deep nested reply was removed + final updatedTopLevelComment = + tester.activityCommentListState.comments.first; + final updatedSecondLevelComment = updatedTopLevelComment.replies!.first; + expect(updatedSecondLevelComment.replies, isEmpty); + expect(updatedSecondLevelComment.replyCount, 0); + // Second-level comment should still exist + expect(updatedTopLevelComment.replies, hasLength(1)); + expect(updatedTopLevelComment.replies!.first.id, 'nested-reply-1'); + }, + ); + + activityCommentListTest( + 'should not update comments if objectId does not match', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(comments: const []), + ), + body: (tester) async { + // Initial state - no comments + expect(tester.activityCommentListState.comments, isEmpty); + + // Emit CommentAddedEvent for different activity + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: + createDefaultActivityResponse(id: 'different-activity-id'), + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'different-activity-id', + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ), + ); + + // Verify state was not updated + expect(tester.activityCommentListState.comments, isEmpty); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity Comment List - Comment Reactions + // ============================================================ + + group('Activity Comment List - Comment Reactions', () { + activityCommentListTest( + 'should handle CommentReactionAddedEvent and update comment', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has no reactions + final initialComment = tester.activityCommentListState.comments.first; + expect(initialComment.ownReactions, isEmpty); + + // Emit event + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has reaction + final updatedComment = tester.activityCommentListState.comments.first; + expect(updatedComment.ownReactions, hasLength(1)); + expect(updatedComment.ownReactions.first.type, reactionType); + expect(updatedComment.ownReactions.first.user.id, userId); + }, + ); + + activityCommentListTest( + 'should handle CommentReactionUpdatedEvent and update reaction', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has 'like' reaction + final initialComment = tester.activityCommentListState.comments.first; + expect(initialComment.ownReactions, hasLength(1)); + expect(initialComment.ownReactions.first.type, reactionType); + + // Emit event + await tester.emitEvent( + CommentReactionUpdatedEvent( + type: EventTypes.commentReactionUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: 'fire', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has updated reaction (old reaction replaced) + final updatedComment = tester.activityCommentListState.comments.first; + expect(updatedComment.ownReactions, hasLength(1)); + expect(updatedComment.ownReactions.first.type, 'fire'); + }, + ); + + activityCommentListTest( + 'should handle CommentReactionDeletedEvent and remove reaction', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has reaction + final initialComment = tester.activityCommentListState.comments.first; + expect(initialComment.ownReactions, hasLength(1)); + + // Emit event + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has no reactions + final updatedComment = tester.activityCommentListState.comments.first; + expect(updatedComment.ownReactions, isEmpty); + }, + ); + + activityCommentListTest( + 'should not update comment if objectId does not match for reaction events', + build: (client) => client.activityCommentList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has no reactions + expect(tester.activityCommentListState.comments, hasLength(1)); + final initialComment = tester.activityCommentListState.comments.first; + expect(initialComment.id, commentId); + expect(initialComment.ownReactions, isEmpty); + + // Emit CommentReactionAddedEvent for different activity + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: + createDefaultActivityResponse(id: 'different-activity-id'), + comment: createDefaultCommentResponse( + id: 'different-comment-id', + objectId: 'different-activity-id', + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: 'different-activity-id', + commentId: 'different-comment-id', + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state was not updated (comment count unchanged, no reactions added) + expect(tester.activityCommentListState.comments, hasLength(1)); + final comment = tester.activityCommentListState.comments.first; + expect(comment.id, commentId); + expect(comment.ownReactions, isEmpty); + }, + ); + }); +} diff --git a/packages/stream_feeds/test/state/activity_list_test.dart b/packages/stream_feeds/test/state/activity_list_test.dart index d49e705d..23509790 100644 --- a/packages/stream_feeds/test/state/activity_list_test.dart +++ b/packages/stream_feeds/test/state/activity_list_test.dart @@ -4,6 +4,11 @@ import 'package:test/test.dart'; import '../test_utils.dart'; void main() { + const activityId = 'activity-1'; + const userId = 'luke_skywalker'; + const reactionType = 'like'; + + const query = ActivitiesQuery(); // ============================================================ // FEATURE: Local Filtering // ============================================================ @@ -34,12 +39,13 @@ void main() { createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - activity: createDefaultActivityResponse(id: 'activity-1') - .copyWith(type: 'comment'), + activity: createDefaultActivityResponse( + id: 'activity-1', + type: 'comment', + ), ), ); - await tester.pump(); expect(tester.activityListState.activities, hasLength(2)); }, ); @@ -63,8 +69,10 @@ void main() { createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - activity: createDefaultActivityResponse(id: 'activity-2') - .copyWith(type: 'comment'), + activity: createDefaultActivityResponse( + id: 'activity-2', + type: 'comment', + ), reaction: FeedsReactionResponse( activityId: 'activity-2', type: 'like', @@ -75,7 +83,6 @@ void main() { ), ); - await tester.pump(); expect(tester.activityListState.activities, hasLength(2)); }, ); @@ -99,8 +106,10 @@ void main() { createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - activity: createDefaultActivityResponse(id: 'activity-3') - .copyWith(type: 'share'), + activity: createDefaultActivityResponse( + id: 'activity-3', + type: 'share', + ), reaction: FeedsReactionResponse( activityId: 'activity-3', type: 'like', @@ -111,7 +120,6 @@ void main() { ), ); - await tester.pump(); expect(tester.activityListState.activities, hasLength(2)); }, ); @@ -136,14 +144,11 @@ void main() { custom: const {}, bookmark: createDefaultBookmarkResponse( activityId: 'activity-1', - ).copyWith( - activity: createDefaultActivityResponse(id: 'activity-1') - .copyWith(type: 'comment'), + activityType: 'comment', ), ), ); - await tester.pump(); expect(tester.activityListState.activities, hasLength(2)); }, ); @@ -168,14 +173,11 @@ void main() { custom: const {}, bookmark: createDefaultBookmarkResponse( activityId: 'activity-2', - ).copyWith( - activity: createDefaultActivityResponse(id: 'activity-2') - .copyWith(type: 'share'), + activityType: 'share', ), ), ); - await tester.pump(); expect(tester.activityListState.activities, hasLength(2)); }, ); @@ -199,15 +201,16 @@ void main() { createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - activity: createDefaultActivityResponse(id: 'activity-3') - .copyWith(type: 'comment'), + activity: createDefaultActivityResponse( + id: 'activity-3', + type: 'comment', + ), comment: createDefaultCommentResponse( objectId: 'activity-3', ), ), ); - await tester.pump(); expect(tester.activityListState.activities, hasLength(2)); }, ); @@ -234,14 +237,14 @@ void main() { createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - activity: createDefaultActivityResponse(id: 'activity-1').copyWith( - type: 'post', // Matches first condition + activity: createDefaultActivityResponse( + id: 'activity-1', + ).copyWith( filterTags: ['general'], // Doesn't match second condition ), ), ); - await tester.pump(); expect(tester.activityListState.activities, hasLength(2)); }, ); @@ -268,14 +271,14 @@ void main() { createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - activity: createDefaultActivityResponse(id: 'activity-1').copyWith( - type: 'post', // Matches first condition + activity: createDefaultActivityResponse( + id: 'activity-1', + ).copyWith( filterTags: ['general'], // Doesn't match second condition ), ), ); - await tester.pump(); expect(tester.activityListState.activities, hasLength(3)); final updatedActivity = tester.activityListState.activities.firstWhere( @@ -301,12 +304,13 @@ void main() { createdAt: DateTime.timestamp(), custom: const {}, fid: 'fid', - activity: createDefaultActivityResponse(id: 'activity-1') - .copyWith(type: 'share'), + activity: createDefaultActivityResponse( + id: 'activity-1', + type: 'share', + ), ), ); - await tester.pump(); expect(tester.activityListState.activities, hasLength(3)); }, ); @@ -389,4 +393,552 @@ void main() { }, ); }); + + // ============================================================ + // FEATURE: Activity List - Query Operations + // ============================================================ + + group('Activity List - Query Operations', () { + activityListTest( + 'get - should query initial activities via API', + build: (client) => client.activityList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final activities = result.getOrThrow(); + + expect(activities, isA>()); + expect(activities, hasLength(3)); + expect(activities[0].id, 'activity-1'); + expect(activities[1].id, 'activity-2'); + expect(activities[2].id, 'activity-3'); + }, + ); + + activityListTest( + 'queryMoreActivities - should load more activities via API', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + activities: [ + createDefaultActivityResponse( + id: activityId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has activity + expect(tester.activityListState.activities, hasLength(1)); + + final nextPageQuery = tester.activityList.query.copyWith( + next: tester.activityListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.queryActivities( + queryActivitiesRequest: nextPageQuery.toRequest(), + ), + result: createDefaultQueryActivitiesResponse( + prev: 'prev-cursor', + activities: [ + createDefaultActivityResponse(id: 'activity-2'), + ], + ), + ); + + // Query more activities + final result = await tester.activityList.queryMoreActivities(); + + expect(result.isSuccess, isTrue); + final activities = result.getOrNull(); + expect(activities, isNotNull); + expect(activities, hasLength(1)); + + // Verify state was updated with merged activities + expect(tester.activityListState.activities, hasLength(2)); + expect(tester.activityListState.pagination?.next, isNull); + expect(tester.activityListState.pagination?.previous, 'prev-cursor'); + }, + verify: (tester) { + final nextPageQuery = tester.activityList.query.copyWith( + next: tester.activityListState.pagination?.next, + ); + + tester.verifyApi( + (api) => api.queryActivities( + queryActivitiesRequest: nextPageQuery.toRequest(), + ), + ); + }, + ); + + activityListTest( + 'queryMoreActivities - should return empty list when no more activities', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), + ), + body: (tester) async { + // Initial state - has activity but no pagination + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.pagination?.next, isNull); + expect(tester.activityListState.pagination?.previous, isNull); + + // Query more activities (should return empty immediately) + final result = await tester.activityList.queryMoreActivities(); + + expect(result.isSuccess, isTrue); + final activities = result.getOrNull(); + expect(activities, isEmpty); + + // Verify state was not updated (no new activities, pagination remains null) + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.pagination?.next, isNull); + expect(tester.activityListState.pagination?.previous, isNull); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity List - Event Handling + // ============================================================ + + group('Activity List - Event Handling', () { + activityListTest( + 'should handle ActivityAddedEvent and add activity', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(activities: const []), + ), + body: (tester) async { + // Initial state - no activities + expect(tester.activityListState.activities, isEmpty); + + // Emit event + await tester.emitEvent( + ActivityAddedEvent( + type: EventTypes.activityAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: activityId), + ), + ); + + // Verify state has activity + expect(tester.activityListState.activities, hasLength(1)); + final addedActivity = tester.activityListState.activities.first; + expect(addedActivity.id, activityId); + expect(addedActivity.type, 'post'); + }, + ); + + activityListTest( + 'should handle ActivityUpdatedEvent and update activity', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), + ), + body: (tester) async { + // Initial state - has activity + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.type, 'post'); + + // Emit event + await tester.emitEvent( + ActivityUpdatedEvent( + type: EventTypes.activityUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse( + id: activityId, + type: 'share', + ), + ), + ); + + // Verify state has updated activity + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.type, 'share'); + }, + ); + + activityListTest( + 'should handle ActivityDeletedEvent and remove activity', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), + ), + body: (tester) async { + // Initial state - has activity + expect(tester.activityListState.activities, hasLength(1)); + expect(tester.activityListState.activities.first.id, activityId); + + // Emit event + await tester.emitEvent( + ActivityDeletedEvent( + type: 'feeds.activity.deleted', + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse( + id: activityId, + ), + ), + ); + + // Verify state has no activities + expect(tester.activityListState.activities, isEmpty); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity List - Reactions + // ============================================================ + + group('Activity List - Reactions', () { + activityListTest( + 'should handle ActivityReactionAddedEvent and update activity', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), + ), + body: (tester) async { + // Initial state - no reactions + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownReactions, isEmpty); + + // Emit event + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse( + id: activityId, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has reaction + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownReactions, hasLength(1)); + expect(updatedActivity.ownReactions.first.type, reactionType); + }, + ); + + activityListTest( + 'should handle ActivityReactionDeletedEvent and remove reaction', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + ownReactions: [ + FeedsReactionResponse( + activityId: activityId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownReactions, hasLength(1)); + + // Emit event + await tester.emitEvent( + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse( + id: activityId, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has no reactions + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownReactions, isEmpty); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity List - Bookmarks + // ============================================================ + + group('Activity List - Bookmarks', () { + activityListTest( + 'should handle BookmarkAddedEvent and update activity', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), + ), + body: (tester) async { + // Initial state - no bookmarks + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownBookmarks, isEmpty); + + // Emit event + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + activityId: activityId, + userId: userId, + ), + ), + ); + + // Verify state has bookmark + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownBookmarks, hasLength(1)); + expect(updatedActivity.ownBookmarks.first.user.id, userId); + }, + ); + + activityListTest( + 'should handle BookmarkDeletedEvent and remove bookmark', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + ownBookmarks: [ + createDefaultBookmarkResponse( + activityId: activityId, + userId: userId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); + + // Emit event + await tester.emitEvent( + BookmarkDeletedEvent( + type: EventTypes.bookmarkDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + activityId: activityId, + userId: userId, + ), + ), + ); + + // Verify state has no bookmarks + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.ownBookmarks, isEmpty); + }, + ); + }); + + // ============================================================ + // FEATURE: Activity List - Comments + // ============================================================ + + group('Activity List - Comments', () { + activityListTest( + 'should handle CommentAddedEvent and update activity', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse(id: activityId), + ], + ), + ), + body: (tester) async { + // Initial state - no comments + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.comments, isEmpty); + + // Emit event + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse( + id: activityId, + ), + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ), + ); + + // Verify state has comment + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.text, 'Test comment'); + }, + ); + + activityListTest( + 'should handle CommentUpdatedEvent and update comment', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + text: 'Original comment', + userId: userId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.text, 'Original comment'); + + // Emit event + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + text: 'Updated comment', + userId: userId, + ), + ), + ); + + // Verify state has updated comment + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.comments, hasLength(1)); + expect(updatedActivity.comments.first.text, 'Updated comment'); + }, + ); + + activityListTest( + 'should handle CommentDeletedEvent and remove comment', + build: (client) => client.activityList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + comments: [ + createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + final initialActivity = tester.activityListState.activities.first; + expect(initialActivity.comments, hasLength(1)); + expect(initialActivity.comments.first.id, 'comment-1'); + expect(initialActivity.commentCount, 1); + + // Emit event + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'comment-1', + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + ), + ); + + // Verify state has no comments + final updatedActivity = tester.activityListState.activities.first; + expect(updatedActivity.comments, isEmpty); + expect(updatedActivity.commentCount, 0); + }, + ); + }); } diff --git a/packages/stream_feeds/test/state/activity_test.dart b/packages/stream_feeds/test/state/activity_test.dart index 77ea249e..3d9cbf18 100644 --- a/packages/stream_feeds/test/state/activity_test.dart +++ b/packages/stream_feeds/test/state/activity_test.dart @@ -1,3 +1,4 @@ +import 'package:mocktail/mocktail.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; @@ -375,4 +376,909 @@ void main() { ); }); }); + + // ============================================================ + // FEATURE: Comments + // ============================================================ + + group('Comments', () { + const commentId = 'comment-test-1'; + const userId = 'luke_skywalker'; + + activityTest( + 'addComment - should add comment to activity via API', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get(), + body: (tester) async { + // Mock API call that will be used + tester.mockApi( + (api) => api.addComment( + addCommentRequest: any(named: 'addCommentRequest'), + ), + result: createDefaultAddCommentResponse( + commentId: commentId, + objectId: activityId, + userId: userId, + text: 'Test comment', + ), + ); + + // Initial state - no comments + expect(tester.activityState.comments, isEmpty); + + // Add comment + final result = await tester.activity.addComment( + request: const ActivityAddCommentRequest( + activityId: activityId, + comment: 'Test comment', + ), + ); + + expect(result.isSuccess, isTrue); + final comment = result.getOrNull(); + expect(comment, isNotNull); + expect(comment!.id, commentId); + expect(comment.text, 'Test comment'); + expect(comment.user.id, userId); + + // Note: addComment updates state automatically via onCommentAdded + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.id, commentId); + }, + verify: (tester) => tester.verifyApi( + (api) => api.addComment( + addCommentRequest: any(named: 'addCommentRequest'), + ), + ), + ); + + activityTest( + 'addComment - should handle CommentAddedEvent and update comments', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get(), + body: (tester) async { + // Initial state - no comments + expect(tester.activityState.comments, isEmpty); + + // Emit event + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ), + ); + + // Verify state has comment + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.id, commentId); + expect(tester.activityState.comments.first.text, 'Test comment'); + expect(tester.activityState.comments.first.user.id, userId); + }, + ); + + activityTest( + 'addComment - should handle both API call and event together', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get(), + body: (tester) async { + // Initial state - no comments + expect(tester.activityState.comments, isEmpty); + + // Mock API call that will be used + tester.mockApi( + (api) => api.addComment( + addCommentRequest: any(named: 'addCommentRequest'), + ), + result: createDefaultAddCommentResponse( + commentId: commentId, + objectId: activityId, + userId: userId, + text: 'Test comment', + ), + ); + + // Add comment via API + final result = await tester.activity.addComment( + request: const ActivityAddCommentRequest( + activityId: activityId, + comment: 'Test comment', + ), + ); + expect(result.isSuccess, isTrue); + + // Also emit event (simulating real-time update) + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ), + ); + + // Verify state has comment (should not duplicate) + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.id, commentId); + }, + ); + + activityTest( + 'addComment - should not update comments if objectId does not match', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get(), + body: (tester) async { + // Initial state - no comments + expect(tester.activityState.comments, isEmpty); + + // Emit CommentAddedEvent for different activity + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: + createDefaultActivityResponse(id: 'different-activity-id'), + comment: createDefaultCommentResponse( + id: commentId, + objectId: 'different-activity-id', + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ), + ); + + // Verify state was not updated + expect(tester.activityState.comments, isEmpty); + }, + ); + + activityTest( + 'deleteComment - should delete comment via API', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.id, commentId); + + // Mock API call that will be used + tester.mockApi( + (api) => api.deleteComment( + id: commentId, + hardDelete: any(named: 'hardDelete'), + ), + result: createDefaultDeleteCommentResponse( + commentId: commentId, + activityId: activityId, + objectId: activityId, + userId: userId, + ), + ); + + // Delete comment + final result = await tester.activity.deleteComment(commentId); + + expect(result.isSuccess, isTrue); + + // Note: deleteComment updates state automatically via onCommentRemoved + expect(tester.activityState.comments, isEmpty); + }, + verify: (tester) => tester.verifyApi( + (api) => api.deleteComment( + id: commentId, + hardDelete: any(named: 'hardDelete'), + ), + ), + ); + + activityTest( + 'deleteComment - should handle CommentDeletedEvent and update comments', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.id, commentId); + + // Emit event + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + ), + ); + + // Verify state has no comments + expect(tester.activityState.comments, isEmpty); + }, + ); + + activityTest( + 'deleteComment - should not update comments if objectId does not match', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.id, commentId); + + // Emit CommentDeletedEvent for different activity + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + comment: createDefaultCommentResponse( + id: 'different-comment-id', + objectId: 'different-activity-id', + objectType: 'activity', + userId: userId, + ), + ), + ); + + // Verify state was not updated + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.id, commentId); + }, + ); + + activityTest( + 'updateComment - should update comment via API', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Original comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.text, 'Original comment'); + + // Mock API call that will be used + tester.mockApi( + (api) => api.updateComment( + id: commentId, + updateCommentRequest: any(named: 'updateCommentRequest'), + ), + result: createDefaultUpdateCommentResponse( + commentId: commentId, + objectId: activityId, + text: 'Updated comment', + userId: userId, + ), + ); + + // Update comment + final result = await tester.activity.updateComment( + commentId, + const ActivityUpdateCommentRequest( + comment: 'Updated comment', + ), + ); + + expect(result.isSuccess, isTrue); + final updatedComment = result.getOrNull(); + expect(updatedComment, isNotNull); + expect(updatedComment!.text, 'Updated comment'); + + // Note: updateComment updates state automatically via onCommentUpdated + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.text, 'Updated comment'); + }, + verify: (tester) => tester.verifyApi( + (api) => api.updateComment( + id: commentId, + updateCommentRequest: any(named: 'updateCommentRequest'), + ), + ), + ); + + activityTest( + 'updateComment - should handle CommentUpdatedEvent and update comments', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Original comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.text, 'Original comment'); + + // Emit event + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Updated comment', + userId: userId, + ), + ), + ); + + // Verify state has updated comment + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.text, 'Updated comment'); + }, + ); + + activityTest( + 'updateComment - should not update comments if objectId does not match', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Original comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has comment + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.text, 'Original comment'); + + // Emit CommentUpdatedEvent for different activity + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + comment: createDefaultCommentResponse( + id: 'different-comment-id', + objectId: 'different-activity-id', + objectType: 'activity', + text: 'Updated comment', + userId: userId, + ), + ), + ); + + // Verify state was not updated + expect(tester.activityState.comments, hasLength(1)); + expect(tester.activityState.comments.first.text, 'Original comment'); + }, + ); + }); + + // ============================================================ + // FEATURE: Comment Reactions + // ============================================================ + + group('Comment Reactions', () { + const commentId = 'comment-test-1'; + const userId = 'luke_skywalker'; + const reactionType = 'heart'; + + setUpAll(() { + registerFallbackValue(const AddCommentReactionRequest(type: 'like')); + }); + + activityTest( + 'addCommentReaction - should add reaction to comment via API', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has no reactions + final initialComment = tester.activityState.comments.first; + expect(initialComment.id, commentId); + expect(initialComment.ownReactions, isEmpty); + + // Mock API call that will be used + tester.mockApi( + (api) => api.addCommentReaction( + id: commentId, + addCommentReactionRequest: any(named: 'addCommentReactionRequest'), + ), + result: createDefaultAddCommentReactionResponse( + commentId: commentId, + objectId: activityId, + userId: userId, + reactionType: reactionType, + ), + ); + + // Add reaction + final result = await tester.activity.addCommentReaction( + commentId: commentId, + request: const AddCommentReactionRequest(type: reactionType), + ); + + expect(result.isSuccess, isTrue); + final reaction = result.getOrNull(); + expect(reaction, isNotNull); + expect(reaction!.type, reactionType); + expect(reaction.user.id, userId); + + // Note: addCommentReaction updates state automatically via onCommentReactionAdded + final updatedComment = tester.activityState.comments.first; + expect(updatedComment.ownReactions, hasLength(1)); + expect(updatedComment.ownReactions.first.type, reactionType); + }, + verify: (tester) => tester.verifyApi( + (api) => api.addCommentReaction( + id: commentId, + addCommentReactionRequest: any(named: 'addCommentReactionRequest'), + ), + ), + ); + + activityTest( + 'addCommentReaction - should handle CommentReactionAddedEvent and update comment', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has no reactions + final initialComment = tester.activityState.comments.first; + expect(initialComment.ownReactions, isEmpty); + + // Emit event + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has reaction + final updatedComment = tester.activityState.comments.first; + expect(updatedComment.ownReactions, hasLength(1)); + expect(updatedComment.ownReactions.first.type, reactionType); + expect(updatedComment.ownReactions.first.user.id, userId); + }, + ); + + activityTest( + 'addCommentReaction - should handle both API call and event together', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has no reactions + final initialComment = tester.activityState.comments.first; + expect(initialComment.ownReactions, isEmpty); + + // Mock API call that will be used + tester.mockApi( + (api) => api.addCommentReaction( + id: commentId, + addCommentReactionRequest: any(named: 'addCommentReactionRequest'), + ), + result: createDefaultAddCommentReactionResponse( + commentId: commentId, + objectId: activityId, + userId: userId, + reactionType: reactionType, + ), + ); + + // Add reaction via API + final result = await tester.activity.addCommentReaction( + commentId: commentId, + request: const AddCommentReactionRequest(type: reactionType), + ); + expect(result.isSuccess, isTrue); + + // Also emit event (simulating real-time update) + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has reaction (should not duplicate) + final updatedComment = tester.activityState.comments.first; + expect(updatedComment.ownReactions, hasLength(1)); + expect(updatedComment.ownReactions.first.type, reactionType); + }, + ); + + activityTest( + 'addCommentReaction - should not update comment if objectId does not match', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has no reactions + final initialComment = tester.activityState.comments.first; + expect(initialComment.ownReactions, isEmpty); + + // Emit CommentReactionAddedEvent for different activity + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: + createDefaultActivityResponse(id: 'different-activity-id'), + comment: createDefaultCommentResponse( + id: 'different-comment-id', + objectId: 'different-activity-id', + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: 'different-activity-id', + commentId: 'different-comment-id', + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state was not updated + final comment = tester.activityState.comments.first; + expect(comment.ownReactions, isEmpty); + }, + ); + + activityTest( + 'deleteCommentReaction - should delete reaction from comment via API', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has reaction + final initialComment = tester.activityState.comments.first; + expect(initialComment.ownReactions, hasLength(1)); + expect(initialComment.ownReactions.first.type, reactionType); + + // Mock API call that will be used + tester.mockApi( + (api) => api.deleteCommentReaction( + id: commentId, + type: reactionType, + ), + result: createDefaultDeleteCommentReactionResponse( + commentId: commentId, + objectId: activityId, + userId: userId, + reactionType: reactionType, + ), + ); + + // Delete reaction + final result = await tester.activity.deleteCommentReaction( + commentId, + reactionType, + ); + + expect(result.isSuccess, isTrue); + + // Note: deleteCommentReaction updates state automatically via onCommentReactionRemoved + final updatedComment = tester.activityState.comments.first; + expect(updatedComment.ownReactions, isEmpty); + }, + verify: (tester) => tester.verifyApi( + (api) => api.deleteCommentReaction( + id: commentId, + type: reactionType, + ), + ), + ); + + activityTest( + 'deleteCommentReaction - should handle CommentReactionDeletedEvent and update comment', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has reaction + final initialComment = tester.activityState.comments.first; + expect(initialComment.ownReactions, hasLength(1)); + + // Emit event + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + comment: createDefaultCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has no reactions + final updatedComment = tester.activityState.comments.first; + expect(updatedComment.ownReactions, isEmpty); + }, + ); + + activityTest( + 'deleteCommentReaction - should not update comment if objectId does not match', + build: (client) => client.activity(activityId: activityId, fid: feedId), + setUp: (tester) => tester.get( + modifyCommentsResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: commentId, + objectId: activityId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: activityId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - comment has reaction + final initialComment = tester.activityState.comments.first; + expect(initialComment.ownReactions, hasLength(1)); + + // Emit CommentReactionDeletedEvent for different activity + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + comment: createDefaultCommentResponse( + id: 'different-comment-id', + objectId: 'different-activity-id', + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: 'different-activity-id', + commentId: 'different-comment-id', + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state was not updated + final comment = tester.activityState.comments.first; + expect(comment.ownReactions, hasLength(1)); + expect(comment.ownReactions.first.type, reactionType); + }, + ); + }); } diff --git a/packages/stream_feeds/test/state/comment_reply_list_test.dart b/packages/stream_feeds/test/state/comment_reply_list_test.dart new file mode 100644 index 00000000..f6756cd6 --- /dev/null +++ b/packages/stream_feeds/test/state/comment_reply_list_test.dart @@ -0,0 +1,1485 @@ +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart'; + +import '../test_utils.dart'; + +void main() { + const commentId = 'comment-1'; + const parentCommentId = 'parent-comment-1'; + const replyId = 'reply-test-1'; + const userId = 'luke_skywalker'; + const reactionType = 'like'; + + const query = CommentRepliesQuery( + commentId: parentCommentId, + ); + + // ============================================================ + // FEATURE: Comment Reply List - Query Operations + // ============================================================ + + group('Comment Reply List - Query Operations', () { + commentReplyListTest( + 'get - should query initial replies via API', + build: (client) => client.commentReplyList(query), + body: (tester) async { + final result = await tester.get(); + + expect(result, isA>>()); + final replies = result.getOrThrow(); + + expect(replies, isA>()); + expect(replies, hasLength(3)); + expect(replies[0].id, 'reply-1'); + expect(replies[1].id, 'reply-2'); + expect(replies[2].id, 'reply-3'); + }, + ); + + commentReplyListTest( + 'queryMoreReplies - should load more replies via API', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + next: 'next-cursor', + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reply + expect(tester.commentReplyListState.replies, hasLength(1)); + + final nextPageQuery = tester.commentReplyList.query.copyWith( + next: tester.commentReplyListState.pagination?.next, + ); + + tester.mockApi( + (api) => api.getCommentReplies( + id: nextPageQuery.commentId, + depth: nextPageQuery.depth, + limit: nextPageQuery.limit, + next: nextPageQuery.next, + prev: nextPageQuery.previous, + repliesLimit: nextPageQuery.repliesLimit, + sort: nextPageQuery.sort, + ), + result: createDefaultCommentRepliesResponse( + prev: 'prev-cursor', + comments: [ + createDefaultThreadedCommentResponse( + id: 'reply-test-2', + objectId: commentId, + objectType: 'activity', + text: 'Second reply', + userId: userId, + ), + ], + ), + ); + + // Query more replies + final result = await tester.commentReplyList.queryMoreReplies(); + + expect(result.isSuccess, isTrue); + final replies = result.getOrNull(); + expect(replies, isNotNull); + expect(replies, hasLength(1)); + + // Verify state was updated with merged replies + expect(tester.commentReplyListState.replies, hasLength(2)); + expect(tester.commentReplyListState.pagination?.next, isNull); + expect( + tester.commentReplyListState.pagination?.previous, + 'prev-cursor', + ); + }, + verify: (tester) { + final nextPageQuery = tester.commentReplyList.query.copyWith( + next: tester.commentReplyListState.pagination?.next, + ); + + tester.verifyApi( + (api) => api.getCommentReplies( + id: nextPageQuery.commentId, + depth: nextPageQuery.depth, + limit: nextPageQuery.limit, + next: nextPageQuery.next, + prev: nextPageQuery.previous, + repliesLimit: nextPageQuery.repliesLimit, + sort: nextPageQuery.sort, + ), + ); + }, + ); + + commentReplyListTest( + 'queryMoreReplies - should return empty list when no more replies', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reply but no pagination + expect(tester.commentReplyListState.replies, hasLength(1)); + expect(tester.commentReplyListState.pagination?.next, isNull); + expect(tester.commentReplyListState.pagination?.previous, isNull); + + // Query more replies (should return empty immediately) + final result = await tester.commentReplyList.queryMoreReplies(); + + expect(result.isSuccess, isTrue); + final replies = result.getOrNull(); + expect(replies, isEmpty); + + // Verify state was not updated (no new replies, pagination remains null) + expect(tester.commentReplyListState.replies, hasLength(1)); + expect(tester.commentReplyListState.pagination?.next, isNull); + expect(tester.commentReplyListState.pagination?.previous, isNull); + }, + ); + }); + + // ============================================================ + // FEATURE: Comment Reply List - Event Handling + // ============================================================ + + group('Comment Reply List - Event Handling', () { + commentReplyListTest( + 'should handle CommentAddedEvent and add reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(comments: const []), + ), + body: (tester) async { + // Initial state - no replies + expect(tester.commentReplyListState.replies, isEmpty); + + // Emit event + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ).copyWith(parentId: parentCommentId), + ), + ); + + // Verify state has reply + expect(tester.commentReplyListState.replies, hasLength(1)); + expect(tester.commentReplyListState.replies.first.id, replyId); + expect(tester.commentReplyListState.replies.first.text, 'Test reply'); + expect(tester.commentReplyListState.replies.first.user.id, userId); + }, + ); + + commentReplyListTest( + 'should handle CommentUpdatedEvent and update reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Original reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reply + expect(tester.commentReplyListState.replies, hasLength(1)); + expect( + tester.commentReplyListState.replies.first.text, + 'Original reply', + ); + + // Emit event + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Updated reply', + userId: userId, + parentId: parentCommentId, + ), + ), + ); + + // Verify state has updated reply + expect(tester.commentReplyListState.replies, hasLength(1)); + expect( + tester.commentReplyListState.replies.first.text, + 'Updated reply', + ); + }, + ); + + commentReplyListTest( + 'should handle CommentDeletedEvent and remove reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reply + expect(tester.commentReplyListState.replies, hasLength(1)); + expect(tester.commentReplyListState.replies.first.id, replyId); + + // Emit event + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + userId: userId, + parentId: parentCommentId, + ), + ), + ); + + // Verify state has no replies + expect(tester.commentReplyListState.replies, isEmpty); + }, + ); + + commentReplyListTest( + 'should not add reply if parentId does not match query commentId', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(comments: const []), + ), + body: (tester) async { + // Initial state - no replies + expect(tester.commentReplyListState.replies, isEmpty); + + // Emit CommentAddedEvent for different parent (not the query's commentId) + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ).copyWith(parentId: 'different-parent-id'), + ), + ); + + // Verify state was not updated (only handles replies to query's commentId) + expect(tester.commentReplyListState.replies, isEmpty); + }, + ); + + commentReplyListTest( + 'should skip top-level comments (only handles replies)', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith(comments: const []), + ), + body: (tester) async { + // Initial state - no replies + expect(tester.commentReplyListState.replies, isEmpty); + + // Emit CommentAddedEvent without parentId (not a reply) + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test comment', + userId: userId, + ), + ), + ); + + // Verify state was not updated (only replies are added, not top-level comments) + expect(tester.commentReplyListState.replies, isEmpty); + }, + ); + + commentReplyListTest( + 'should skip top-level comment updates (only handles replies)', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Original reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reply + expect(tester.commentReplyListState.replies, hasLength(1)); + expect( + tester.commentReplyListState.replies.first.text, + 'Original reply', + ); + + // Emit CommentUpdatedEvent without parentId (not a reply) + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Updated comment', + userId: userId, + ), + ), + ); + + // Verify state was not updated (only replies are updated, not top-level comments) + expect(tester.commentReplyListState.replies, hasLength(1)); + expect( + tester.commentReplyListState.replies.first.text, + 'Original reply', + ); + }, + ); + + commentReplyListTest( + 'should skip top-level comment deletions (only handles replies)', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reply + expect(tester.commentReplyListState.replies, hasLength(1)); + + // Emit CommentDeletedEvent without parentId (not a reply) + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + userId: userId, + ), + ), + ); + + // Verify state was not updated (only replies are deleted, not top-level comments) + expect(tester.commentReplyListState.replies, hasLength(1)); + }, + ); + + commentReplyListTest( + 'should handle CommentAddedEvent and add nested reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has top-level reply + expect(tester.commentReplyListState.replies, hasLength(1)); + final initialReply = tester.commentReplyListState.replies.first; + expect(initialReply.id, replyId); + expect(initialReply.replies, isNull); + expect(initialReply.replyCount, 0); + + // Emit event for nested reply (parentId matches existing reply's ID) + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Nested reply', + userId: userId, + ).copyWith(parentId: replyId), + ), + ); + + // Verify nested reply was added + expect(tester.commentReplyListState.replies, hasLength(1)); + final topLevelReply = tester.commentReplyListState.replies.first; + expect(topLevelReply.id, replyId); + expect(topLevelReply.replies, isNotNull); + expect(topLevelReply.replies, hasLength(1)); + expect(topLevelReply.replies!.first.id, 'nested-reply-1'); + expect(topLevelReply.replies!.first.text, 'Nested reply'); + expect(topLevelReply.replyCount, 1); + }, + ); + + commentReplyListTest( + 'should handle CommentUpdatedEvent and update nested reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Original nested reply', + userId: userId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has nested reply + final topLevelReply = tester.commentReplyListState.replies.first; + expect(topLevelReply.replies, hasLength(1)); + expect(topLevelReply.replies!.first.text, 'Original nested reply'); + + // Emit event to update nested reply + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Updated nested reply', + userId: userId, + parentId: replyId, + ), + ), + ); + + // Verify nested reply was updated + final updatedTopLevelReply = tester.commentReplyListState.replies.first; + expect(updatedTopLevelReply.replies, hasLength(1)); + expect( + updatedTopLevelReply.replies!.first.text, + 'Updated nested reply', + ); + }, + ); + + commentReplyListTest( + 'should handle CommentDeletedEvent and remove nested reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Nested reply', + userId: userId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has nested reply + final topLevelReply = tester.commentReplyListState.replies.first; + expect(topLevelReply.replies, hasLength(1)); + expect(topLevelReply.replies!.first.id, 'nested-reply-1'); + expect(topLevelReply.replyCount, 1); + + // Emit event to delete nested reply + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + userId: userId, + parentId: replyId, + ), + ), + ); + + // Verify nested reply was removed + final updatedTopLevelReply = tester.commentReplyListState.replies.first; + expect(updatedTopLevelReply.replies, isEmpty); + expect(updatedTopLevelReply.replyCount, 0); + // Top-level reply should still exist + expect(tester.commentReplyListState.replies, hasLength(1)); + expect(tester.commentReplyListState.replies.first.id, replyId); + }, + ); + + commentReplyListTest( + 'should not add nested reply if parentId does not match any existing reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has top-level reply + expect(tester.commentReplyListState.replies, hasLength(1)); + final topLevelReply = tester.commentReplyListState.replies.first; + expect(topLevelReply.replies, isNull); + + // Emit event for nested reply with parentId that doesn't match any existing reply + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Nested reply', + userId: userId, + ).copyWith(parentId: 'non-existent-reply-id'), + ), + ); + + // Verify nested reply was not added (parentId doesn't match parentCommentId or any existing reply) + expect(tester.commentReplyListState.replies, hasLength(1)); + final updatedTopLevelReply = tester.commentReplyListState.replies.first; + expect(updatedTopLevelReply.replies, isNull); + }, + ); + + commentReplyListTest( + 'should handle CommentAddedEvent and add deep nested reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Second-level reply', + userId: userId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has nested reply + expect(tester.commentReplyListState.replies, hasLength(1)); + final topLevelReply = tester.commentReplyListState.replies.first; + expect(topLevelReply.id, replyId); + expect(topLevelReply.replies, hasLength(1)); + expect(topLevelReply.replies!.first.id, 'nested-reply-1'); + expect(topLevelReply.replies!.first.replies, isNull); + expect(topLevelReply.replyCount, 1); + expect(topLevelReply.replies!.first.replyCount, 0); + + // Emit event for deep nested reply (reply to nested-reply-1) + await tester.emitEvent( + CommentAddedEvent( + type: EventTypes.commentAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: 'deep-nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Deep nested reply', + userId: userId, + ).copyWith(parentId: 'nested-reply-1'), + ), + ); + + // Verify deep nested reply was added + expect(tester.commentReplyListState.replies, hasLength(1)); + final updatedTopLevelReply = tester.commentReplyListState.replies.first; + expect(updatedTopLevelReply.id, replyId); + expect(updatedTopLevelReply.replies, hasLength(1)); + expect(updatedTopLevelReply.replyCount, 1); + + final secondLevelReply = updatedTopLevelReply.replies!.first; + expect(secondLevelReply.id, 'nested-reply-1'); + expect(secondLevelReply.replies, isNotNull); + expect(secondLevelReply.replies, hasLength(1)); + expect(secondLevelReply.replyCount, 1); + + final deepNestedReply = secondLevelReply.replies!.first; + expect(deepNestedReply.id, 'deep-nested-reply-1'); + expect(deepNestedReply.text, 'Deep nested reply'); + }, + ); + + commentReplyListTest( + 'should handle CommentUpdatedEvent and update deep nested reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Second-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'deep-nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Original deep nested reply', + userId: userId, + ), + ], + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has deep nested reply + final topLevelReply = tester.commentReplyListState.replies.first; + final secondLevelReply = topLevelReply.replies!.first; + expect(secondLevelReply.replies, hasLength(1)); + expect( + secondLevelReply.replies!.first.text, + 'Original deep nested reply', + ); + + // Emit event to update deep nested reply + await tester.emitEvent( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'deep-nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Updated deep nested reply', + userId: userId, + parentId: 'nested-reply-1', + ), + ), + ); + + // Verify deep nested reply was updated + final updatedTopLevelReply = tester.commentReplyListState.replies.first; + final updatedSecondLevelReply = updatedTopLevelReply.replies!.first; + expect(updatedSecondLevelReply.replies, hasLength(1)); + expect( + updatedSecondLevelReply.replies!.first.text, + 'Updated deep nested reply', + ); + }, + ); + + commentReplyListTest( + 'should handle CommentDeletedEvent and remove deep nested reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Second-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'deep-nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Deep nested reply', + userId: userId, + ), + ], + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has deep nested reply + final topLevelReply = tester.commentReplyListState.replies.first; + final secondLevelReply = topLevelReply.replies!.first; + expect(secondLevelReply.replies, hasLength(1)); + expect(secondLevelReply.replies!.first.id, 'deep-nested-reply-1'); + expect(secondLevelReply.replyCount, 1); + + // Emit event to delete deep nested reply + await tester.emitEvent( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'deep-nested-reply-1', + objectId: commentId, + objectType: 'activity', + userId: userId, + parentId: 'nested-reply-1', + ), + ), + ); + + // Verify deep nested reply was removed + final updatedTopLevelReply = tester.commentReplyListState.replies.first; + final updatedSecondLevelReply = updatedTopLevelReply.replies!.first; + expect(updatedSecondLevelReply.replies, isEmpty); + expect(updatedSecondLevelReply.replyCount, 0); + // Second-level reply should still exist + expect(updatedTopLevelReply.replies, hasLength(1)); + expect(updatedTopLevelReply.replies!.first.id, 'nested-reply-1'); + }, + ); + }); + + // ============================================================ + // FEATURE: Comment Reply List - Reply Reactions + // ============================================================ + + group('Comment Reply List - Reply Reactions', () { + commentReplyListTest( + 'should handle CommentReactionAddedEvent and update reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - no reactions + final initialReply = tester.commentReplyListState.replies.first; + expect(initialReply.ownReactions, isEmpty); + + // Emit event + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + userId: userId, + parentId: parentCommentId, + ), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has reaction + final updatedReply = tester.commentReplyListState.replies.first; + expect(updatedReply.ownReactions, hasLength(1)); + expect(updatedReply.ownReactions.first.type, reactionType); + }, + ); + + commentReplyListTest( + 'should handle CommentReactionDeletedEvent and remove reaction', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + final initialReply = tester.commentReplyListState.replies.first; + expect(initialReply.ownReactions, hasLength(1)); + + // Emit event + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + userId: userId, + parentId: parentCommentId, + ), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has no reactions + final updatedReply = tester.commentReplyListState.replies.first; + expect(updatedReply.ownReactions, isEmpty); + }, + ); + + commentReplyListTest( + 'should handle CommentReactionUpdatedEvent and update reaction', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has 'like' reaction + final initialReply = tester.commentReplyListState.replies.first; + expect(initialReply.ownReactions, hasLength(1)); + expect(initialReply.ownReactions.first.type, reactionType); + + // Emit event to update reaction to 'fire' + await tester.emitEvent( + CommentReactionUpdatedEvent( + type: EventTypes.commentReactionUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + userId: userId, + parentId: parentCommentId, + ), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: 'fire', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state has updated reaction (old reaction replaced) + final updatedReply = tester.commentReplyListState.replies.first; + expect(updatedReply.ownReactions, hasLength(1)); + expect(updatedReply.ownReactions.first.type, 'fire'); + }, + ); + + commentReplyListTest( + 'should handle CommentReactionAddedEvent and update nested reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Nested reply', + userId: userId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - nested reply has no reactions + final topLevelReply = tester.commentReplyListState.replies.first; + expect(topLevelReply.replies, hasLength(1)); + expect(topLevelReply.replies!.first.ownReactions, isEmpty); + + // Emit event to add reaction to nested reply + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + userId: userId, + parentId: replyId, + ), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + commentId: 'nested-reply-1', + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify nested reply has reaction + final updatedTopLevelReply = tester.commentReplyListState.replies.first; + expect(updatedTopLevelReply.replies, hasLength(1)); + expect(updatedTopLevelReply.replies!.first.ownReactions, hasLength(1)); + expect( + updatedTopLevelReply.replies!.first.ownReactions.first.type, + reactionType, + ); + }, + ); + + commentReplyListTest( + 'should handle CommentReactionAddedEvent and update deep nested reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Second-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'deep-nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Deep nested reply', + userId: userId, + ), + ], + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - deep nested reply has no reactions + final topLevelReply = tester.commentReplyListState.replies.first; + final secondLevelReply = topLevelReply.replies!.first; + expect(secondLevelReply.replies, hasLength(1)); + expect(secondLevelReply.replies!.first.ownReactions, isEmpty); + + // Emit event to add reaction to deep nested reply + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: 'deep-nested-reply-1', + objectId: commentId, + objectType: 'activity', + userId: userId, + parentId: 'nested-reply-1', + ), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + commentId: 'deep-nested-reply-1', + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify deep nested reply has reaction + final updatedTopLevelReply = tester.commentReplyListState.replies.first; + final updatedSecondLevelReply = updatedTopLevelReply.replies!.first; + expect(updatedSecondLevelReply.replies, hasLength(1)); + expect( + updatedSecondLevelReply.replies!.first.ownReactions, + hasLength(1), + ); + expect( + updatedSecondLevelReply.replies!.first.ownReactions.first.type, + reactionType, + ); + }, + ); + + commentReplyListTest( + 'should handle CommentReactionDeletedEvent and remove reaction from deep nested reply', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Top-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Second-level reply', + userId: userId, + replies: [ + createDefaultThreadedCommentResponse( + id: 'deep-nested-reply-1', + objectId: commentId, + objectType: 'activity', + text: 'Deep nested reply', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: 'activity-1', + commentId: 'deep-nested-reply-1', + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - deep nested reply has reaction + final topLevelReply = tester.commentReplyListState.replies.first; + final secondLevelReply = topLevelReply.replies!.first; + expect(secondLevelReply.replies, hasLength(1)); + expect(secondLevelReply.replies!.first.ownReactions, hasLength(1)); + + // Emit event to remove reaction from deep nested reply + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: 'deep-nested-reply-1', + objectId: commentId, + objectType: 'activity', + userId: userId, + parentId: 'nested-reply-1', + ), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + commentId: 'deep-nested-reply-1', + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify deep nested reply has no reactions + final updatedTopLevelReply = tester.commentReplyListState.replies.first; + final updatedSecondLevelReply = updatedTopLevelReply.replies!.first; + expect(updatedSecondLevelReply.replies, hasLength(1)); + expect(updatedSecondLevelReply.replies!.first.ownReactions, isEmpty); + }, + ); + + commentReplyListTest( + 'should skip reaction additions for top-level comments (only handles replies)', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ), + ], + ), + ), + body: (tester) async { + // Initial state - no reactions + final initialReply = tester.commentReplyListState.replies.first; + expect(initialReply.ownReactions, isEmpty); + + // Emit CommentReactionAddedEvent without parentId (not a reply) + await tester.emitEvent( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state was not updated (only replies get reactions, not top-level comments) + final updatedReply = tester.commentReplyListState.replies.first; + expect(updatedReply.ownReactions, isEmpty); + }, + ); + + commentReplyListTest( + 'should skip reaction updates for top-level comments (only handles replies)', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + final initialReply = tester.commentReplyListState.replies.first; + expect(initialReply.ownReactions, hasLength(1)); + expect(initialReply.ownReactions.first.type, reactionType); + + // Emit CommentReactionUpdatedEvent without parentId (not a reply) + await tester.emitEvent( + CommentReactionUpdatedEvent( + type: EventTypes.commentReactionUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + activity: createDefaultActivityResponse(id: 'activity-1'), + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: 'fire', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state was not updated (only replies get reactions updated, not top-level comments) + final updatedReply = tester.commentReplyListState.replies.first; + expect(updatedReply.ownReactions, hasLength(1)); + expect(updatedReply.ownReactions.first.type, reactionType); + }, + ); + + commentReplyListTest( + 'should skip reaction deletions for top-level comments (only handles replies)', + build: (client) => client.commentReplyList(query), + setUp: (tester) => tester.get( + modifyResponse: (response) => response.copyWith( + comments: [ + createDefaultThreadedCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + text: 'Test reply', + userId: userId, + ownReactions: [ + FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + final initialReply = tester.commentReplyListState.replies.first; + expect(initialReply.ownReactions, hasLength(1)); + + // Emit CommentReactionDeletedEvent without parentId (not a reply) + await tester.emitEvent( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: 'user:john', + comment: createDefaultCommentResponse( + id: replyId, + objectId: commentId, + objectType: 'activity', + userId: userId, + ), + reaction: FeedsReactionResponse( + activityId: 'activity-1', + commentId: replyId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state was not updated (only replies get reactions deleted, not top-level comments) + final updatedReply = tester.commentReplyListState.replies.first; + expect(updatedReply.ownReactions, hasLength(1)); + }, + ); + }); +} diff --git a/packages/stream_feeds/test/state/feed_test.dart b/packages/stream_feeds/test/state/feed_test.dart index 3a49ca5c..98491a04 100644 --- a/packages/stream_feeds/test/state/feed_test.dart +++ b/packages/stream_feeds/test/state/feed_test.dart @@ -666,92 +666,6 @@ void main() { }, ); - feedTest( - 'BookmarkAddedEvent - should remove activity when bookmark causes filter mismatch', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.in_( - ActivitiesFilterField.filterTags, - ['important'], - ), - ), - ), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: initialActivities, - pinnedActivities: initialPinnedActivities, - ), - ), - body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); - - // Send BookmarkAddedEvent with activity that doesn't have 'important' tag - await tester.emitEvent( - BookmarkAddedEvent( - type: EventTypes.bookmarkAdded, - createdAt: DateTime.timestamp(), - custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: 'activity-1', - ).copyWith( - activity: createDefaultActivityResponse( - id: 'activity-1', - ).copyWith( - feeds: [feedId.rawValue], // Activity belongs to this feed - filterTags: ['general'], // Doesn't have 'important' tag - ), - ), - ), - ); - - expect(tester.feedState.activities, hasLength(2)); - }, - ); - - feedTest( - 'BookmarkDeletedEvent - should remove activity when bookmark deletion causes filter mismatch', - build: (client) => client.feedFromQuery( - FeedQuery( - fid: feedId, - activityFilter: Filter.in_( - ActivitiesFilterField.filterTags, - ['important'], - ), - ), - ), - setUp: (tester) => tester.getOrCreate( - modifyResponse: (it) => it.copyWith( - activities: initialActivities, - pinnedActivities: initialPinnedActivities, - ), - ), - body: (tester) async { - expect(tester.feedState.activities, hasLength(3)); - - // Send BookmarkDeletedEvent with activity that doesn't have 'important' tag - await tester.emitEvent( - BookmarkDeletedEvent( - type: EventTypes.bookmarkDeleted, - createdAt: DateTime.timestamp(), - custom: const {}, - bookmark: createDefaultBookmarkResponse( - activityId: 'activity-2', - ).copyWith( - activity: createDefaultActivityResponse( - id: 'activity-2', - feeds: [feedId.rawValue], // Activity belongs to this feed - ).copyWith( - filterTags: ['general'], // Doesn't have 'important' tag - ), - ), - ), - ); - - expect(tester.feedState.activities, hasLength(2)); - }, - ); - feedTest( 'Complex filter with AND - should filter correctly', build: (client) => client.feedFromQuery( @@ -1035,4 +949,842 @@ void main() { }, ); }); + + // ============================================================ + // FEATURE: Bookmarks + // ============================================================ + + group('Bookmarks', () { + const feedId = FeedId(group: 'user', id: 'john'); + const activityId = 'activity-1'; + const userId = 'luke_skywalker'; + + feedTest( + 'addBookmark - should add bookmark to activity via API', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Mock API call that will be used + tester.mockApi( + (api) => api.addBookmark( + activityId: activityId, + addBookmarkRequest: any(named: 'addBookmarkRequest'), + ), + result: createDefaultAddBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ); + + // Initial state - no bookmarks + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.id, activityId); + expect(initialActivity.ownBookmarks, isEmpty); + + // Add bookmark + final result = await tester.feed.addBookmark(activityId: activityId); + + expect(result, isA>()); + final bookmark = result.getOrThrow(); + expect(bookmark.activity.id, activityId); + expect(bookmark.user.id, userId); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, hasLength(1)); + expect(updatedActivity.ownBookmarks.first.id, bookmark.id); + expect(updatedActivity.ownBookmarks.first.user.id, userId); + }, + verify: (tester) => tester.verifyApi( + (api) => api.addBookmark( + activityId: activityId, + addBookmarkRequest: any(named: 'addBookmarkRequest'), + ), + ), + ); + + feedTest( + 'addBookmark - should handle BookmarkAddedEvent and update activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no bookmarks + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, isEmpty); + + // Emit BookmarkAddedEvent + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ), + ); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, hasLength(1)); + expect(updatedActivity.ownBookmarks.first.user.id, userId); + expect(updatedActivity.ownBookmarks.first.activity.id, activityId); + }, + ); + + feedTest( + 'addBookmark - should handle both API call and event together', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no bookmarks + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, isEmpty); + + // Mock API call that will be used + tester.mockApi( + (api) => api.addBookmark( + activityId: activityId, + addBookmarkRequest: any(named: 'addBookmarkRequest'), + ), + result: createDefaultAddBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ); + + // Add bookmark via API + final result = await tester.feed.addBookmark(activityId: activityId); + expect(result.isSuccess, isTrue); + + // Also emit event (simulating real-time update) + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ), + ); + + // Verify state has bookmark (should not duplicate) + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, hasLength(1)); + }, + ); + + feedTest( + 'addBookmark - should not update activity if activity ID does not match', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no bookmarks + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, isEmpty); + + // Emit BookmarkAddedEvent for different activity + await tester.emitEvent( + BookmarkAddedEvent( + type: EventTypes.bookmarkAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: 'different-activity-id', + ), + ), + ); + + // Verify state was not updated + final activity = tester.feedState.activities.first; + expect(activity.ownBookmarks, isEmpty); + }, + ); + + feedTest( + 'updateBookmark - should update bookmark via API', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ownBookmarks: [ + createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + folderId: 'folder-id', + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); + expect(initialActivity.ownBookmarks.first.folder?.id, 'folder-id'); + + // Mock API call that will be used + tester.mockApi( + (api) => api.updateBookmark( + activityId: activityId, + updateBookmarkRequest: any(named: 'updateBookmarkRequest'), + ), + result: createDefaultUpdateBookmarkResponse( + userId: userId, + activityId: activityId, + folderId: 'new-folder-id', + ), + ); + + final result = await tester.feed.updateBookmark( + activityId: activityId, + request: const UpdateBookmarkRequest(folderId: 'new-folder-id'), + ); + + expect(result, isA>()); + final bookmark = result.getOrThrow(); + expect(bookmark.activity.id, activityId); + expect(bookmark.folder?.id, 'new-folder-id'); + }, + verify: (tester) => tester.verifyApi( + (api) => api.updateBookmark( + activityId: activityId, + updateBookmarkRequest: any(named: 'updateBookmarkRequest'), + ), + ), + ); + + // Note: BookmarkUpdatedEvent is not currently handled in FeedEventHandler + // It's only handled in BookmarkListEventHandler for bookmark list state + // This test is skipped until BookmarkUpdatedEvent handling is added to FeedEventHandler + + feedTest( + 'deleteBookmark - should delete bookmark via API', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ownBookmarks: [ + createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); + + // Mock API call that will be used + tester.mockApi( + (api) => api.deleteBookmark( + activityId: activityId, + folderId: any(named: 'folderId'), + ), + result: createDefaultDeleteBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ); + + // Delete bookmark + final result = await tester.feed.deleteBookmark(activityId: activityId); + + expect(result, isA>()); + final bookmark = result.getOrThrow(); + expect(bookmark.activity.id, activityId); + expect(bookmark.user.id, userId); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, isEmpty); + }, + verify: (tester) => tester.verifyApi( + (api) => api.deleteBookmark( + activityId: activityId, + folderId: any(named: 'folderId'), + ), + ), + ); + + feedTest( + 'deleteBookmark - should handle BookmarkDeletedEvent and update activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ownBookmarks: [ + createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); + + // Emit BookmarkDeletedEvent + await tester.emitEvent( + BookmarkDeletedEvent( + type: EventTypes.bookmarkDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ), + ); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, isEmpty); + }, + ); + + feedTest( + 'deleteBookmark - should handle both API call and event together', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ownBookmarks: [ + createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ], + ), + ], + ), + ), + body: (tester) async { + // Initial state - has bookmark + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownBookmarks, hasLength(1)); + + // Mock API call that will be used + tester.mockApi( + (api) => api.deleteBookmark( + activityId: activityId, + folderId: any(named: 'folderId'), + ), + result: createDefaultDeleteBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ); + + // Delete bookmark via API + final result = await tester.feed.deleteBookmark(activityId: activityId); + expect(result.isSuccess, isTrue); + + // Also emit event (simulating real-time update) + await tester.emitEvent( + BookmarkDeletedEvent( + type: EventTypes.bookmarkDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + ), + ), + ); + + // Verify state has no bookmarks + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownBookmarks, isEmpty); + }, + ); + }); + + // ============================================================ + // FEATURE: Reactions + // ============================================================ + + group('Reactions', () { + const feedId = FeedId(group: 'user', id: 'john'); + const activityId = 'activity-1'; + const userId = 'luke_skywalker'; + + setUpAll(() { + registerFallbackValue(const AddReactionRequest(type: 'like')); + }); + + feedTest( + 'addActivityReaction - should add reaction to activity via API', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Mock API call that will be used + tester.mockApi( + (api) => api.addActivityReaction( + activityId: activityId, + addReactionRequest: any(named: 'addReactionRequest'), + ), + result: createDefaultAddReactionResponse( + activityId: activityId, + userId: userId, + reactionType: 'heart', + ), + ); + + // Initial state - no reactions + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, isEmpty); + + // Add reaction + final result = await tester.feed.addActivityReaction( + activityId: activityId, + request: const AddReactionRequest(type: 'heart'), + ); + + expect(result.isSuccess, isTrue); + final reaction = result.getOrThrow(); + expect(reaction.activityId, activityId); + expect(reaction.type, 'heart'); + expect(reaction.user.id, userId); + }, + verify: (tester) => tester.verifyApi( + (api) => api.addActivityReaction( + activityId: activityId, + addReactionRequest: any(named: 'addReactionRequest'), + ), + ), + ); + + feedTest( + 'addActivityReaction - should handle ActivityReactionAddedEvent and update activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no reactions + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, isEmpty); + + // Emit ActivityReactionAddedEvent + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: activityId).copyWith( + reactionGroups: { + 'heart': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + }, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + type: 'heart', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownReactions, hasLength(1)); + expect(updatedActivity.ownReactions.first.type, 'heart'); + expect(updatedActivity.ownReactions.first.user.id, userId); + expect(updatedActivity.reactionGroups['heart']?.count ?? 0, 1); + }, + ); + + feedTest( + 'deleteActivityReaction - should delete reaction via API', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ownReactions: [ + FeedsReactionResponse( + activityId: activityId, + type: 'heart', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + reactionGroups: { + 'heart': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + }, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, hasLength(1)); + + // Mock API call that will be used + tester.mockApi( + (api) => api.deleteActivityReaction( + activityId: activityId, + type: 'heart', + ), + result: createDefaultDeleteReactionResponse( + activityId: activityId, + userId: userId, + reactionType: 'heart', + ), + ); + + // Delete reaction + final result = await tester.feed.deleteActivityReaction( + activityId: activityId, + type: 'heart', + ); + + expect(result.isSuccess, isTrue); + final reaction = result.getOrThrow(); + expect(reaction.activityId, activityId); + expect(reaction.type, 'heart'); + expect(reaction.user.id, userId); + + // Note: deleteActivityReaction doesn't update state automatically + // State is only updated via events (ActivityReactionDeletedEvent) + }, + verify: (tester) => tester.verifyApi( + (api) => api.deleteActivityReaction( + activityId: activityId, + type: 'heart', + ), + ), + ); + + feedTest( + 'deleteActivityReaction - should handle ActivityReactionDeletedEvent and update activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ownReactions: [ + FeedsReactionResponse( + activityId: activityId, + type: 'heart', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + reactionGroups: { + 'heart': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + }, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has reaction + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, hasLength(1)); + + // Emit ActivityReactionDeletedEvent + await tester.emitEvent( + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: activityId).copyWith( + reactionGroups: const {}, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + type: 'heart', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + // Verify state was updated + final updatedActivity = tester.feedState.activities.first; + expect(updatedActivity.ownReactions, isEmpty); + }, + ); + + feedTest( + 'should handle multiple reaction types (heart and fire) on same activity', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ), + ], + ), + ), + body: (tester) async { + // Initial state - no reactions + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions, isEmpty); + expect(initialActivity.reactionGroups, isEmpty); + + // Add heart reaction via event + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: activityId).copyWith( + reactionGroups: { + 'heart': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + }, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + type: 'heart', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + final activityAfterHeart = tester.feedState.activities.first; + expect( + activityAfterHeart.ownReactions.any((r) => r.type == 'heart'), + isTrue, + ); + expect(activityAfterHeart.reactionGroups['heart']?.count ?? 0, 1); + + // Add fire reaction via event (should coexist with heart) + await tester.emitEvent( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: activityId).copyWith( + reactionGroups: { + 'heart': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + 'fire': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + }, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + type: 'fire', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + final activityAfterFire = tester.feedState.activities.first; + expect( + activityAfterFire.ownReactions.any((r) => r.type == 'heart'), + isTrue, + ); + expect( + activityAfterFire.ownReactions.any((r) => r.type == 'fire'), + isTrue, + ); + expect(activityAfterFire.reactionGroups['heart']?.count ?? 0, 1); + expect(activityAfterFire.reactionGroups['fire']?.count ?? 0, 1); + }, + ); + + feedTest( + 'should handle removing one reaction type while keeping another', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: activityId, + feeds: [feedId.rawValue], + ownReactions: [ + FeedsReactionResponse( + activityId: activityId, + type: 'heart', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + FeedsReactionResponse( + activityId: activityId, + type: 'fire', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ], + reactionGroups: { + 'heart': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + 'fire': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + }, + ), + ], + ), + ), + body: (tester) async { + // Initial state - has both reactions + final initialActivity = tester.feedState.activities.first; + expect(initialActivity.ownReactions.length, 2); + expect(initialActivity.reactionGroups['heart']?.count ?? 0, 1); + expect(initialActivity.reactionGroups['fire']?.count ?? 0, 1); + + // Delete heart reaction via event + await tester.emitEvent( + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + activity: createDefaultActivityResponse(id: activityId).copyWith( + reactionGroups: { + 'fire': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime.timestamp(), + lastReactionAt: DateTime.timestamp(), + ), + }, + ), + reaction: FeedsReactionResponse( + activityId: activityId, + type: 'heart', + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ), + ); + + final activityAfterDelete = tester.feedState.activities.first; + expect(activityAfterDelete.ownReactions.length, 1); + expect(activityAfterDelete.ownReactions.first.type, 'fire'); + expect(activityAfterDelete.reactionGroups['heart']?.count ?? 0, 0); + expect(activityAfterDelete.reactionGroups['fire']?.count ?? 0, 1); + }, + ); + }); } diff --git a/packages/stream_feeds/test/test_utils.dart b/packages/stream_feeds/test/test_utils.dart index c3c35eff..5d30c9b8 100644 --- a/packages/stream_feeds/test/test_utils.dart +++ b/packages/stream_feeds/test/test_utils.dart @@ -1,11 +1,13 @@ export 'test_utils/event_types.dart'; export 'test_utils/fakes.dart'; export 'test_utils/mocks.dart'; +export 'test_utils/testers/activity_comment_list_tester.dart'; export 'test_utils/testers/activity_list_tester.dart'; export 'test_utils/testers/activity_tester.dart'; export 'test_utils/testers/bookmark_folder_list_tester.dart'; export 'test_utils/testers/bookmark_list_tester.dart'; export 'test_utils/testers/comment_list_tester.dart'; +export 'test_utils/testers/comment_reply_list_tester.dart'; export 'test_utils/testers/feed_list_tester.dart'; export 'test_utils/testers/feed_tester.dart'; export 'test_utils/testers/follow_list_tester.dart'; diff --git a/packages/stream_feeds/test/test_utils/event_types.dart b/packages/stream_feeds/test/test_utils/event_types.dart index 9f487a07..f7130588 100644 --- a/packages/stream_feeds/test/test_utils/event_types.dart +++ b/packages/stream_feeds/test/test_utils/event_types.dart @@ -21,7 +21,11 @@ class EventTypes { // Comment events static const commentAdded = 'feeds.comment.added'; + static const commentDeleted = 'feeds.comment.deleted'; static const commentUpdated = 'feeds.comment.updated'; + static const commentReactionAdded = 'feeds.comment.reaction.added'; + static const commentReactionDeleted = 'feeds.comment.reaction.deleted'; + static const commentReactionUpdated = 'feeds.comment.reaction.updated'; // Feed events static const feedUpdated = 'feeds.feed.updated'; diff --git a/packages/stream_feeds/test/test_utils/fakes.dart b/packages/stream_feeds/test/test_utils/fakes.dart index a7045f02..286bebdd 100644 --- a/packages/stream_feeds/test/test_utils/fakes.dart +++ b/packages/stream_feeds/test/test_utils/fakes.dart @@ -2,12 +2,42 @@ import 'package:stream_feeds/stream_feeds.dart'; -GetCommentsResponse createDefaultCommentsResponse() { - return const GetCommentsResponse( - comments: [], - next: null, - prev: null, - duration: 'duration', +GetCommentsResponse createDefaultCommentsResponse({ + String? next, + String? prev, + List comments = const [], +}) { + return GetCommentsResponse( + next: next, + prev: prev, + comments: comments, + duration: '10ms', + ); +} + +GetCommentRepliesResponse createDefaultCommentRepliesResponse({ + String? next, + String? prev, + List comments = const [], +}) { + return GetCommentRepliesResponse( + next: next, + prev: prev, + comments: comments, + duration: '10ms', + ); +} + +QueryActivitiesResponse createDefaultQueryActivitiesResponse({ + String? next, + String? prev, + List activities = const [], +}) { + return QueryActivitiesResponse( + next: next, + prev: prev, + activities: activities, + duration: '10ms', ); } @@ -57,14 +87,18 @@ ActivityResponse createDefaultActivityResponse({ PollResponseData? poll, bool hidden = false, bool? isWatched, + List ownBookmarks = const [], + List ownReactions = const [], + Map reactionGroups = const {}, + List comments = const [], }) { return ActivityResponse( id: id, attachments: const [], - bookmarkCount: 0, + bookmarkCount: ownBookmarks.length, collections: const {}, - commentCount: 0, - comments: const [], + commentCount: comments.length, + comments: comments, createdAt: DateTime(2021, 1, 1), custom: const {}, feeds: feeds, @@ -75,14 +109,14 @@ ActivityResponse createDefaultActivityResponse({ mentionedUsers: const [], moderation: null, notificationContext: null, - ownBookmarks: const [], - ownReactions: const [], + ownBookmarks: ownBookmarks, + ownReactions: ownReactions, parent: null, poll: poll, popularity: 0, preview: false, - reactionCount: 0, - reactionGroups: const {}, + reactionCount: reactionGroups.values.sumOf((group) => group.count), + reactionGroups: reactionGroups, restrictReplies: 'everyone', score: 0, searchData: const {}, @@ -123,9 +157,7 @@ PollResponseData createDefaultPollResponse({ latestVotesByOption: latestVotesByOption, ownVotes: const [], updatedAt: DateTime.now(), - voteCount: latestVotesByOption.values - .map((e) => e.length) - .fold(0, (v, e) => v + e), + voteCount: latestVotesByOption.values.sumOf((it) => it.length), voteCountsByOption: latestVotesByOption.map( (k, e) => MapEntry(k, e.length), ), @@ -199,6 +231,8 @@ CommentResponse createDefaultCommentResponse({ required String objectId, String objectType = 'post', String? text, + String? userId, + String? parentId, }) { return CommentResponse( id: id, @@ -210,6 +244,7 @@ CommentResponse createDefaultCommentResponse({ objectId: objectId, objectType: objectType, ownReactions: const [], + parentId: parentId, reactionCount: 0, replyCount: 0, score: 0, @@ -217,7 +252,147 @@ CommentResponse createDefaultCommentResponse({ text: text, updatedAt: DateTime(2021, 2, 1), upvoteCount: 0, - user: createDefaultUserResponse(), + user: createDefaultUserResponse(id: userId ?? 'user-1'), + ); +} + +ThreadedCommentResponse createDefaultThreadedCommentResponse({ + String id = 'id', + required String objectId, + String objectType = 'post', + String? text, + String? userId, + List? ownReactions, + List replies = const [], +}) { + return ThreadedCommentResponse( + id: id, + confidenceScore: 0, + createdAt: DateTime(2021, 1, 1), + custom: const {}, + downvoteCount: 0, + mentionedUsers: const [], + objectId: objectId, + objectType: objectType, + ownReactions: ownReactions ?? const [], + reactionCount: 0, + replyCount: replies.length, + replies: replies.isEmpty ? null : replies, + score: 0, + status: 'status', + text: text, + updatedAt: DateTime(2021, 2, 1), + upvoteCount: 0, + user: createDefaultUserResponse(id: userId ?? 'user-1'), + ); +} + +AddCommentResponse createDefaultAddCommentResponse({ + String commentId = 'comment-1', + required String objectId, + String objectType = 'activity', + String? text, + String? userId, +}) { + return AddCommentResponse( + comment: createDefaultCommentResponse( + id: commentId, + objectId: objectId, + objectType: objectType, + text: text, + userId: userId, + ), + duration: '10ms', + ); +} + +UpdateCommentResponse createDefaultUpdateCommentResponse({ + String commentId = 'comment-1', + required String objectId, + String objectType = 'activity', + String? text, + String? userId, +}) { + return UpdateCommentResponse( + comment: createDefaultCommentResponse( + id: commentId, + objectId: objectId, + objectType: objectType, + text: text, + userId: userId, + ), + duration: '10ms', + ); +} + +DeleteCommentResponse createDefaultDeleteCommentResponse({ + String commentId = 'comment-1', + required String activityId, + required String objectId, + String objectType = 'activity', + String? userId, +}) { + return DeleteCommentResponse( + activity: createDefaultActivityResponse(id: activityId), + comment: createDefaultCommentResponse( + id: commentId, + objectId: objectId, + objectType: objectType, + userId: userId ?? 'user-1', + ), + duration: '10ms', + ); +} + +AddCommentReactionResponse createDefaultAddCommentReactionResponse({ + String commentId = 'comment-1', + required String objectId, + String objectType = 'activity', + String userId = 'user-1', + String reactionType = 'like', +}) { + return AddCommentReactionResponse( + comment: createDefaultCommentResponse( + id: commentId, + objectId: objectId, + objectType: objectType, + userId: userId, + ), + duration: '10ms', + reaction: FeedsReactionResponse( + activityId: objectId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ); +} + +DeleteCommentReactionResponse createDefaultDeleteCommentReactionResponse({ + String commentId = 'comment-1', + required String objectId, + String objectType = 'activity', + String userId = 'user-1', + String reactionType = 'like', +}) { + return DeleteCommentReactionResponse( + comment: createDefaultCommentResponse( + id: commentId, + objectId: objectId, + objectType: objectType, + userId: userId, + ), + duration: '10ms', + reaction: FeedsReactionResponse( + activityId: objectId, + commentId: commentId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), ); } @@ -240,10 +415,11 @@ PinActivityResponse createDefaultPinActivityResponse({ BookmarkResponse createDefaultBookmarkResponse({ String userId = 'user-id', String activityId = 'activity-id', + String activityType = 'post', String folderId = 'folder-id', }) { return BookmarkResponse( - activity: createDefaultActivityResponse(id: activityId), + activity: createDefaultActivityResponse(id: activityId, type: activityType), createdAt: DateTime(2021, 1, 1), custom: const {}, folder: createDefaultBookmarkFolderResponse(id: folderId), @@ -252,6 +428,87 @@ BookmarkResponse createDefaultBookmarkResponse({ ); } +AddBookmarkResponse createDefaultAddBookmarkResponse({ + String userId = 'user-id', + String activityId = 'activity-id', + String folderId = 'folder-id', +}) { + return AddBookmarkResponse( + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + folderId: folderId, + ), + duration: '10ms', + ); +} + +UpdateBookmarkResponse createDefaultUpdateBookmarkResponse({ + String userId = 'user-id', + String activityId = 'activity-id', + String folderId = 'folder-id', +}) { + return UpdateBookmarkResponse( + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + folderId: folderId, + ), + duration: '10ms', + ); +} + +DeleteBookmarkResponse createDefaultDeleteBookmarkResponse({ + String userId = 'user-id', + String activityId = 'activity-id', + String folderId = 'folder-id', +}) { + return DeleteBookmarkResponse( + bookmark: createDefaultBookmarkResponse( + userId: userId, + activityId: activityId, + folderId: folderId, + ), + duration: '10ms', + ); +} + +AddReactionResponse createDefaultAddReactionResponse({ + String activityId = 'activity-id', + String userId = 'user-id', + String reactionType = 'like', +}) { + return AddReactionResponse( + activity: createDefaultActivityResponse(id: activityId), + duration: '10ms', + reaction: FeedsReactionResponse( + activityId: activityId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ); +} + +DeleteActivityReactionResponse createDefaultDeleteReactionResponse({ + String activityId = 'activity-id', + String userId = 'user-id', + String reactionType = 'like', +}) { + return DeleteActivityReactionResponse( + activity: createDefaultActivityResponse(id: activityId), + duration: '10ms', + reaction: FeedsReactionResponse( + activityId: activityId, + type: reactionType, + createdAt: DateTime.timestamp(), + updatedAt: DateTime.timestamp(), + user: createDefaultUserResponse(id: userId), + ), + ); +} + FeedMemberResponse createDefaultFeedMemberResponse({ String id = 'member-id', String role = 'member', diff --git a/packages/stream_feeds/test/test_utils/testers/activity_comment_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/activity_comment_list_tester.dart new file mode 100644 index 00000000..a6786212 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/activity_comment_list_tester.dart @@ -0,0 +1,172 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../fakes.dart'; +import '../mocks.dart'; +import 'base_tester.dart'; + +/// Test helper for activity comment list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'activity-comment-list' by default for filtering. +/// +/// [build] constructs the [ActivityCommentList] under test using the provided [StreamFeedsClient]. +/// [setUp] is optional and runs before [body] for setting up mocks and test state. +/// [body] is the test callback that receives an [ActivityCommentListTester] for interactions. +/// [verify] is optional and runs after [body] for verifying API calls and interactions. +/// [tearDown] is optional and runs after [verify] for cleanup operations. +/// [skip] is optional, skip this test. +/// [tags] is optional, tags for test filtering. Defaults to ['activity-comment-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// activityCommentListTest( +/// 'queries initial comments', +/// build: (client) => client.activityCommentList( +/// ActivityCommentsQuery( +/// objectId: 'activity-1', +/// objectType: 'activity', +/// ), +/// ), +/// setUp: (tester) => tester.get(), +/// body: (tester) async { +/// expect(tester.activityCommentListState.comments, hasLength(3)); +/// }, +/// ); +/// ``` +@isTest +void activityCommentListTest( + String description, { + required ActivityCommentList Function(StreamFeedsClient client) build, + FutureOr Function(ActivityCommentListTester tester)? setUp, + required FutureOr Function(ActivityCommentListTester tester) body, + FutureOr Function(ActivityCommentListTester tester)? verify, + FutureOr Function(ActivityCommentListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['activity-comment-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createActivityCommentListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for activity comment list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying activity comment list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class ActivityCommentListTester extends BaseTester { + const ActivityCommentListTester._({ + required ActivityCommentList activityCommentList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: activityCommentList); + + /// The activity comment list being tested. + ActivityCommentList get activityCommentList => subject; + + /// Current state of the activity comment list. + ActivityCommentListState get activityCommentListState { + return activityCommentList.notifier.state; + } + + /// Stream of activity comment list state updates. + Stream get activityCommentListStateStream { + return activityCommentList.stream; + } + + /// Gets the activity comment list by fetching it from the API. + /// + /// Call this in event tests to set up initial state before emitting events. + /// Skip this in API tests that only verify method calls. + /// + /// Parameters: + /// - [modifyResponse]: Optional function to customize the comments response + Future>> get({ + GetCommentsResponse Function(GetCommentsResponse)? modifyResponse, + }) { + final query = activityCommentList.query; + + final defaultCommentsResponse = createDefaultCommentsResponse( + comments: [ + createDefaultThreadedCommentResponse( + id: 'comment-1', + objectId: query.objectId, + objectType: query.objectType, + text: 'First comment', + userId: 'user-1', + ), + createDefaultThreadedCommentResponse( + id: 'comment-2', + objectId: query.objectId, + objectType: query.objectType, + text: 'Second comment', + userId: 'user-2', + ), + createDefaultThreadedCommentResponse( + id: 'comment-3', + objectId: query.objectId, + objectType: query.objectType, + text: 'Third comment', + userId: 'user-3', + ), + ], + ); + + mockApi( + (api) => api.getComments( + objectId: query.objectId, + objectType: query.objectType, + depth: query.depth, + sort: query.sort, + limit: query.limit, + next: query.next, + prev: query.previous, + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultCommentsResponse), + _ => defaultCommentsResponse, + }, + ); + + return activityCommentList.get(); + } +} + +// Creates an ActivityCommentListTester for testing activity comment list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by activityCommentListTest only. +Future _createActivityCommentListTester({ + required ActivityCommentList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose activity comment list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => ActivityCommentListTester._( + activityCommentList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/packages/stream_feeds/test/test_utils/testers/activity_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/activity_list_tester.dart index 48403678..005e48fa 100644 --- a/packages/stream_feeds/test/test_utils/testers/activity_list_tester.dart +++ b/packages/stream_feeds/test/test_utils/testers/activity_list_tester.dart @@ -101,8 +101,7 @@ final class ActivityListTester extends BaseTester { }) { final query = activityList.query; - final defaultActivityListResponse = QueryActivitiesResponse( - duration: DateTime.now().toIso8601String(), + final defaultActivityListResponse = createDefaultQueryActivitiesResponse( activities: [ createDefaultActivityResponse(id: 'activity-1'), createDefaultActivityResponse(id: 'activity-2'), diff --git a/packages/stream_feeds/test/test_utils/testers/comment_reply_list_tester.dart b/packages/stream_feeds/test/test_utils/testers/comment_reply_list_tester.dart new file mode 100644 index 00000000..3a823a02 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/testers/comment_reply_list_tester.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart' as test; + +import '../fakes.dart'; +import '../mocks.dart'; +import 'base_tester.dart'; + +/// Test helper for comment reply list operations. +/// +/// Automatically sets up WebSocket connection, client, and test infrastructure. +/// Tests are tagged with 'comment-reply-list' by default for filtering. +/// +/// [build] constructs the [CommentReplyList] under test using the provided [StreamFeedsClient]. +/// [setUp] is optional and runs before [body] for setting up mocks and test state. +/// [body] is the test callback that receives a [CommentReplyListTester] for interactions. +/// [verify] is optional and runs after [body] for verifying API calls and interactions. +/// [tearDown] is optional and runs after [verify] for cleanup operations. +/// [skip] is optional, skip this test. +/// [tags] is optional, tags for test filtering. Defaults to ['comment-reply-list']. +/// [timeout] is optional, custom timeout for this test. +/// +/// Example: +/// ```dart +/// commentReplyListTest( +/// 'should query initial replies via API', +/// build: (client) => client.commentReplyList( +/// CommentRepliesQuery( +/// commentId: 'comment-1', +/// ), +/// ), +/// body: (tester) async { +/// final result = await tester.commentReplyList.get(); +/// +/// expect(result.isSuccess, isTrue); +/// expect(tester.commentReplyListState.replies, isEmpty); +/// }, +/// ); +/// ``` +@isTest +void commentReplyListTest( + String description, { + required CommentReplyList Function(StreamFeedsClient client) build, + FutureOr Function(CommentReplyListTester tester)? setUp, + required FutureOr Function(CommentReplyListTester tester) body, + FutureOr Function(CommentReplyListTester tester)? verify, + FutureOr Function(CommentReplyListTester tester)? tearDown, + bool skip = false, + Iterable tags = const ['comment-reply-list'], + test.Timeout? timeout, +}) { + return testWithTester( + description, + build: build, + createTesterFn: _createCommentReplyListTester, + setUp: setUp, + body: body, + verify: verify, + tearDown: tearDown, + skip: skip, + tags: tags, + timeout: timeout, + ); +} + +/// A test utility for comment reply list operations with WebSocket support. +/// +/// Provides helper methods for emitting events and verifying comment reply list state. +/// +/// Resources are automatically cleaned up after the test completes. +final class CommentReplyListTester extends BaseTester { + const CommentReplyListTester._({ + required CommentReplyList commentReplyList, + required super.wsStreamController, + required super.feedsApi, + }) : super(subject: commentReplyList); + + /// The comment reply list being tested. + CommentReplyList get commentReplyList => subject; + + /// Current state of the comment reply list. + CommentReplyListState get commentReplyListState { + return commentReplyList.state; + } + + /// Stream of comment reply list state updates. + Stream get commentReplyListStateStream { + return commentReplyList.stream; + } + + /// Gets the comment reply list by fetching it from the API. + /// + /// Call this in event tests to set up initial state before emitting events. + /// Skip this in API tests that only verify method calls. + /// + /// Parameters: + /// - [modifyResponse]: Optional function to customize the comment replies response + Future>> get({ + GetCommentRepliesResponse Function(GetCommentRepliesResponse)? + modifyResponse, + }) { + final query = commentReplyList.query; + + final defaultRepliesResponse = createDefaultCommentRepliesResponse( + comments: [ + createDefaultThreadedCommentResponse( + id: 'reply-1', + objectId: query.commentId, + objectType: 'comment', + text: 'First reply', + userId: 'user-1', + ), + createDefaultThreadedCommentResponse( + id: 'reply-2', + objectId: query.commentId, + objectType: 'comment', + text: 'Second reply', + userId: 'user-2', + ), + createDefaultThreadedCommentResponse( + id: 'reply-3', + objectId: query.commentId, + objectType: 'comment', + text: 'Third reply', + userId: 'user-3', + ), + ], + ); + + mockApi( + (api) => api.getCommentReplies( + id: query.commentId, + depth: query.depth, + limit: query.limit, + next: query.next, + prev: query.previous, + repliesLimit: query.repliesLimit, + sort: query.sort, + ), + result: switch (modifyResponse) { + final modifier? => modifier(defaultRepliesResponse), + _ => defaultRepliesResponse, + }, + ); + + return commentReplyList.get(); + } +} + +// Creates a CommentReplyListTester for testing comment reply list operations. +// +// Automatically sets up WebSocket connection and registers cleanup handlers. +// This function is for internal use by commentReplyListTest only. +Future _createCommentReplyListTester({ + required CommentReplyList subject, + required StreamFeedsClient client, + required MockDefaultApi feedsApi, + required MockWebSocketChannel webSocketChannel, +}) { + // Dispose comment reply list after test + test.addTearDown(subject.dispose); + + return createTester( + client: client, + webSocketChannel: webSocketChannel, + create: (wsStreamController) => CommentReplyListTester._( + commentReplyList: subject, + wsStreamController: wsStreamController, + feedsApi: feedsApi, + ), + ); +} diff --git a/sample_app/lib/screens/user_feed/comment/user_comments.dart b/sample_app/lib/screens/user_feed/comment/user_comments.dart index dababc46..f3864664 100644 --- a/sample_app/lib/screens/user_feed/comment/user_comments.dart +++ b/sample_app/lib/screens/user_feed/comment/user_comments.dart @@ -4,6 +4,7 @@ import 'package:stream_feeds/stream_feeds.dart'; import '../../../core/di/di_initializer.dart'; import '../../../theme/extensions/theme_extensions.dart'; +import '../reaction_icon.dart'; import 'user_comments_item.dart'; class UserComments extends StatefulWidget { @@ -70,7 +71,6 @@ class _UserCommentsState extends State { _buildHeader( context, activity, - comments, ), Expanded( child: _buildUserCommentsList( @@ -88,7 +88,6 @@ class _UserCommentsState extends State { Widget _buildHeader( BuildContext context, ActivityData? activity, - List comments, ) { final totalComments = activity?.commentCount ?? 0; @@ -142,7 +141,7 @@ class _UserCommentsState extends State { Widget _buildUserCommentsList( BuildContext context, - List comments, + List comments, bool canLoadMore, ) { if (comments.isEmpty) return const EmptyComments(); @@ -177,9 +176,11 @@ class _UserCommentsState extends State { return UserCommentItem( comment: comment, - onHeartClick: _onHeartClick, + onReactionClick: _onReactionClick, onReplyClick: _onReplyClick, - onLongPressComment: _onLongPressComment, + onLongPressComment: (comment) { + _onLongPressComment(context, comment); + }, ); }, ), @@ -204,24 +205,43 @@ class _UserCommentsState extends State { await activity.get(); } - void _onHeartClick(ThreadedCommentData comment, bool isAdding) { - const type = 'heart'; + Future _onReactionClick( + CommentData comment, + ReactionIcon reaction, + ) { + final ownReactions = [...comment.ownReactions]; + final shouldDelete = ownReactions.any((it) => it.type == reaction.type); - if (isAdding) { - activity.addCommentReaction( - commentId: comment.id, - request: const AddCommentReactionRequest( - type: type, - createNotificationActivity: true, - ), - ); - } else { - activity.deleteCommentReaction(comment.id, type); + if (shouldDelete) { + return activity.deleteCommentReaction(comment.id, reaction.type); } + + return activity.addCommentReaction( + commentId: comment.id, + request: AddCommentReactionRequest( + type: reaction.type, + enforceUnique: true, + createNotificationActivity: true, + custom: { + // Add emoji code only if available + if (reaction.emojiCode case final code?) 'emoji_code': code, + }, + ), + ); } - Future _onReplyClick([ThreadedCommentData? parentComment]) async { - final text = await _displayTextInputDialog(context, title: 'Add comment'); + Future _onReplyClick([CommentData? parentComment]) async { + final title = switch (parentComment) { + final comment? => 'Reply to ${comment.user.name ?? 'unknown'}', + _ => 'Add Comment', + }; + + final text = await _displayTextInputDialog( + context, + title: title, + parentComment: parentComment, + ); + if (text == null) return; await activity.addComment( @@ -234,9 +254,13 @@ class _UserCommentsState extends State { ); } - void _onLongPressComment(ThreadedCommentData comment) { + void _onLongPressComment( + BuildContext context, + CommentData comment, + ) { final isOwnComment = comment.user.id == client.user.id; if (!isOwnComment) return; + final canEdit = capabilities.contains(FeedOwnCapability.updateOwnComment); final canDelete = capabilities.contains(FeedOwnCapability.deleteOwnComment); if (!canEdit && !canDelete) return; @@ -272,7 +296,7 @@ class _UserCommentsState extends State { Future _editComment( BuildContext context, - ThreadedCommentData comment, + CommentData comment, ) async { final text = await _displayTextInputDialog( context, @@ -294,31 +318,169 @@ class _UserCommentsState extends State { required String title, String? initialText, String positiveAction = 'Add', + CommentData? parentComment, }) { - final textFieldController = TextEditingController(); - textFieldController.text = initialText ?? ''; return showDialog( context: context, - builder: (context) { - return AlertDialog( - title: Text(title), - content: TextField(controller: textFieldController), - actions: [ - TextButton( - child: const Text('Cancel'), - onPressed: () { - Navigator.pop(context); - }, + builder: (context) => _CommentInputDialog( + title: title, + initialText: initialText ?? '', + positiveAction: positiveAction, + parentComment: parentComment, + ), + ); + } +} + +class _CommentInputDialog extends StatefulWidget { + const _CommentInputDialog({ + required this.title, + required this.initialText, + required this.positiveAction, + this.parentComment, + }); + + final String title; + final String initialText; + final String positiveAction; + final CommentData? parentComment; + + @override + State<_CommentInputDialog> createState() => _CommentInputDialogState(); +} + +class _CommentInputDialogState extends State<_CommentInputDialog> { + late final _controller = TextEditingController( + text: widget.initialText, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + final actions = [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: const Text('Cancel'), + ), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, textValue, _) { + return TextButton( + onPressed: switch (textValue.text.trim()) { + final commentText when commentText.isEmpty => null, + final commentText => () => Navigator.of(context).pop(commentText), + }, + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, ), - TextButton( - child: Text(positiveAction), - onPressed: () { - Navigator.pop(context, textFieldController.text); - }, + child: Text(widget.positiveAction), + ); + }, + ), + ]; + + return AlertDialog( + actions: actions, + backgroundColor: colorTheme.appBg, + actionsPadding: const EdgeInsets.all(8), + contentPadding: const EdgeInsets.all(16), + title: Text(widget.title, style: textTheme.headlineBold), + titlePadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + content: Column( + spacing: 16, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.parentComment case final parentComment?) ...[ + DecoratedBox( + decoration: BoxDecoration( + color: colorTheme.inputBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorTheme.borders), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + spacing: 12, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.reply_rounded, + size: 16, + color: colorTheme.accentPrimary, + ), + Expanded( + child: Column( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + parentComment.user.name ?? 'unknown', + style: textTheme.bodyBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + Text( + maxLines: 2, + parentComment.text ?? '', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), ), ], - ); - }, + TextField( + maxLines: 5, + maxLength: 300, + autofocus: true, + style: textTheme.body, + controller: _controller, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + filled: true, + fillColor: colorTheme.inputBg, + hintText: switch (widget.parentComment) { + CommentData() => 'Write a reply...', + _ => 'Enter your comment', + }, + hintStyle: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), ); } } diff --git a/sample_app/lib/screens/user_feed/comment/user_comments_item.dart b/sample_app/lib/screens/user_feed/comment/user_comments_item.dart index b650d5ef..b9119c95 100644 --- a/sample_app/lib/screens/user_feed/comment/user_comments_item.dart +++ b/sample_app/lib/screens/user_feed/comment/user_comments_item.dart @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_positional_boolean_parameters - import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; @@ -7,41 +5,40 @@ import '../../../theme/extensions/theme_extensions.dart'; import '../../../utils/date_time_extensions.dart'; import '../../../widgets/action_button.dart'; import '../../../widgets/user_avatar.dart'; +import '../reaction_icon.dart'; class UserCommentItem extends StatelessWidget { const UserCommentItem({ super.key, required this.comment, - required this.onHeartClick, + required this.onReactionClick, required this.onReplyClick, required this.onLongPressComment, }); - final ThreadedCommentData comment; - final ValueSetter onReplyClick; - final void Function(ThreadedCommentData comment, bool isAdding) onHeartClick; - final ValueSetter onLongPressComment; + final CommentData comment; + final ValueSetter onReplyClick; + final void Function(CommentData, ReactionIcon) onReactionClick; + final ValueSetter onLongPressComment; @override Widget build(BuildContext context) { final user = comment.user; - final heartsCount = comment.reactionGroups['heart']?.count ?? 0; - final hasOwnHeart = comment.ownReactions.any((it) => it.type == 'heart'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), - GestureDetector( - onLongPress: () => onLongPressComment(comment), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - UserAvatar.appBar( - user: User(id: user.id, name: user.name, image: user.image), - ), - const SizedBox(width: 8), - Expanded( + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UserAvatar.appBar( + user: User(id: user.id, name: user.name, image: user.image), + ), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onLongPress: () => onLongPressComment(comment), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -58,8 +55,8 @@ class UserCommentItem extends StatelessWidget { ], ), ), - ], - ), + ), + ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -79,16 +76,14 @@ class UserCommentItem extends StatelessWidget { ), ), ), - ActionButton( - icon: Icon( - switch (hasOwnHeart) { - true => Icons.favorite_rounded, - false => Icons.favorite_outline_rounded, - }, - ), - count: heartsCount, - color: hasOwnHeart ? context.appColors.accentError : null, - onTap: () => onHeartClick(comment, !hasOwnHeart), + Row( + spacing: 8, + children: [ + ..._buildReactions( + context, + onReactionClick: (it) => onReactionClick(comment, it), + ), + ], ), ], ), @@ -97,7 +92,7 @@ class UserCommentItem extends StatelessWidget { padding: const EdgeInsets.only(left: 32), child: UserCommentItem( comment: reply, - onHeartClick: onHeartClick, + onReactionClick: onReactionClick, onReplyClick: onReplyClick, onLongPressComment: onLongPressComment, ), @@ -105,4 +100,24 @@ class UserCommentItem extends StatelessWidget { ], ); } + + Iterable _buildReactions( + BuildContext context, { + ValueSetter? onReactionClick, + }) sync* { + final groups = comment.reactionGroups; + final ownReactions = comment.ownReactions; + + for (final reaction in ReactionIcon.defaultReactions) { + final count = groups[reaction.type]?.count ?? 0; + final selected = ownReactions.any((it) => it.type == reaction.type); + + yield ActionButton( + icon: Icon(reaction.getIcon(selected)), + count: count, + color: reaction.getColor(selected), + onTap: () => onReactionClick?.call(reaction), + ); + } + } } diff --git a/sample_app/lib/screens/user_feed/feed/user_feed.dart b/sample_app/lib/screens/user_feed/feed/user_feed.dart index 035fcc50..aa784887 100644 --- a/sample_app/lib/screens/user_feed/feed/user_feed.dart +++ b/sample_app/lib/screens/user_feed/feed/user_feed.dart @@ -5,6 +5,7 @@ import 'package:stream_feeds/stream_feeds.dart'; import '../../../core/di/di_initializer.dart'; import '../../../theme/theme.dart'; import '../comment/user_comments.dart'; +import '../reaction_icon.dart'; import 'stories_bar.dart'; import 'user_feed_item.dart'; @@ -56,15 +57,9 @@ class UserFeed extends StatelessWidget { onCommentClick: (activity) { _onCommentClick(context, activity); }, - onHeartClick: (data) { - _onHeartClick(data.activity, data.isAdding); - }, - onRepostClick: (data) { - _onRepostClick(context, data.activity, data.message); - }, - onBookmarkClick: (activity) { - _onBookmarkClick(context, activity); - }, + onReactionClick: _onReactionClick, + onRepostClick: _onRepostClick, + onBookmarkClick: _onBookmarkClick, onDeleteClick: (activity) {}, onEditSave: (data) {}, ), @@ -90,8 +85,11 @@ class UserFeed extends StatelessWidget { ); } - void _onCommentClick(BuildContext context, ActivityData activity) { - showModalBottomSheet( + Future _onCommentClick( + BuildContext context, + ActivityData activity, + ) { + return showModalBottomSheet( context: context, useSafeArea: true, isScrollControlled: true, @@ -117,37 +115,52 @@ class UserFeed extends StatelessWidget { ); } - void _onHeartClick(ActivityData activity, bool isAdding) { - if (isAdding) { - userFeed.addActivityReaction( - activityId: activity.id, - request: const AddReactionRequest( - type: 'heart', - createNotificationActivity: true, - ), - ); - } else { - userFeed.deleteActivityReaction( + Future _onReactionClick( + ActivityData activity, + ReactionIcon reaction, + ) { + final ownReactions = [...activity.ownReactions]; + final shouldDelete = ownReactions.any((it) => it.type == reaction.type); + + if (shouldDelete) { + return timelineFeed.deleteActivityReaction( + type: reaction.type, activityId: activity.id, - type: 'heart', ); } + + return timelineFeed.addActivityReaction( + activityId: activity.id, + request: AddReactionRequest( + type: reaction.type, + enforceUnique: true, + createNotificationActivity: true, + custom: { + // Add emoji code only if available + if (reaction.emojiCode case final code?) 'emoji_code': code, + }, + ), + ); } - void _onRepostClick( - BuildContext context, - ActivityData activity, + Future _onRepostClick( + ActivityData activity, { String? message, - ) { - userFeed.repost(activityId: activity.id, text: message); + }) { + return userFeed.repost( + activityId: activity.id, + text: message, + ); } - void _onBookmarkClick(BuildContext context, ActivityData activity) { - if (activity.ownBookmarks.isNotEmpty) { - userFeed.deleteBookmark(activityId: activity.id); - } else { - userFeed.addBookmark(activityId: activity.id); + Future _onBookmarkClick(ActivityData activity) { + final shouldDelete = activity.ownBookmarks.isNotEmpty; + + if (shouldDelete) { + return timelineFeed.deleteBookmark(activityId: activity.id); } + + return timelineFeed.addBookmark(activityId: activity.id); } } @@ -157,7 +170,7 @@ class _TimelineFeedItemList extends StatelessWidget { required this.timelineFeed, required this.currentUserId, required this.onCommentClick, - required this.onHeartClick, + required this.onReactionClick, required this.onRepostClick, required this.onBookmarkClick, required this.onDeleteClick, @@ -168,8 +181,8 @@ class _TimelineFeedItemList extends StatelessWidget { final Feed timelineFeed; final String currentUserId; final ValueSetter? onCommentClick; - final ValueSetter<({ActivityData activity, bool isAdding})>? onHeartClick; - final ValueSetter<({ActivityData activity, String? message})>? onRepostClick; + final void Function(ActivityData, ReactionIcon)? onReactionClick; + final ValueSetter? onRepostClick; final ValueSetter? onBookmarkClick; final ValueSetter? onDeleteClick; final ValueSetter<({ActivityData activity, String text})>? onEditSave; @@ -212,12 +225,8 @@ class _TimelineFeedItemList extends StatelessWidget { attachments: baseActivity.attachments, currentUserId: currentUserId, onCommentClick: () => onCommentClick?.call(activity), - onHeartClick: (isAdding) => onHeartClick?.call( - (activity: activity, isAdding: isAdding), - ), - onRepostClick: (message) => onRepostClick?.call( - (activity: activity, message: message), - ), + onReactionClick: (it) => onReactionClick?.call(activity, it), + onRepostClick: () => onRepostClick?.call(activity), onBookmarkClick: () => onBookmarkClick?.call(activity), onDeleteClick: () => onDeleteClick?.call(activity), onEditSave: (text) => onEditSave?.call( diff --git a/sample_app/lib/screens/user_feed/feed/user_feed_item.dart b/sample_app/lib/screens/user_feed/feed/user_feed_item.dart index 42cc59a3..86484833 100644 --- a/sample_app/lib/screens/user_feed/feed/user_feed_item.dart +++ b/sample_app/lib/screens/user_feed/feed/user_feed_item.dart @@ -10,6 +10,7 @@ import '../../../widgets/attachment_gallery/attachment_metadata.dart'; import '../../../widgets/attachments/attachments.dart'; import '../../../widgets/user_avatar.dart'; import '../polls/show_poll/show_poll_widget.dart'; +import '../reaction_icon.dart'; class UserFeedItem extends StatelessWidget { const UserFeedItem({ @@ -21,7 +22,7 @@ class UserFeedItem extends StatelessWidget { required this.data, required this.currentUserId, this.onCommentClick, - this.onHeartClick, + this.onReactionClick, this.onRepostClick, this.onBookmarkClick, this.onDeleteClick, @@ -34,8 +35,8 @@ class UserFeedItem extends StatelessWidget { final ActivityData data; final String currentUserId; final VoidCallback? onCommentClick; - final ValueSetter? onHeartClick; - final ValueSetter? onRepostClick; + final ValueSetter? onReactionClick; + final VoidCallback? onRepostClick; final VoidCallback? onBookmarkClick; final VoidCallback? onDeleteClick; final ValueChanged? onEditSave; @@ -60,7 +61,7 @@ class UserFeedItem extends StatelessWidget { data: data, currentUserId: currentUserId, onCommentClick: onCommentClick, - onHeartClick: onHeartClick, + onReactionClick: onReactionClick, onRepostClick: onRepostClick, onBookmarkClick: onBookmarkClick, ), @@ -181,7 +182,7 @@ class _UserActions extends StatelessWidget { required this.data, required this.currentUserId, this.onCommentClick, - this.onHeartClick, + this.onReactionClick, this.onRepostClick, this.onBookmarkClick, }); @@ -190,16 +191,13 @@ class _UserActions extends StatelessWidget { final ActivityData data; final String currentUserId; final VoidCallback? onCommentClick; - final ValueSetter? onHeartClick; - final ValueSetter? onRepostClick; + final ValueSetter? onReactionClick; + final VoidCallback? onRepostClick; final VoidCallback? onBookmarkClick; @override Widget build(BuildContext context) { - final heartsCount = data.reactionGroups['heart']?.count ?? 0; - final hasOwnHeart = data.ownReactions.any((it) => it.type == 'heart'); - - final hasOwnBookmark = data.ownReactions.any((it) => it.type == 'bookmark'); + final hasOwnBookmark = data.ownBookmarks.isNotEmpty; return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -209,21 +207,13 @@ class _UserActions extends StatelessWidget { count: data.commentCount, onTap: onCommentClick, ), - ActionButton( - icon: Icon( - switch (hasOwnHeart) { - true => Icons.favorite_rounded, - false => Icons.favorite_outline_rounded, - }, - ), - count: heartsCount, - color: hasOwnHeart ? context.appColors.accentError : null, - onTap: () => onHeartClick?.call(!hasOwnHeart), - ), + // region Reactions + ..._buildReactions(context, onReactionClick: onReactionClick), + // endregion ActionButton( icon: const Icon(Icons.repeat_rounded), count: data.shareCount, - onTap: () => onRepostClick?.call(null), + onTap: onRepostClick, ), ActionButton( icon: Icon( @@ -239,4 +229,24 @@ class _UserActions extends StatelessWidget { ], ); } + + Iterable _buildReactions( + BuildContext context, { + ValueSetter? onReactionClick, + }) sync* { + final groups = data.reactionGroups; + final ownReactions = data.ownReactions; + + for (final reaction in ReactionIcon.defaultReactions) { + final count = groups[reaction.type]?.count ?? 0; + final selected = ownReactions.any((it) => it.type == reaction.type); + + yield ActionButton( + icon: Icon(reaction.getIcon(selected)), + count: count, + color: reaction.getColor(selected), + onTap: () => onReactionClick?.call(reaction), + ); + } + } } diff --git a/sample_app/lib/screens/user_feed/reaction_icon.dart b/sample_app/lib/screens/user_feed/reaction_icon.dart new file mode 100644 index 00000000..dba81478 --- /dev/null +++ b/sample_app/lib/screens/user_feed/reaction_icon.dart @@ -0,0 +1,74 @@ +// ignore_for_file: avoid_positional_boolean_parameters + +import 'package:flutter/material.dart'; + +/// {@template reactionIcon} +/// Reaction icon data +/// {@endtemplate} +class ReactionIcon { + /// {@macro reactionIcon} + const ReactionIcon({ + required this.type, + required this.icon, + required this.filledIcon, + this.iconColor, + this.emojiCode, + }); + + /// Type of reaction + final String type; + + /// The outlined icon representing the reaction. + /// + /// For example, a heart outline for a "like" reaction. + final IconData icon; + + /// The filled icon representing the reaction. + /// + /// For example, a filled heart for a "like" reaction. + final IconData filledIcon; + + /// The color associated with the reaction icon. + /// + /// This color is typically used to highlight the icon when selected. + final Color? iconColor; + + /// Optional emoji code for the reaction. + /// + /// Used to display a custom emoji in the notification. + final String? emojiCode; + + /// The default list of reaction icons provided by the app. + /// + /// This includes two reactions: + /// - heart: Represented by a heart icon + /// - fire: Represented by a fire icon + /// + /// These default reactions can be used directly or as a starting point for + /// custom reaction configurations. + static const List defaultReactions = [ + ReactionIcon( + type: 'heart', + emojiCode: '❤️', + icon: Icons.favorite_outline_rounded, + filledIcon: Icons.favorite_rounded, + iconColor: Color(0xFFE91E63), + ), + ReactionIcon( + type: 'fire', + emojiCode: '🔥', + icon: Icons.local_fire_department_outlined, + filledIcon: Icons.local_fire_department_rounded, + iconColor: Color(0xFFFF5722), + ), + ]; +} + +/// Extension methods for [ReactionIcon]. +extension ReactionIconExtension on ReactionIcon { + /// Returns the appropriate icon based on whether the reaction is highlighted. + IconData getIcon(bool highlighted) => highlighted ? filledIcon : icon; + + /// Returns the appropriate color based on whether the reaction is highlighted. + Color? getColor(bool isHighlighted) => isHighlighted ? iconColor : null; +} diff --git a/sample_app/lib/theme/schemes/app_color_scheme.dart b/sample_app/lib/theme/schemes/app_color_scheme.dart index 61c7d5c8..7f39e88f 100644 --- a/sample_app/lib/theme/schemes/app_color_scheme.dart +++ b/sample_app/lib/theme/schemes/app_color_scheme.dart @@ -24,6 +24,7 @@ class AppColorScheme { required this.accentPrimary, required this.accentError, required this.accentInfo, + required this.accentWarning, required this.highlight, required this.overlay, required this.overlayDark, @@ -44,6 +45,7 @@ class AppColorScheme { accentPrimary: AppColorTokens.blue500, accentError: AppColorTokens.red500, accentInfo: AppColorTokens.green500, + accentWarning: AppColorTokens.orange500, highlight: AppColorTokens.yellow100, overlay: AppColorTokens.blackAlpha20, overlayDark: AppColorTokens.blackAlpha60, @@ -65,6 +67,7 @@ class AppColorScheme { accentPrimary: AppColorTokens.blue600, accentError: AppColorTokens.red600, accentInfo: AppColorTokens.green500, + accentWarning: AppColorTokens.orange600, highlight: AppColorTokens.yellow800, overlay: AppColorTokens.blackAlpha40, overlayDark: AppColorTokens.whiteAlpha60, @@ -116,6 +119,9 @@ class AppColorScheme { /// Info accent color. final Color accentInfo; + /// Warning accent color. + final Color accentWarning; + /// Highlight color. final Color highlight; @@ -141,6 +147,7 @@ class AppColorScheme { Color? accentPrimary, Color? accentError, Color? accentInfo, + Color? accentWarning, Color? highlight, Color? overlay, Color? overlayDark, @@ -158,6 +165,7 @@ class AppColorScheme { accentPrimary: accentPrimary ?? this.accentPrimary, accentError: accentError ?? this.accentError, accentInfo: accentInfo ?? this.accentInfo, + accentWarning: accentWarning ?? this.accentWarning, highlight: highlight ?? this.highlight, overlay: overlay ?? this.overlay, overlayDark: overlayDark ?? this.overlayDark, @@ -179,6 +187,7 @@ class AppColorScheme { accentPrimary: Color.lerp(a.accentPrimary, b.accentPrimary, t)!, accentError: Color.lerp(a.accentError, b.accentError, t)!, accentInfo: Color.lerp(a.accentInfo, b.accentInfo, t)!, + accentWarning: Color.lerp(a.accentWarning, b.accentWarning, t)!, highlight: Color.lerp(a.highlight, b.highlight, t)!, overlay: Color.lerp(a.overlay, b.overlay, t)!, overlayDark: Color.lerp(a.overlayDark, b.overlayDark, t)!, @@ -201,6 +210,7 @@ class AppColorScheme { other.accentPrimary == accentPrimary && other.accentError == accentError && other.accentInfo == accentInfo && + other.accentWarning == accentWarning && other.highlight == highlight && other.overlay == overlay && other.overlayDark == overlayDark && @@ -221,6 +231,7 @@ class AppColorScheme { accentPrimary, accentError, accentInfo, + accentWarning, highlight, overlay, overlayDark, diff --git a/sample_app/lib/theme/tokens/color_tokens.dart b/sample_app/lib/theme/tokens/color_tokens.dart index e97a3fb8..6048d458 100644 --- a/sample_app/lib/theme/tokens/color_tokens.dart +++ b/sample_app/lib/theme/tokens/color_tokens.dart @@ -36,6 +36,10 @@ abstract class AppColorTokens { /// Green color scale for success states static const green500 = Color(0xff20E070); + /// Orange color scale for warning states + static const orange500 = Color(0xffFF6B35); + static const orange600 = Color(0xffFF8555); + /// Special colors for highlights and overlays static const yellow100 = Color(0xfffbf4dd); static const yellow800 = Color(0xff302d22);