diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 8fe93c33..0f516f3d 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,6 +1,7 @@ ## unreleased - Update follower and following counts on the feed state when receiving follow websocket events. - Fix FeedsReactionData id for updating reactions in the feed state. +- Improve feed and activity state updates for websocket events. - Improvement for stories and minor updates to other AggregatedActivity state updates. ## 0.3.1 diff --git a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart index d4aad511..eb5c4888 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -383,6 +383,7 @@ class StreamFeedsClientImpl implements StreamFeedsClient { query: query, commentsRepository: _commentsRepository, eventsEmitter: events, + currentUserId: user.id, ); } diff --git a/packages/stream_feeds/lib/src/models.dart b/packages/stream_feeds/lib/src/models.dart index c55014d4..6d329d5a 100644 --- a/packages/stream_feeds/lib/src/models.dart +++ b/packages/stream_feeds/lib/src/models.dart @@ -7,7 +7,7 @@ export 'models/feed_member_data.dart'; export 'models/feed_member_request_data.dart'; export 'models/feeds_config.dart'; export 'models/follow_data.dart'; -export 'models/poll_data.dart'; +export 'models/poll_data.dart' show PollData; export 'models/poll_option_data.dart'; export 'models/poll_vote_data.dart'; export 'models/push_notifications_config.dart'; diff --git a/packages/stream_feeds/lib/src/models/comment_data.dart b/packages/stream_feeds/lib/src/models/comment_data.dart index 5bd07b7d..18aed826 100644 --- a/packages/stream_feeds/lib/src/models/comment_data.dart +++ b/packages/stream_feeds/lib/src/models/comment_data.dart @@ -1,6 +1,7 @@ // ignore_for_file: avoid_redundant_argument_values 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'; @@ -197,3 +198,99 @@ extension CommentResponseMapper on CommentResponse { ); } } + +extension CommentDataMutations on CommentData { + /// 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 [CommentData] instance with the updated reaction data. + CommentData 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 [CommentData] instance with the updated reaction data. + CommentData 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, + ); + } +} 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 3e477447..66ca8baa 100644 --- a/packages/stream_feeds/lib/src/models/feeds_reaction_data.dart +++ b/packages/stream_feeds/lib/src/models/feeds_reaction_data.dart @@ -26,6 +26,8 @@ class FeedsReactionData with _$FeedsReactionData { @override final String activityId; + /// The ID of the comment this reaction is associated with. + @override final String? commentId; /// The date and time when the reaction was created. diff --git a/packages/stream_feeds/lib/src/models/feeds_reaction_data.freezed.dart b/packages/stream_feeds/lib/src/models/feeds_reaction_data.freezed.dart index db51d0d1..3c53c1b3 100644 --- a/packages/stream_feeds/lib/src/models/feeds_reaction_data.freezed.dart +++ b/packages/stream_feeds/lib/src/models/feeds_reaction_data.freezed.dart @@ -16,6 +16,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$FeedsReactionData { String get activityId; + String? get commentId; DateTime get createdAt; String get type; DateTime get updatedAt; @@ -37,6 +38,8 @@ mixin _$FeedsReactionData { other is FeedsReactionData && (identical(other.activityId, activityId) || other.activityId == activityId) && + (identical(other.commentId, commentId) || + other.commentId == commentId) && (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && (identical(other.type, type) || other.type == type) && @@ -47,12 +50,12 @@ mixin _$FeedsReactionData { } @override - int get hashCode => Object.hash(runtimeType, activityId, createdAt, type, - updatedAt, user, const DeepCollectionEquality().hash(custom)); + int get hashCode => Object.hash(runtimeType, activityId, commentId, createdAt, + type, updatedAt, user, const DeepCollectionEquality().hash(custom)); @override String toString() { - return 'FeedsReactionData(activityId: $activityId, createdAt: $createdAt, type: $type, updatedAt: $updatedAt, user: $user, custom: $custom)'; + return 'FeedsReactionData(activityId: $activityId, commentId: $commentId, createdAt: $createdAt, type: $type, updatedAt: $updatedAt, user: $user, custom: $custom)'; } } @@ -64,6 +67,7 @@ abstract mixin class $FeedsReactionDataCopyWith<$Res> { @useResult $Res call( {String activityId, + String? commentId, DateTime createdAt, String type, DateTime updatedAt, @@ -85,6 +89,7 @@ class _$FeedsReactionDataCopyWithImpl<$Res> @override $Res call({ Object? activityId = null, + Object? commentId = freezed, Object? createdAt = null, Object? type = null, Object? updatedAt = null, @@ -96,6 +101,10 @@ class _$FeedsReactionDataCopyWithImpl<$Res> ? _self.activityId : activityId // ignore: cast_nullable_to_non_nullable as String, + commentId: freezed == commentId + ? _self.commentId + : commentId // ignore: cast_nullable_to_non_nullable + as String?, createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_feeds/lib/src/models/poll_data.dart b/packages/stream_feeds/lib/src/models/poll_data.dart index 1925750f..d3f47712 100644 --- a/packages/stream_feeds/lib/src/models/poll_data.dart +++ b/packages/stream_feeds/lib/src/models/poll_data.dart @@ -175,12 +175,19 @@ extension PollDataMutations on PollData { return copyWith(options: updatedOptions); } - PollData castAnswer(PollVoteData answer, String currentUserId) { - final updatedLatestAnswers = latestAnswers.let((it) { + PollData castAnswer( + PollVoteData answer, + String currentUserId, { + List? currentLatestAnswers, + List? currentOwnVotesAndAnswers, + }) { + final updatedLatestAnswers = + (currentLatestAnswers ?? latestAnswers).let((it) { return it.upsert(answer, key: (it) => it.id == answer.id); }); - final updatedOwnVotesAndAnswers = ownVotesAndAnswers.let((it) { + final updatedOwnVotesAndAnswers = + (currentOwnVotesAndAnswers ?? ownVotesAndAnswers).let((it) { if (answer.userId != currentUserId) return it; return it.upsert(answer, key: (it) => it.id == answer.id); }); @@ -190,6 +197,65 @@ extension PollDataMutations on PollData { ownVotesAndAnswers: updatedOwnVotesAndAnswers, ); } + + PollData changeVote( + PollVoteData vote, + String currentUserId, { + List? currentLatestVotes, + List? currentOwnVotesAndAnswers, + }) { + final latestAnswers = currentLatestVotes ?? latestVotes; + final ownVotesAndAnswers = + (currentOwnVotesAndAnswers ?? this.ownVotesAndAnswers).let((it) { + if (vote.userId != currentUserId) return it; + return it.upsert(vote, key: (it) => it.id == vote.id); + }); + + return copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); + } + + PollData removeAnswer( + PollVoteData answer, + String currentUserId, { + List? currentLatestAnswers, + List? currentOwnVotesAndAnswers, + }) { + final latestAnswers = + (currentLatestAnswers ?? this.latestAnswers).where((it) { + return it.id != answer.id; + }).toList(); + + final ownVotesAndAnswers = + (currentOwnVotesAndAnswers ?? this.ownVotesAndAnswers).where((it) { + return it.id != answer.id; + }).toList(); + + return copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); + } + + PollData removeVote( + PollVoteData vote, + String currentUserId, { + List? currentLatestVotes, + List? currentOwnVotesAndAnswers, + }) { + final latestAnswers = currentLatestVotes ?? latestVotes; + final ownVotesAndAnswers = + (currentOwnVotesAndAnswers ?? this.ownVotesAndAnswers).where((it) { + return it.id != vote.id; + }).toList(); + + return copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); + } } /// Extension function to convert a [PollResponseData] to a [PollData] model. diff --git a/packages/stream_feeds/lib/src/state/activity.dart b/packages/stream_feeds/lib/src/state/activity.dart index f4dc8e93..00262ab6 100644 --- a/packages/stream_feeds/lib/src/state/activity.dart +++ b/packages/stream_feeds/lib/src/state/activity.dart @@ -68,7 +68,11 @@ class Activity with Disposable { ); // Attach event handlers for real-time updates - final handler = ActivityEventHandler(fid: fid, state: _stateNotifier); + final handler = ActivityEventHandler( + fid: fid, + state: _stateNotifier, + capabilitiesRepository: capabilitiesRepository, + ); _eventsSubscription = eventsEmitter.listen(handler.handleEvent); } diff --git a/packages/stream_feeds/lib/src/state/activity_state.dart b/packages/stream_feeds/lib/src/state/activity_state.dart index dad2e395..8b388002 100644 --- a/packages/stream_feeds/lib/src/state/activity_state.dart +++ b/packages/stream_feeds/lib/src/state/activity_state.dart @@ -1,13 +1,17 @@ +import 'dart:math' as math; + import 'package:freezed_annotation/freezed_annotation.dart'; 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/feeds_reaction_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'; +import 'event/partial_activity_event_handler.dart'; part 'activity_state.freezed.dart'; @@ -15,7 +19,8 @@ part 'activity_state.freezed.dart'; /// /// Provides methods to update the activity state in response to data changes /// and real-time events from the Stream Feeds API. -class ActivityStateNotifier extends StateNotifier { +class ActivityStateNotifier extends StateNotifier + implements StateWithUpdatableActivity { ActivityStateNotifier({ required ActivityState initialState, required this.currentUserId, @@ -40,6 +45,7 @@ class ActivityStateNotifier extends StateNotifier { } /// Handles the update of an activity. + @override void onActivityUpdated(ActivityData activity) { state = state.copyWith( activity: activity, @@ -48,6 +54,7 @@ class ActivityStateNotifier extends StateNotifier { } /// Handles when a poll is closed. + @override void onPollClosed(PollData poll) { if (state.poll?.id != poll.id) return; @@ -56,112 +63,140 @@ class ActivityStateNotifier extends StateNotifier { } /// Handles when a poll is deleted. + @override void onPollDeleted(String pollId) { if (state.poll?.id != pollId) return; state = state.copyWith(poll: null); } /// Handles when a poll is updated. + @override void onPollUpdated(PollData poll) { final currentPoll = state.poll; if (currentPoll == null || currentPoll.id != poll.id) return; - final latestAnswers = currentPoll.latestAnswers; - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers; - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + latestAnswers: currentPoll.latestAnswers, + ownVotesAndAnswers: currentPoll.ownVotesAndAnswers, ); state = state.copyWith(poll: updatedPoll); } /// Handles when a poll answer is casted. + @override void onPollAnswerCasted(PollVoteData answer, PollData poll) { final currentPoll = state.poll; if (currentPoll == null || currentPoll.id != poll.id) return; - final latestAnswers = currentPoll.latestAnswers.let((it) { - return it.upsert(answer, key: (it) => it.id == answer.id); - }); - - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers.let((it) { - if (answer.userId != currentUserId) return it; - return it.upsert(answer, key: (it) => it.id == answer.id); - }); - - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + final updatedPoll = poll.castAnswer( + answer, + currentUserId, + currentLatestAnswers: currentPoll.latestAnswers, + currentOwnVotesAndAnswers: currentPoll.ownVotesAndAnswers, ); state = state.copyWith(poll: updatedPoll); } /// Handles when a poll vote is casted (with poll data). + @override void onPollVoteCasted(PollVoteData vote, PollData poll) { return onPollVoteChanged(vote, poll); } /// Handles when a poll vote is changed. + @override void onPollVoteChanged(PollVoteData vote, PollData poll) { final currentPoll = state.poll; if (currentPoll == null || currentPoll.id != poll.id) return; - final latestAnswers = currentPoll.latestAnswers; - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers.let((it) { - if (vote.userId != currentUserId) return it; - return it.upsert(vote, key: (it) => it.id == vote.id); - }); - - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + final updatedPoll = poll.changeVote( + vote, + currentUserId, + currentLatestVotes: currentPoll.latestVotes, + currentOwnVotesAndAnswers: currentPoll.ownVotesAndAnswers, ); state = state.copyWith(poll: updatedPoll); } /// Handles when a poll answer is removed (with poll data). + @override void onPollAnswerRemoved(PollVoteData answer, PollData poll) { final currentPoll = state.poll; if (currentPoll == null || currentPoll.id != poll.id) return; - final latestAnswers = currentPoll.latestAnswers.where((it) { - return it.id != answer.id; - }).toList(); - - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers.where((it) { - return it.id != answer.id; - }).toList(); - - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + final updatedPoll = poll.removeAnswer( + answer, + currentUserId, + currentLatestAnswers: currentPoll.latestAnswers, + currentOwnVotesAndAnswers: currentPoll.ownVotesAndAnswers, ); state = state.copyWith(poll: updatedPoll); } /// Handles when a poll vote is removed (with poll data). + @override void onPollVoteRemoved(PollVoteData vote, PollData poll) { final currentPoll = state.poll; if (currentPoll == null || currentPoll.id != poll.id) return; - final latestAnswers = currentPoll.latestAnswers; - final ownVotesAndAnswers = currentPoll.ownVotesAndAnswers.where((it) { - return it.id != vote.id; - }).toList(); - - final updatedPoll = poll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, + final updatedPoll = poll.removeVote( + vote, + currentUserId, + currentLatestVotes: currentPoll.latestVotes, + currentOwnVotesAndAnswers: currentPoll.ownVotesAndAnswers, ); state = state.copyWith(poll: updatedPoll); } + @override + void onCommentAdded(CommentData comment) { + // The comments are stored in the comment list, but that doesn't contain the total count. + if (state.activity case final activity?) { + state = state.copyWith( + activity: activity.copyWith( + commentCount: math.max(0, activity.commentCount + 1), + ), + ); + } + } + + @override + void onCommentRemoved(CommentData comment) { + // The comments are stored in the comment list, but that doesn't contain the total count. + if (state.activity case final activity?) { + state = state.copyWith( + activity: activity.copyWith( + commentCount: math.max(0, activity.commentCount - 1), + ), + ); + } + } + + @override + void onReactionAdded(FeedsReactionData reaction) { + final activity = state.activity; + if (activity == null || activity.id != reaction.activityId) return; + + state = state.copyWith( + activity: activity.addReaction(reaction, currentUserId), + ); + } + + @override + void onReactionRemoved(FeedsReactionData reaction) { + final activity = state.activity; + if (activity == null || reaction.activityId != activity.id) return; + + state = state.copyWith( + activity: activity.removeReaction(reaction, currentUserId), + ); + } + @override void dispose() { _removeCommentListListener?.call(); diff --git a/packages/stream_feeds/lib/src/state/comment_list.dart b/packages/stream_feeds/lib/src/state/comment_list.dart index da07af0a..218dfc51 100644 --- a/packages/stream_feeds/lib/src/state/comment_list.dart +++ b/packages/stream_feeds/lib/src/state/comment_list.dart @@ -25,9 +25,11 @@ class CommentList extends Disposable { required this.query, required this.commentsRepository, required this.eventsEmitter, + required this.currentUserId, }) { _stateNotifier = CommentListStateNotifier( initialState: const CommentListState(), + currentUserId: currentUserId, ); // Attach event handlers for real-time updates @@ -41,6 +43,7 @@ class CommentList extends Disposable { final CommentsQuery query; final CommentsRepository commentsRepository; + final String currentUserId; CommentListState get state => stateNotifier.value; StateNotifier get notifier => stateNotifier; diff --git a/packages/stream_feeds/lib/src/state/comment_list_state.dart b/packages/stream_feeds/lib/src/state/comment_list_state.dart index e91b4162..d9c4beab 100644 --- a/packages/stream_feeds/lib/src/state/comment_list_state.dart +++ b/packages/stream_feeds/lib/src/state/comment_list_state.dart @@ -3,6 +3,7 @@ import 'package:state_notifier/state_notifier.dart'; import 'package:stream_core/stream_core.dart'; import '../models/comment_data.dart'; +import '../models/feeds_reaction_data.dart'; import '../models/pagination_data.dart'; import 'query/comments_query.dart'; @@ -15,8 +16,10 @@ part 'comment_list_state.freezed.dart'; class CommentListStateNotifier extends StateNotifier { CommentListStateNotifier({ required CommentListState initialState, + required this.currentUserId, }) : super(initialState); + final String currentUserId; ({Filter? filter, CommentsSort? sort})? _queryConfig; CommentsSort get commentSort => _queryConfig?.sort ?? CommentsSort.last; @@ -59,6 +62,32 @@ class CommentListStateNotifier extends StateNotifier { state = state.copyWith(comments: updatedComments); } + + /// Handles the addition of a reaction to a comment. + void onCommentReactionAdded(String commentId, FeedsReactionData reaction) { + final updatedComments = state.comments.updateNested( + (comment) => comment.id == commentId, + children: (it) => it.replies ?? [], + update: (found) => found.addReaction(reaction, currentUserId), + updateChildren: (parent, replies) => parent.copyWith(replies: replies), + compare: commentSort.compare, + ); + + state = state.copyWith(comments: updatedComments); + } + + /// Handles the removal of a reaction from a comment. + void onCommentReactionRemoved(String commentId, FeedsReactionData reaction) { + final updatedComments = state.comments.updateNested( + (comment) => comment.id == commentId, + children: (it) => it.replies ?? [], + update: (found) => found.removeReaction(reaction, currentUserId), + updateChildren: (parent, replies) => parent.copyWith(replies: replies), + compare: commentSort.compare, + ); + + state = state.copyWith(comments: updatedComments); + } } /// An observable state object that manages the current state of a comment list. diff --git a/packages/stream_feeds/lib/src/state/event/activity_event_handler.dart b/packages/stream_feeds/lib/src/state/event/activity_event_handler.dart index 7ecf793a..a2466c48 100644 --- a/packages/stream_feeds/lib/src/state/event/activity_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/activity_event_handler.dart @@ -4,9 +4,11 @@ import '../../generated/api/models.dart' as api; import '../../models/feed_id.dart'; import '../../models/poll_data.dart'; import '../../models/poll_vote_data.dart'; +import '../../repository/capabilities_repository.dart'; import '../../resolvers/poll/poll_answer_casted.dart'; import '../../resolvers/poll/poll_answer_removed.dart'; import '../activity_state.dart'; +import 'partial_activity_event_handler.dart'; import 'state_event_handler.dart'; /// Event handler for activity real-time updates. @@ -14,16 +16,26 @@ import 'state_event_handler.dart'; /// Processes WebSocket events related to polls and their associated voting /// and updates the activity state accordingly. class ActivityEventHandler implements StateEventHandler { - const ActivityEventHandler({ + ActivityEventHandler({ required this.fid, required this.state, + required this.capabilitiesRepository, }); final FeedId fid; final ActivityStateNotifier state; + final CapabilitiesRepository capabilitiesRepository; + + late final PartialActivityEventHandler _partialActivityEventHandler = + PartialActivityEventHandler( + state: state, + capabilitiesRepository: capabilitiesRepository, + fid: fid, + ); @override - void handleEvent(WsEvent event) { + Future handleEvent(WsEvent event) async { + if (await _partialActivityEventHandler.handleEvent(event)) return; if (event is api.PollClosedFeedEvent) { return state.onPollClosed(event.poll.toModel()); } diff --git a/packages/stream_feeds/lib/src/state/event/comment_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/comment_list_event_handler.dart index 9f4d4062..1c91e40f 100644 --- a/packages/stream_feeds/lib/src/state/event/comment_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/comment_list_event_handler.dart @@ -3,6 +3,7 @@ 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 '../comment_list_state.dart'; import '../query/comments_query.dart'; import 'state_event_handler.dart'; @@ -41,6 +42,20 @@ class CommentListEventHandler implements StateEventHandler { return state.onCommentRemoved(event.comment.id); } + if (event is api.CommentReactionAddedEvent) { + return state.onCommentReactionAdded( + event.comment.id, + event.reaction.toModel(), + ); + } + + if (event is api.CommentReactionDeletedEvent) { + return state.onCommentReactionRemoved( + event.comment.id, + event.reaction.toModel(), + ); + } + // Handle other comment-related events if 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 e9d7b13b..b8b693c8 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 @@ -1,13 +1,9 @@ import 'package:stream_core/stream_core.dart'; import '../../generated/api/models.dart' as api; -import '../../models/activity_data.dart'; import '../../models/activity_pin_data.dart'; import '../../models/aggregated_activity_data.dart'; -import '../../models/bookmark_data.dart'; -import '../../models/comment_data.dart'; import '../../models/feed_data.dart'; -import '../../models/feeds_reaction_data.dart'; import '../../models/follow_data.dart'; import '../../models/mark_activity_data.dart'; import '../../repository/capabilities_repository.dart'; @@ -15,10 +11,12 @@ import '../feed_state.dart'; import '../query/feed_query.dart'; import 'feed_capabilities_mixin.dart'; +import 'partial_activity_event_handler.dart'; +import 'partial_activity_list_event_handler.dart'; import 'state_event_handler.dart'; class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { - const FeedEventHandler({ + FeedEventHandler({ required this.query, required this.state, required this.capabilitiesRepository, @@ -30,99 +28,46 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { @override final CapabilitiesRepository capabilitiesRepository; + late final PartialActivityListEventHandler _partialActivityListEventHandler = + PartialActivityListEventHandler( + state: state, + capabilitiesRepository: capabilitiesRepository, + fid: query.fid, + activityFilter: query.activityFilter, + ); + + late final PartialActivityEventHandler _partialActivityEventHandler = + PartialActivityEventHandler( + state: state, + capabilitiesRepository: capabilitiesRepository, + fid: query.fid, + ); + @override Future handleEvent(WsEvent event) async { - final fid = query.fid; - - bool matchesQueryFilter(ActivityData activity) { - final filter = query.activityFilter; - if (filter == null) return true; - return filter.matches(activity); - } - - if (event is api.ActivityAddedEvent) { - if (event.fid != fid.rawValue) return; - - final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) return; - - state.onActivityAdded(activity); - - final updatedActivity = await withUpdatedFeedCapabilities(activity); - if (updatedActivity != null) state.onActivityUpdated(updatedActivity); - - return; - } - - if (event is api.ActivityUpdatedEvent) { - if (event.fid != fid.rawValue) return; - - final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the updated activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - - final updatedActivity = await withUpdatedFeedCapabilities(activity); - return state.onActivityUpdated(updatedActivity ?? activity); - } - - if (event is api.ActivityDeletedEvent) { - if (event.fid != fid.rawValue) return; - return state.onActivityRemoved(event.activity.toModel()); - } + if (await _partialActivityListEventHandler.handleEvent(event)) return; + if (await _partialActivityEventHandler.handleEvent(event)) return; - if (event is api.ActivityReactionAddedEvent) { - 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); - } - - return state.onReactionAdded(event.reaction.toModel()); - } - - if (event is api.ActivityReactionDeletedEvent) { - 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); - } - - return state.onReactionRemoved(event.reaction.toModel()); - } + final fid = query.fid; if (event is api.ActivityPinnedEvent) { if (event.fid != fid.rawValue) return; - final activity = event.pinnedActivity.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the pinned activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - - return state.onActivityPinned(event.pinnedActivity.toModel()); + state.onActivityPinned(event.pinnedActivity.toModel()); + return; } if (event is api.ActivityUnpinnedEvent) { if (event.fid != fid.rawValue) return; - final activity = event.pinnedActivity.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the unpinned activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - - return state.onActivityUnpinned(event.pinnedActivity.activity.id); + state.onActivityUnpinned(event.pinnedActivity.activity.id); + return; } if (event is api.ActivityMarkEvent) { if (event.fid != fid.rawValue) return; - return state.onActivityMarked(event.toModel()); + state.onActivityMarked(event.toModel()); + return; } if (event is api.NotificationFeedUpdatedEvent) { @@ -133,48 +78,6 @@ 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()); - } - - if (event is api.CommentAddedEvent) { - if (event.fid != fid.rawValue) return; - - final activity = event.activity.toModel(); - if (!matchesQueryFilter(activity)) { - // If the comment's activity no longer matches the filter, remove it - return state.onActivityRemoved(activity); - } - - return state.onCommentAdded(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 - return state.onCommentRemoved(event.comment.toModel()); - } - if (event is api.FeedDeletedEvent) { if (event.fid != fid.rawValue) return; return state.onFeedDeleted(); diff --git a/packages/stream_feeds/lib/src/state/event/partial_activity_event_handler.dart b/packages/stream_feeds/lib/src/state/event/partial_activity_event_handler.dart new file mode 100644 index 00000000..674cdab3 --- /dev/null +++ b/packages/stream_feeds/lib/src/state/event/partial_activity_event_handler.dart @@ -0,0 +1,128 @@ +import '../../../stream_feeds.dart'; +import '../../../stream_feeds.dart' as api; +import '../../models/comment_data.dart'; +import '../../models/feeds_reaction_data.dart'; +import '../../models/poll_data.dart'; +import '../../repository/capabilities_repository.dart'; +import '../../resolvers/resolvers.dart'; +import 'feed_capabilities_mixin.dart'; + +class PartialActivityEventHandler with FeedCapabilitiesMixin { + const PartialActivityEventHandler({ + required this.fid, + required this.state, + required this.capabilitiesRepository, + }); + + final FeedId fid; + final StateWithUpdatableActivity state; + @override + final CapabilitiesRepository capabilitiesRepository; + + Future handleEvent(WsEvent event) async { + if (event is api.ActivityUpdatedEvent) { + if (event.fid != fid.rawValue) return false; + + final activity = event.activity.toModel(); + final updatedActivity = await withUpdatedFeedCapabilities(activity); + state.onActivityUpdated(updatedActivity ?? activity); + return true; + } + + if (event is api.ActivityReactionAddedEvent) { + if (event.fid != fid.rawValue) return false; + + state.onReactionAdded(event.reaction.toModel()); + return true; + } + if (event is api.ActivityReactionDeletedEvent) { + if (event.fid != fid.rawValue) return false; + + state.onReactionRemoved(event.reaction.toModel()); + return true; + } + + if (event is api.CommentAddedEvent) { + if (event.fid != fid.rawValue) return false; + + state.onCommentAdded(event.comment.toModel()); + return true; + } + + if (event is api.CommentDeletedEvent) { + if (event.fid != fid.rawValue) return false; + state.onCommentRemoved(event.comment.toModel()); + return true; + } + + if (event is api.PollClosedFeedEvent) { + state.onPollClosed(event.poll.toModel()); + return true; + } + + if (event is api.PollDeletedFeedEvent) { + state.onPollDeleted(event.poll.id); + return true; + } + + if (event is api.PollUpdatedFeedEvent) { + state.onPollUpdated(event.poll.toModel()); + return true; + } + + if (event is PollAnswerCastedFeedEvent) { + final answer = event.pollVote.toModel(); + final poll = event.poll.toModel(); + state.onPollAnswerCasted(answer, poll); + return true; + } + + if (event is api.PollVoteCastedFeedEvent) { + final vote = event.pollVote.toModel(); + final poll = event.poll.toModel(); + state.onPollVoteCasted(vote, poll); + return true; + } + + if (event is api.PollVoteChangedFeedEvent) { + // Only handle events for this specific feed + final vote = event.pollVote.toModel(); + final poll = event.poll.toModel(); + state.onPollVoteChanged(vote, poll); + return true; + } + + if (event is PollAnswerRemovedFeedEvent) { + final vote = event.pollVote.toModel(); + final poll = event.poll.toModel(); + state.onPollAnswerRemoved(vote, poll); + return true; + } + + if (event is api.PollVoteRemovedFeedEvent) { + final vote = event.pollVote.toModel(); + final poll = event.poll.toModel(); + state.onPollVoteRemoved(vote, poll); + return true; + } + + return false; + } +} + +abstract interface class StateWithUpdatableActivity { + void onActivityUpdated(ActivityData activity); + void onReactionAdded(FeedsReactionData reaction); + void onReactionRemoved(FeedsReactionData reaction); + void onCommentAdded(CommentData comment); + void onCommentRemoved(CommentData comment); + + void onPollClosed(PollData poll); + void onPollDeleted(String pollId); + void onPollUpdated(PollData poll); + void onPollAnswerCasted(PollVoteData answer, PollData poll); + void onPollVoteCasted(PollVoteData vote, PollData poll); + void onPollVoteChanged(PollVoteData vote, PollData poll); + void onPollAnswerRemoved(PollVoteData vote, PollData poll); + void onPollVoteRemoved(PollVoteData vote, PollData poll); +} diff --git a/packages/stream_feeds/lib/src/state/event/partial_activity_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/partial_activity_list_event_handler.dart new file mode 100644 index 00000000..38916dab --- /dev/null +++ b/packages/stream_feeds/lib/src/state/event/partial_activity_list_event_handler.dart @@ -0,0 +1,120 @@ +import '../../../stream_feeds.dart'; +import '../../../stream_feeds.dart' as api; +import '../../repository/capabilities_repository.dart'; +import 'feed_capabilities_mixin.dart'; + +class PartialActivityListEventHandler with FeedCapabilitiesMixin { + const PartialActivityListEventHandler({ + required this.state, + required this.capabilitiesRepository, + this.fid, + this.activityFilter, + }); + + final StateWithListOfActivities state; + final FeedId? fid; + @override + final CapabilitiesRepository capabilitiesRepository; + final ActivitiesFilter? activityFilter; + + bool matchesQueryFilter(ActivityData activity) { + final filter = activityFilter; + if (filter == null) return true; + return filter.matches(activity); + } + + bool removeActivityIfNotMatchesFilter(ActivityData activity) { + if (!matchesQueryFilter(activity)) { + state.onActivityRemoved(activity); + return true; + } + return false; + } + + Future handleEvent(WsEvent event) async { + if (event is api.ActivityAddedEvent) { + if (fid != null && event.fid != fid!.rawValue) return false; + + final activity = event.activity.toModel(); + if (!matchesQueryFilter(activity)) return false; + + state.onActivityAdded(activity); + + final updatedActivity = await withUpdatedFeedCapabilities(activity); + if (updatedActivity != null) state.onActivityUpdated(updatedActivity); + + return true; + } + + if (event is api.ActivityUpdatedEvent) { + if (fid != null && event.fid != fid!.rawValue) return false; + return removeActivityIfNotMatchesFilter(event.activity.toModel()); + } + + if (event is api.ActivityDeletedEvent) { + if (fid != null && event.fid != fid!.rawValue) return false; + state.onActivityRemoved(event.activity.toModel()); + return true; + } + + if (event is api.ActivityReactionAddedEvent) { + if (fid != null && event.fid != fid!.rawValue) return false; + return removeActivityIfNotMatchesFilter(event.activity.toModel()); + } + + if (event is api.ActivityReactionDeletedEvent) { + if (fid != null && event.fid != fid!.rawValue) return false; + return removeActivityIfNotMatchesFilter(event.activity.toModel()); + } + + if (event is api.ActivityPinnedEvent) { + if (fid != null && event.fid != fid!.rawValue) return false; + + return removeActivityIfNotMatchesFilter( + event.pinnedActivity.activity.toModel(), + ); + } + + if (event is api.ActivityUnpinnedEvent) { + if (fid != null && event.fid != fid!.rawValue) return false; + + return removeActivityIfNotMatchesFilter( + event.pinnedActivity.activity.toModel(), + ); + } + + if (event is api.BookmarkAddedEvent) { + final activity = event.bookmark.activity.toModel(); + if (fid != null && !activity.feeds.contains(fid!.rawValue)) return false; + return removeActivityIfNotMatchesFilter(activity); + } + + if (event is api.BookmarkDeletedEvent) { + final activity = event.bookmark.activity.toModel(); + if (fid != null && !activity.feeds.contains(fid!.rawValue)) return false; + + return removeActivityIfNotMatchesFilter(activity); + } + + if (event is api.CommentAddedEvent) { + if (fid != null && event.fid != fid!.rawValue) return false; + + final activity = event.activity.toModel(); + return removeActivityIfNotMatchesFilter(activity); + } + + if (event is api.CommentDeletedEvent) { + if (fid != null && event.fid != fid!.rawValue) return false; + // TODO: Match event activity against filter once available in the event + return false; + } + + return false; + } +} + +abstract interface class StateWithListOfActivities { + void onActivityAdded(ActivityData activity); + void onActivityUpdated(ActivityData activity); + void onActivityRemoved(ActivityData activity); +} diff --git a/packages/stream_feeds/lib/src/state/feed_state.dart b/packages/stream_feeds/lib/src/state/feed_state.dart index 47864fd6..19643f23 100644 --- a/packages/stream_feeds/lib/src/state/feed_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_state.dart @@ -19,7 +19,11 @@ import '../models/follow_data.dart'; import '../models/get_or_create_feed_data.dart'; import '../models/mark_activity_data.dart'; import '../models/pagination_data.dart'; +import '../models/poll_data.dart'; +import '../models/poll_vote_data.dart'; import '../models/query_configuration.dart'; +import 'event/partial_activity_event_handler.dart'; +import 'event/partial_activity_list_event_handler.dart'; import 'member_list_state.dart'; import 'query/activities_query.dart'; import 'query/feed_query.dart'; @@ -30,7 +34,8 @@ part 'feed_state.freezed.dart'; /// /// Provides methods to update the feed state in response to data changes, /// user interactions, and real-time events from the Stream Feeds API. -class FeedStateNotifier extends StateNotifier { +class FeedStateNotifier extends StateNotifier + implements StateWithListOfActivities, StateWithUpdatableActivity { FeedStateNotifier({ required FeedState initialState, required this.currentUserId, @@ -108,6 +113,7 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when a new activity is added. + @override void onActivityAdded(ActivityData activity) { // Upsert the new activity into the existing activities list final updatedActivities = state.activities.sortedUpsert( @@ -120,6 +126,7 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when an activity is updated. + @override void onActivityUpdated(ActivityData activity) { final updatedActivities = state.activities.sortedUpsert( activity, @@ -139,6 +146,7 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when activity is removed. + @override void onActivityRemoved(ActivityData activity) { return onActivityDeleted(activity.id); } @@ -251,6 +259,7 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when a comment is added or removed. + @override void onCommentAdded(CommentData comment) { // Add or update the comment in the activity final updatedActivities = state.activities.map((activity) { @@ -262,6 +271,7 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when a comment is removed. + @override void onCommentRemoved(CommentData comment) { // Remove the comment from the activity final updatedActivities = state.activities.map((activity) { @@ -337,6 +347,7 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when a reaction is added. + @override void onReactionAdded(FeedsReactionData reaction) { // Add or update the reaction in the activity final updatedActivities = state.activities.map((activity) { @@ -348,6 +359,7 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when a reaction is removed. + @override void onReactionRemoved(FeedsReactionData reaction) { // Remove the reaction from the activity final updatedActivities = state.activities.map((activity) { @@ -534,6 +546,128 @@ class FeedStateNotifier extends StateNotifier { _removeMemberListListener?.call(); super.dispose(); } + + ActivityData? _getActivityForPoll(String pollId) { + return state.activities.firstWhereOrNull((it) => it.poll?.id == pollId); + } + + void _updateActivityInState(ActivityData activity) { + state = state.copyWith( + activities: state.activities.upsert(activity, key: (it) => it.id), + ); + } + + /// Handles when a poll is closed. + @override + void onPollClosed(PollData poll) { + final activity = _getActivityForPoll(poll.id); + if (activity == null) return; + + final updatedPoll = activity.poll?.copyWith(isClosed: true); + _updateActivityInState(activity.copyWith(poll: updatedPoll)); + } + + /// Handles when a poll is deleted. + @override + void onPollDeleted(String pollId) { + final activity = _getActivityForPoll(pollId); + if (activity == null) return; + _updateActivityInState(activity.copyWith(poll: null)); + } + + /// Handles when a poll is updated. + @override + void onPollUpdated(PollData poll) { + final activity = _getActivityForPoll(poll.id); + if (activity == null) return; + + final currentPoll = activity.poll!; + + final updatedPoll = poll.copyWith( + latestAnswers: currentPoll.latestAnswers, + ownVotesAndAnswers: currentPoll.ownVotesAndAnswers, + ); + + _updateActivityInState(activity.copyWith(poll: updatedPoll)); + } + + /// Handles when a poll answer is casted. + @override + void onPollAnswerCasted(PollVoteData answer, PollData poll) { + final activity = _getActivityForPoll(poll.id); + if (activity == null) return; + + final currentPoll = activity.poll!; + + final updatedPoll = poll.castAnswer( + answer, + currentUserId, + currentLatestAnswers: currentPoll.latestAnswers, + currentOwnVotesAndAnswers: currentPoll.ownVotesAndAnswers, + ); + + _updateActivityInState(activity.copyWith(poll: updatedPoll)); + } + + /// Handles when a poll vote is casted (with poll data). + @override + void onPollVoteCasted(PollVoteData vote, PollData poll) { + return onPollVoteChanged(vote, poll); + } + + /// Handles when a poll vote is changed. + @override + void onPollVoteChanged(PollVoteData vote, PollData poll) { + final activity = _getActivityForPoll(poll.id); + if (activity == null) return; + + final currentPoll = activity.poll!; + + final updatedPoll = poll.changeVote( + vote, + currentUserId, + currentLatestVotes: currentPoll.latestVotes, + currentOwnVotesAndAnswers: currentPoll.ownVotesAndAnswers, + ); + + _updateActivityInState(activity.copyWith(poll: updatedPoll)); + } + + /// Handles when a poll answer is removed (with poll data). + @override + void onPollAnswerRemoved(PollVoteData answer, PollData poll) { + final activity = _getActivityForPoll(poll.id); + if (activity == null) return; + + final currentPoll = activity.poll!; + + final updatedPoll = poll.removeAnswer( + answer, + currentUserId, + currentLatestAnswers: currentPoll.latestAnswers, + currentOwnVotesAndAnswers: currentPoll.ownVotesAndAnswers, + ); + + _updateActivityInState(activity.copyWith(poll: updatedPoll)); + } + + /// Handles when a poll vote is removed (with poll data). + @override + void onPollVoteRemoved(PollVoteData vote, PollData poll) { + final activity = _getActivityForPoll(poll.id); + if (activity == null) return; + + final currentPoll = activity.poll!; + + final updatedPoll = poll.removeVote( + vote, + currentUserId, + currentLatestVotes: currentPoll.latestVotes, + currentOwnVotesAndAnswers: currentPoll.ownVotesAndAnswers, + ); + + _updateActivityInState(activity.copyWith(poll: updatedPoll)); + } } /// Represents the current state of a feed. diff --git a/packages/stream_feeds/test/state/activity_test.dart b/packages/stream_feeds/test/state/activity_test.dart index 93c24274..2c084f8f 100644 --- a/packages/stream_feeds/test/state/activity_test.dart +++ b/packages/stream_feeds/test/state/activity_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'dart:async'; import 'dart:convert'; @@ -64,7 +66,7 @@ void main() { }); }); - group('Poll events', () { + group('WS events', () { late StreamController wsStreamController; late MockWebSocketSink webSocketSink; @@ -85,8 +87,11 @@ void main() { await wsStreamController.close(); }); - void setupMockActivity({GetActivityResponse? activity}) { - const activityId = 'id'; + void setupMockActivity({ + String activityId = 'id', + GetActivityResponse? activity, + GetCommentsResponse? comments, + }) { when(() => feedsApi.getActivity(id: activityId)).thenAnswer( (_) async => Result.success(activity ?? createDefaultActivityResponse()), @@ -98,10 +103,53 @@ void main() { depth: 3, ), ).thenAnswer( - (_) async => Result.success(createDefaultCommentsResponse()), + (_) async => + Result.success(comments ?? createDefaultCommentsResponse()), ); } + test('poll updated', () async { + final originalDate = DateTime(2021, 1, 1); + final updatedDate = DateTime(2021, 1, 2); + + final poll = createDefaultPollResponseData(updatedAt: originalDate); + setupMockActivity( + activity: createDefaultActivityResponse(poll: poll), + ); + + final activity = client.activity( + activityId: 'id', + fid: const FeedId(group: 'group', id: 'id'), + ); + await activity.get(); + + expect(poll.voteCount, 0); + expect(poll.updatedAt, originalDate); + + activity.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.poll?.id, 'poll-id'); + expect(event.poll?.voteCount, 1); + expect(event.poll?.updatedAt, updatedDate); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + PollUpdatedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: 'fid', + poll: poll.copyWith(voteCount: 1, updatedAt: updatedDate), + type: EventTypes.pollUpdated, + ).toJson(), + ), + ); + }); + test('poll vote casted', () async { final poll = createDefaultPollResponseData(); final pollId = poll.id; @@ -379,5 +427,355 @@ void main() { ), ); }); + + test('comment added', () async { + const activityId = 'activity-id'; + const fid = FeedId(group: 'group', id: 'id'); + final comment1 = createDefaultCommentResponse( + objectId: activityId, + id: 'comment-id-1', + text: 'comment-text-1', + ); + final comment2 = createDefaultCommentResponse( + objectId: activityId, + id: 'comment-id-2', + text: 'comment-text-2', + ); + + final initialComments = createDefaultCommentsResponse( + comments: [ThreadedCommentResponse.fromJson(comment1.toJson())], + ); + + setupMockActivity( + activityId: activityId, + activity: createDefaultActivityResponse( + id: activityId, + comments: [comment1], + ), + comments: initialComments, + ); + + final activity = client.activity( + activityId: activityId, + fid: fid, + ); + final activityData = await activity.get(); + expect(activityData, isA>()); + expect(activityData.getOrNull()?.id, activityId); + expect(activityData.getOrNull()?.comments.length, 1); + + expect(activity.state.activity?.commentCount, 1); + expect(activity.state.comments.length, 1); + + // The event will trigger twice, first with updated count and then with the new comment. + var count = 0; + activity.notifier.stream.listen( + expectAsync1( + count: 2, + (event) { + count++; + if (count == 1) { + expect(event, isA()); + expect(event.comments.length, 2); + } + if (count == 2) { + expect(event, isA()); + expect(event.activity?.commentCount, 2); + } + }, + ), + ); + wsStreamController.add( + jsonEncode( + CommentAddedEvent( + type: EventTypes.commentAdded, + activity: createDefaultActivityResponse().activity, + createdAt: DateTime.now(), + custom: const {}, + fid: fid.rawValue, + comment: comment2, + ), + ), + ); + }); + + test('comment updated', () async { + const activityId = 'activity-id'; + const fid = FeedId(group: 'group', id: 'id'); + final comment = createDefaultCommentResponse( + objectId: activityId, + id: 'comment-id', + text: 'comment-text', + ); + + final initialComments = createDefaultCommentsResponse( + comments: [ThreadedCommentResponse.fromJson(comment.toJson())], + ); + + setupMockActivity( + activityId: activityId, + activity: createDefaultActivityResponse( + id: activityId, + comments: [comment], + ), + comments: initialComments, + ); + + final activity = client.activity( + activityId: activityId, + fid: fid, + ); + final activityData = await activity.get(); + expect(activityData, isA>()); + expect(activityData.getOrNull()?.id, activityId); + expect(activityData.getOrNull()?.comments.length, 1); + + expect(activity.state.activity?.commentCount, 1); + expect(activity.state.comments.first.text, 'comment-text'); + + // The event will trigger twice, first with updated count and then with the new comment. + activity.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activity?.commentCount, 1); + expect(event.comments.first.text, 'comment-text-2'); + }, + ), + ); + wsStreamController.add( + jsonEncode( + CommentUpdatedEvent( + type: EventTypes.commentUpdated, + createdAt: DateTime.now(), + custom: const {}, + fid: fid.rawValue, + comment: comment.copyWith(text: 'comment-text-2'), + ), + ), + ); + }); + + test('comment removed', () async { + const activityId = 'activity-id'; + const fid = FeedId(group: 'group', id: 'id'); + final comment = createDefaultCommentResponse( + objectId: activityId, + id: 'comment-id', + text: 'comment-text', + ); + + final initialComments = createDefaultCommentsResponse( + comments: [ThreadedCommentResponse.fromJson(comment.toJson())], + ); + + setupMockActivity( + activityId: activityId, + activity: createDefaultActivityResponse( + id: activityId, + comments: [comment], + ), + comments: initialComments, + ); + + final activity = client.activity( + activityId: activityId, + fid: fid, + ); + final activityData = await activity.get(); + expect(activityData, isA>()); + expect(activityData.getOrNull()?.id, activityId); + expect(activityData.getOrNull()?.comments.length, 1); + + expect(activity.state.activity?.commentCount, 1); + expect(activity.state.comments.length, 1); + + // The event will trigger twice, first with updated count and then with the new comment. + var count = 0; + activity.notifier.stream.listen( + expectAsync1( + count: 2, + (event) { + count++; + if (count == 1) { + expect(event.comments, isEmpty); + } + if (count == 2) { + expect(event, isA()); + expect(event.activity?.commentCount, 0); + } + }, + ), + ); + wsStreamController.add( + jsonEncode( + CommentDeletedEvent( + type: EventTypes.commentDeleted, + createdAt: DateTime.now(), + custom: const {}, + fid: fid.rawValue, + comment: comment, + ), + ), + ); + }); + + test('activity reaction added', () async { + const activityId = 'activity-id'; + const fid = FeedId(group: 'group', id: 'id'); + + final activityResponse = createDefaultActivityResponse(id: activityId); + + setupMockActivity( + activityId: activityId, + activity: activityResponse, + ); + + final activity = client.activity( + activityId: activityId, + fid: fid, + ); + final activityData = await activity.get(); + expect(activityData, isA>()); + expect(activityData.getOrNull()?.id, activityId); + expect(activityData.getOrNull()?.reactionCount, 0); + + activity.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activity?.reactionCount, 1); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.now(), + custom: const {}, + fid: fid.rawValue, + activity: activityResponse.activity, + reaction: FeedsReactionResponse( + activityId: activityId, + type: 'like', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + user: createDefaultUserResponse(), + ), + ), + ), + ); + }); + + test('activity reaction not added when activity id does not match', + () async { + const activityId = 'activity-id'; + const fid = FeedId(group: 'group', id: 'id'); + + final activityResponse = createDefaultActivityResponse(id: activityId); + + setupMockActivity( + activityId: activityId, + activity: activityResponse, + ); + + final activity = client.activity( + activityId: activityId, + fid: fid, + ); + final activityData = await activity.get(); + expect(activityData, isA>()); + expect(activityData.getOrNull()?.id, activityId); + expect(activityData.getOrNull()?.reactionCount, 0); + + activity.notifier.stream.listen( + expectAsync1( + count: 0, + (event) {}, + ), + ); + + wsStreamController.add( + jsonEncode( + ActivityReactionAddedEvent( + type: EventTypes.activityReactionAdded, + createdAt: DateTime.now(), + custom: const {}, + fid: fid.rawValue, + activity: activityResponse.activity, + reaction: FeedsReactionResponse( + activityId: 'other-activity-id', + type: 'like', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + user: createDefaultUserResponse(), + ), + ), + ), + ); + }); + + test('activity reaction deleted', () async { + const activityId = 'activity-id'; + const fid = FeedId(group: 'group', id: 'id'); + final dateReaction = DateTime(2025, 1, 1); + + final activityResponse = createDefaultActivityResponse( + id: activityId, + reactionGroups: { + 'like': ReactionGroupResponse( + count: 1, + firstReactionAt: dateReaction, + lastReactionAt: dateReaction, + ), + }, + ); + + setupMockActivity( + activityId: activityId, + activity: activityResponse, + ); + final activity = client.activity( + activityId: activityId, + fid: fid, + ); + + final activityData = await activity.get(); + expect(activityData, isA>()); + expect(activityData.getOrNull()?.id, activityId); + expect(activityData.getOrNull()?.reactionCount, 1); + expect(activityData.getOrNull()?.reactionGroups.length, 1); + + activity.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activity?.reactionCount, 0); + expect(event.activity?.reactionGroups.length, 0); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + ActivityReactionDeletedEvent( + type: EventTypes.activityReactionDeleted, + createdAt: DateTime.now(), + custom: const {}, + fid: fid.rawValue, + activity: activityResponse.activity, + reaction: FeedsReactionResponse( + activityId: activityId, + type: 'like', + createdAt: dateReaction, + updatedAt: DateTime.now(), + user: createDefaultUserResponse(), + ), + ), + ), + ); + }); }); } diff --git a/packages/stream_feeds/test/state/comment_list_test.dart b/packages/stream_feeds/test/state/comment_list_test.dart index b0d33630..d7cfc829 100644 --- a/packages/stream_feeds/test/state/comment_list_test.dart +++ b/packages/stream_feeds/test/state/comment_list_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'dart:async'; import 'dart:convert'; @@ -59,7 +61,17 @@ void main() { comments: [ createDefaultCommentResponse(id: 'comment-1', objectId: 'obj-1'), createDefaultCommentResponse(id: 'comment-2', objectId: 'obj-1'), - createDefaultCommentResponse(id: 'comment-3', objectId: 'obj-1'), + createDefaultCommentResponse( + id: 'comment-3', + objectId: 'obj-1', + reactionGroups: { + 'like': ReactionGroupResponse( + count: 1, + firstReactionAt: DateTime(2025, 1, 1), + lastReactionAt: DateTime(2025, 1, 1), + ), + }, + ), ], ), ), @@ -136,5 +148,90 @@ void main() { expect(commentList.state.comments, hasLength(3)); }, ); + + test('comment reaction added', () async { + final commentList = client.commentList( + CommentsQuery( + filter: Filter.equal(CommentsFilterField.status, 'active'), + ), + ); + await commentList.get(); + expect(commentList.state.comments, hasLength(3)); + expect(commentList.state.comments[1].reactionCount, 0); + expect(commentList.state.comments[1].reactionGroups.length, 0); + + wsStreamController.add( + jsonEncode( + CommentReactionAddedEvent( + type: EventTypes.commentReactionAdded, + activity: createDefaultActivityResponse().activity, + comment: createDefaultCommentResponse( + id: 'comment-2', + objectId: 'obj-1', + ), + createdAt: DateTime.now(), + custom: const {}, + fid: 'user:id', + reaction: FeedsReactionResponse( + activityId: 'obj-1', + commentId: 'comment-2', + type: 'like', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + user: createDefaultUserResponse(), + ), + ), + ), + ); + + await Future.delayed(Duration.zero); + + expect(commentList.state.comments, hasLength(3)); + expect(commentList.state.comments[1].reactionCount, 1); + expect(commentList.state.comments[1].reactionGroups.length, 1); + expect(commentList.state.comments[1].reactionGroups['like']?.count, 1); + }); + + test('comment reaction deleted', () async { + final commentList = client.commentList( + CommentsQuery( + filter: Filter.equal(CommentsFilterField.status, 'active'), + ), + ); + await commentList.get(); + expect(commentList.state.comments, hasLength(3)); + expect(commentList.state.comments[2].reactionCount, 1); + expect(commentList.state.comments[2].reactionGroups.length, 1); + expect(commentList.state.comments[2].reactionGroups['like']?.count, 1); + + wsStreamController.add( + jsonEncode( + CommentReactionDeletedEvent( + type: EventTypes.commentReactionDeleted, + createdAt: DateTime.now(), + custom: const {}, + fid: 'user:id', + comment: createDefaultCommentResponse( + id: 'comment-3', + objectId: 'obj-1', + ), + reaction: FeedsReactionResponse( + activityId: 'obj-1', + commentId: 'comment-3', + type: 'like', + createdAt: DateTime(2025, 1, 1), + updatedAt: DateTime.now(), + user: createDefaultUserResponse(), + ), + ), + ), + ); + + await Future.delayed(Duration.zero); + + expect(commentList.state.comments, hasLength(3)); + expect(commentList.state.comments[2].reactionCount, 0); + expect(commentList.state.comments[2].reactionGroups.length, 0); + }); }); } diff --git a/packages/stream_feeds/test/state/feed_test.dart b/packages/stream_feeds/test/state/feed_test.dart index 34741695..13550ae0 100644 --- a/packages/stream_feeds/test/state/feed_test.dart +++ b/packages/stream_feeds/test/state/feed_test.dart @@ -885,6 +885,349 @@ void main() { ); }); + group('Poll events', () { + late StreamController wsStreamController; + late MockWebSocketSink webSocketSink; + const defaultFeedId = FeedId(group: 'group', id: 'id'); + + setUp(() async { + wsStreamController = StreamController(); + webSocketSink = MockWebSocketSink(); + WsTestConnection( + wsStreamController: wsStreamController, + webSocketSink: webSocketSink, + webSocketChannel: webSocketChannel, + ).setUp(); + + await client.connect(); + }); + + tearDown(() async { + await webSocketSink.close(); + await wsStreamController.close(); + }); + + void setupMockFeed({ + FeedId feedId = defaultFeedId, + List activities = const [], + }) { + // Setup default mock response + when( + () => feedsApi.getOrCreateFeed( + feedGroupId: feedId.group, + feedId: feedId.id, + getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), + ), + ).thenAnswer( + (_) async => Result.success( + createDefaultGetOrCreateFeedResponse().copyWith( + activities: activities, + ), + ), + ); + } + + test('poll updated', () async { + final originalDate = DateTime(2021, 1, 1); + final updatedDate = DateTime(2021, 1, 2); + final poll = createDefaultPollResponseData(updatedAt: originalDate); + final pollId = poll.id; + setupMockFeed( + activities: [createDefaultActivityResponse(poll: poll).activity], + ); + + final feed = client.feedFromId(defaultFeedId); + await feed.getOrCreate(); + + expect(poll.updatedAt, originalDate); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activities.first.poll?.id, pollId); + expect(event.activities.first.poll?.updatedAt, updatedDate); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + PollUpdatedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: 'fid', + poll: poll.copyWith(updatedAt: updatedDate), + type: EventTypes.pollUpdated, + ).toJson(), + ), + ); + }); + + test('poll vote casted', () async { + final poll = createDefaultPollResponseData(); + final pollId = poll.id; + final firstOptionId = poll.options.first.id; + + setupMockFeed( + activities: [createDefaultActivityResponse(poll: poll).activity], + ); + + final feed = client.feedFromId(defaultFeedId); + await feed.getOrCreate(); + + expect(poll.voteCount, 0); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activities.first.poll?.id, pollId); + expect(event.activities.first.poll?.voteCount, 1); + }, + ), + ); + wsStreamController.add( + jsonEncode( + PollVoteCastedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: 'fid', // Feed Id doesn't matter for poll vote casted event + poll: poll.copyWith(voteCount: 1), + pollVote: PollVoteResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: 'voteId1', + optionId: firstOptionId, + pollId: pollId, + ), + type: EventTypes.pollVoteCasted, + ).toJson(), + ), + ); + }); + + test('poll answer casted', () async { + final poll = createDefaultPollResponseData(); + final pollId = poll.id; + setupMockFeed( + activities: [createDefaultActivityResponse(poll: poll).activity], + ); + + final feed = client.feedFromId(defaultFeedId); + await feed.getOrCreate(); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activities.first.poll?.id, pollId); + expect(event.activities.first.poll?.answersCount, 1); + expect(event.activities.first.poll?.latestAnswers.length, 1); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + PollVoteCastedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: 'fid', + poll: poll.copyWith(answersCount: 1), + pollVote: PollVoteResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: 'voteId1', + answerText: 'answerText1', + isAnswer: true, + optionId: 'optionId1', + pollId: pollId, + ), + type: EventTypes.pollVoteCasted, + ), + ), + ); + }); + + test('poll answer removed', () async { + final poll = createDefaultPollResponseData( + latestAnswers: [ + PollVoteResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: 'voteId1', + answerText: 'answerText1', + isAnswer: true, + optionId: 'optionId1', + pollId: 'pollId1', + ), + ], + ); + final pollId = poll.id; + setupMockFeed( + activities: [createDefaultActivityResponse(poll: poll).activity], + ); + + final feed = client.feedFromId(defaultFeedId); + await feed.getOrCreate(); + + expect(poll.answersCount, 1); + expect(poll.latestAnswers.length, 1); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activities.first.poll?.id, pollId); + expect(event.activities.first.poll?.answersCount, 0); + expect(event.activities.first.poll?.latestAnswers.length, 0); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + PollVoteRemovedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: 'fid', + poll: poll.copyWith(answersCount: 0), + pollVote: PollVoteResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: 'voteId1', + answerText: 'answerText1', + isAnswer: true, + optionId: 'optionId1', + pollId: pollId, + ), + type: EventTypes.pollVoteRemoved, + ), + ), + ); + }); + + test('poll vote removed', () async { + final poll = createDefaultPollResponseData( + latestVotesByOption: { + 'optionId1': [ + PollVoteResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: 'voteId1', + optionId: 'optionId1', + pollId: 'pollId1', + ), + ], + }, + ); + final pollId = poll.id; + setupMockFeed( + activities: [createDefaultActivityResponse(poll: poll).activity], + ); + + final feed = client.feedFromId(defaultFeedId); + await feed.getOrCreate(); + + expect(poll.voteCount, 1); + expect(poll.latestVotesByOption.length, 1); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activities.first.poll?.id, pollId); + expect(event.activities.first.poll?.voteCount, 0); + expect(event.activities.first.poll?.latestVotesByOption.length, 0); + }, + ), + ); + wsStreamController.add( + jsonEncode( + PollVoteRemovedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: 'fid', + poll: poll.copyWith(voteCount: 0, latestVotesByOption: {}), + pollVote: PollVoteResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: 'voteId1', + optionId: 'optionId1', + pollId: pollId, + ), + type: EventTypes.pollVoteRemoved, + ), + ), + ); + }); + + test('poll closed', () async { + final poll = createDefaultPollResponseData(); + final pollId = poll.id; + setupMockFeed( + activities: [createDefaultActivityResponse(poll: poll).activity], + ); + + final feed = client.feedFromId(defaultFeedId); + await feed.getOrCreate(); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activities.first.poll?.id, pollId); + expect(event.activities.first.poll?.isClosed, true); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + PollClosedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: 'fid', + poll: poll.copyWith(isClosed: true), + type: EventTypes.pollClosed, + ), + ), + ); + }); + + test('poll deleted', () async { + final poll = createDefaultPollResponseData(); + setupMockFeed( + activities: [createDefaultActivityResponse(poll: poll).activity], + ); + + final feed = client.feedFromId(defaultFeedId); + await feed.getOrCreate(); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.activities.first.poll, null); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + PollDeletedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: 'fid', + poll: poll, + type: EventTypes.pollDeleted, + ), + ), + ); + }); + }); + group('Story events', () { late StreamController wsStreamController; late MockWebSocketSink webSocketSink; diff --git a/packages/stream_feeds/test/test_utils/event_types.dart b/packages/stream_feeds/test/test_utils/event_types.dart index 2eca0d8f..5430ae2f 100644 --- a/packages/stream_feeds/test/test_utils/event_types.dart +++ b/packages/stream_feeds/test/test_utils/event_types.dart @@ -1,5 +1,14 @@ class EventTypes { static const String activityMarked = 'feeds.activity.marked'; + static const String activityReactionAdded = 'feeds.activity.reaction.added'; + static const String activityReactionDeleted = + 'feeds.activity.reaction.deleted'; + + static const String commentAdded = 'feeds.comment.added'; + static const String commentUpdated = 'feeds.comment.updated'; + static const String commentDeleted = 'feeds.comment.deleted'; + static const String commentReactionAdded = 'feeds.comment.reaction.added'; + static const String commentReactionDeleted = 'feeds.comment.reaction.deleted'; static const String followCreated = 'feeds.follow.created'; static const String followDeleted = 'feeds.follow.deleted'; @@ -7,6 +16,7 @@ class EventTypes { static const String pollClosed = 'feeds.poll.closed'; static const String pollDeleted = 'feeds.poll.deleted'; + static const String pollUpdated = 'feeds.poll.updated'; static const String pollVoteCasted = 'feeds.poll.vote_casted'; static const String pollVoteRemoved = 'feeds.poll.vote_removed'; diff --git a/packages/stream_feeds/test/test_utils/fakes.dart b/packages/stream_feeds/test/test_utils/fakes.dart index 041922b0..caa6821a 100644 --- a/packages/stream_feeds/test/test_utils/fakes.dart +++ b/packages/stream_feeds/test/test_utils/fakes.dart @@ -2,9 +2,11 @@ import 'package:stream_feeds/stream_feeds.dart'; -GetCommentsResponse createDefaultCommentsResponse() => - const GetCommentsResponse( - comments: [], +GetCommentsResponse createDefaultCommentsResponse({ + List comments = const [], +}) => + GetCommentsResponse( + comments: comments, next: null, prev: null, duration: 'duration', @@ -35,14 +37,16 @@ GetActivityResponse createDefaultActivityResponse({ String type = 'post', List feeds = const [], PollResponseData? poll, + List comments = const [], + Map reactionGroups = const {}, }) { return GetActivityResponse( activity: ActivityResponse( id: id, attachments: const [], bookmarkCount: 0, - commentCount: 0, - comments: const [], + commentCount: comments.length, + comments: comments, createdAt: DateTime(2021, 1, 1), custom: const {}, feeds: feeds, @@ -57,8 +61,8 @@ GetActivityResponse createDefaultActivityResponse({ parent: null, poll: poll, popularity: 0, - reactionCount: 0, - reactionGroups: const {}, + reactionCount: reactionGroups.values.fold(0, (v, e) => v + e.count), + reactionGroups: reactionGroups, score: 0, searchData: const {}, shareCount: 0, @@ -77,6 +81,7 @@ PollResponseData createDefaultPollResponseData({ String id = 'poll-id', List latestAnswers = const [], Map> latestVotesByOption = const {}, + DateTime? updatedAt, }) => PollResponseData( id: id, @@ -92,7 +97,7 @@ PollResponseData createDefaultPollResponseData({ latestAnswers: latestAnswers, latestVotesByOption: latestVotesByOption, ownVotes: const [], - updatedAt: DateTime.now(), + updatedAt: updatedAt ?? DateTime.now(), voteCount: latestVotesByOption.values .map((e) => e.length) .fold(0, (v, e) => v + e), @@ -164,8 +169,9 @@ FeedResponse createDefaultFeedResponse({ CommentResponse createDefaultCommentResponse({ String id = 'id', required String objectId, - String objectType = 'post', + String objectType = 'activity', String? text, + Map reactionGroups = const {}, }) { return CommentResponse( id: id, @@ -177,7 +183,8 @@ CommentResponse createDefaultCommentResponse({ objectId: objectId, objectType: objectType, ownReactions: const [], - reactionCount: 0, + reactionCount: reactionGroups.values.fold(0, (v, e) => v + e.count), + reactionGroups: reactionGroups, replyCount: 0, score: 0, status: 'status', diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/show_poll_widget.dart b/sample_app/lib/screens/user_feed/polls/show_poll/show_poll_widget.dart index c95ab351..16f3d06a 100644 --- a/sample_app/lib/screens/user_feed/polls/show_poll/show_poll_widget.dart +++ b/sample_app/lib/screens/user_feed/polls/show_poll/show_poll_widget.dart @@ -20,14 +20,10 @@ class ShowPollWidget extends StatefulWidget { State createState() => _ShowPollWidgetState(); } -class _ShowPollWidgetState extends State - with AutomaticKeepAliveClientMixin { +class _ShowPollWidgetState extends State { StreamFeedsClient get client => locator(); late Activity activity; - @override - bool get wantKeepAlive => true; - @override void initState() { super.initState(); @@ -50,19 +46,17 @@ class _ShowPollWidgetState extends State } void _getActivity() { + // Because we already have up to date initial data, we don't need to fetch it again. + // The activity is being updated in real-time while this widget lives. activity = client.activity( activityId: widget.activity.id, fid: widget.feed.fid, initialData: widget.activity, ); - - activity.get().ignore(); } @override Widget build(BuildContext context) { - super.build(context); - return StateNotifierBuilder( stateNotifier: activity.notifier, builder: (context, state, child) {