diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 7b8a1f1..21c9aa6 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -3,6 +3,7 @@ - Add appeal-related methods to moderation client: `appeal`, `getAppeal`, and `queryAppeals`. - Add `activityCount` field to `FeedData` model to track the number of activities in a feed. - Add `ownFollowings` field to `FeedData` model to track feeds that the current user is following from this feed. +- Add batch follow and unfollow support. ## 0.5.0 - [BREAKING] Unified `ThreadedCommentData` into `CommentData` to handle both flat and threaded comments. 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 0081189..28ff929 100644 --- a/packages/stream_feeds/lib/src/client/feeds_client_impl.dart +++ b/packages/stream_feeds/lib/src/client/feeds_client_impl.dart @@ -9,8 +9,11 @@ import '../feeds_client.dart'; import '../generated/api/api.dart' as api; import '../models/activity_data.dart'; import '../models/app_data.dart'; +import '../models/batch_follow_data.dart'; import '../models/feed_id.dart'; import '../models/feeds_config.dart'; +import '../models/follow_data.dart'; +import '../models/model_updates.dart'; import '../models/push_notifications_config.dart'; import '../repository/activities_repository.dart'; import '../repository/app_repository.dart'; @@ -502,6 +505,42 @@ class StreamFeedsClientImpl implements StreamFeedsClient { return _cdnClient.deleteImage(url); } + @override + Future> getOrCreateFollows( + api.FollowBatchRequest request, + ) async { + final result = await _feedsRepository.getOrCreateFollows(request); + + result.onSuccess((data) { + final createdIds = data.created.map((f) => f.id).toSet(); + final updates = ModelUpdates( + added: data.created, + updated: data.follows.where((f) => !createdIds.contains(f.id)).toList(), + ); + + _stateUpdateEmitter.tryEmit(FollowBatchUpdate(updates: updates)); + }); + + return result; + } + + @override + Future>> getOrCreateUnfollows( + api.UnfollowBatchRequest request, + ) async { + final result = await _feedsRepository.getOrCreateUnfollows(request); + + result.onSuccess((data) { + final updates = ModelUpdates( + removedIds: data.map((f) => f.id).toSet(), + ); + + _stateUpdateEmitter.tryEmit(FollowBatchUpdate(updates: updates)); + }); + + return result; + } + Stream get onReconnectEmitter { return connectionState.scan( (state, connectionStatus, i) => switch (connectionStatus) { diff --git a/packages/stream_feeds/lib/src/feeds_client.dart b/packages/stream_feeds/lib/src/feeds_client.dart index 081c5eb..61eb562 100644 --- a/packages/stream_feeds/lib/src/feeds_client.dart +++ b/packages/stream_feeds/lib/src/feeds_client.dart @@ -6,8 +6,10 @@ import 'client/moderation_client.dart'; import 'generated/api/api.dart' as api; import 'models/activity_data.dart'; import 'models/app_data.dart'; +import 'models/batch_follow_data.dart'; import 'models/feed_id.dart'; import 'models/feeds_config.dart'; +import 'models/follow_data.dart'; import 'models/push_notifications_config.dart'; import 'state/activity.dart'; import 'state/activity_comment_list.dart'; @@ -779,6 +781,77 @@ abstract interface class StreamFeedsClient { /// Returns a [Result] indicating success or failure of the deletion operation. Future> deleteImage({required String url}); + /// Creates or updates multiple follow relationships in a batch operation. + /// + /// Creates or updates follow relationships for multiple feeds using the provided [request] data. + /// Returns both newly created follows and existing follows that were already present. + /// + /// Example: + /// ```dart + /// final result = await client.getOrCreateFollows( + /// api.FollowBatchRequest( + /// follows: [ + /// api.FollowRequest( + /// source: FeedId.user('john').rawValue, + /// target: FeedId.user('jane').rawValue, + /// ), + /// api.FollowRequest( + /// source: FeedId.user('john').rawValue, + /// target: FeedId.user('bob').rawValue, + /// ), + /// ], + /// ), + /// ); + /// + /// switch (result) { + /// case Success(value: final batchFollowData): + /// print('Created ${batchFollowData.created.length} new follows'); + /// print('Total follows: ${batchFollowData.follows.length}'); + /// case Failure(error: final error): + /// print('Failed to get or create follows: $error'); + /// } + /// ``` + /// + /// Returns a [Result] containing a [BatchFollowData] with the follows or an error. + Future> getOrCreateFollows( + api.FollowBatchRequest request, + ); + + /// Removes multiple follow relationships in a batch operation. + /// + /// Unfollows multiple feeds using the provided [request] data. Returns the list of + /// follow relationships that were removed. + /// + /// Example: + /// ```dart + /// final result = await client.getOrCreateUnfollows( + /// api.UnfollowBatchRequest( + /// follows: [ + /// api.FollowPair( + /// source: FeedId.user('john').rawValue, + /// target: FeedId.user('jane').rawValue, + /// ), + /// api.FollowPair( + /// source: FeedId.user('john').rawValue, + /// target: FeedId.user('bob').rawValue, + /// ), + /// ], + /// ), + /// ); + /// + /// switch (result) { + /// case Success(value: final unfollowedFollows): + /// print('Unfollowed ${unfollowedFollows.length} feeds'); + /// case Failure(error: final error): + /// print('Failed to unfollow feeds: $error'); + /// } + /// ``` + /// + /// Returns a [Result] containing a list of [FollowData] with the unfollowed feeds or an error. + Future>> getOrCreateUnfollows( + api.UnfollowBatchRequest request, + ); + /// The moderation client for managing moderation-related operations. /// /// Provides access to moderation configurations, content moderation, and moderation-related diff --git a/packages/stream_feeds/lib/src/models.dart b/packages/stream_feeds/lib/src/models.dart index dce840e..b2871c7 100644 --- a/packages/stream_feeds/lib/src/models.dart +++ b/packages/stream_feeds/lib/src/models.dart @@ -2,6 +2,7 @@ export 'models/activity_data.dart'; export 'models/activity_pin_data.dart'; export 'models/aggregated_activity_data.dart'; export 'models/app_data.dart'; +export 'models/batch_follow_data.dart'; export 'models/bookmark_data.dart'; export 'models/bookmark_folder_data.dart'; export 'models/comment_data.dart'; @@ -15,6 +16,7 @@ export 'models/feeds_config.dart'; export 'models/feeds_reaction_data.dart'; export 'models/file_upload_config_data.dart'; export 'models/follow_data.dart'; +export 'models/model_updates.dart'; export 'models/moderation.dart'; export 'models/moderation_config_data.dart'; export 'models/pagination_data.dart'; diff --git a/packages/stream_feeds/lib/src/models/batch_follow_data.dart b/packages/stream_feeds/lib/src/models/batch_follow_data.dart new file mode 100644 index 0000000..834c9bd --- /dev/null +++ b/packages/stream_feeds/lib/src/models/batch_follow_data.dart @@ -0,0 +1,46 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../generated/api/models.dart'; +import 'follow_data.dart'; + +part 'batch_follow_data.freezed.dart'; + +/// Represents the result of a batch follow operation. +/// +/// Contains information about follows that were created and all follows +/// (including existing and newly created ones). +@freezed +class BatchFollowData with _$BatchFollowData { + const BatchFollowData({ + required this.created, + required this.follows, + }); + + /// The follows that were created as a result of the batch operation. + /// + /// Contains only the newly created follow relationships. For getOrCreate + /// operations, this will be empty if all follows already existed. + @override + final List created; + + /// All follows, including existing and newly created ones. + /// + /// Contains the complete list of follow relationships returned from the + /// batch operation, regardless of whether they were newly created or already existed. + @override + final List follows; +} + +/// Extension function to convert a [FollowBatchResponse] to a [BatchFollowData] model. +extension FollowBatchResponseMapper on FollowBatchResponse { + /// Converts this API batch follow response to a domain [BatchFollowData] instance. + /// + /// Maps the created and follows lists from API responses to domain models + /// with proper type conversions. + BatchFollowData toModel() { + return BatchFollowData( + created: created.map((f) => f.toModel()).toList(), + follows: follows.map((f) => f.toModel()).toList(), + ); + } +} diff --git a/packages/stream_feeds/lib/src/models/batch_follow_data.freezed.dart b/packages/stream_feeds/lib/src/models/batch_follow_data.freezed.dart new file mode 100644 index 0000000..4ad0d5c --- /dev/null +++ b/packages/stream_feeds/lib/src/models/batch_follow_data.freezed.dart @@ -0,0 +1,88 @@ +// 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 'batch_follow_data.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BatchFollowData { + List get created; + List get follows; + + /// Create a copy of BatchFollowData + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $BatchFollowDataCopyWith get copyWith => + _$BatchFollowDataCopyWithImpl( + this as BatchFollowData, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is BatchFollowData && + const DeepCollectionEquality().equals(other.created, created) && + const DeepCollectionEquality().equals(other.follows, follows)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(created), + const DeepCollectionEquality().hash(follows)); + + @override + String toString() { + return 'BatchFollowData(created: $created, follows: $follows)'; + } +} + +/// @nodoc +abstract mixin class $BatchFollowDataCopyWith<$Res> { + factory $BatchFollowDataCopyWith( + BatchFollowData value, $Res Function(BatchFollowData) _then) = + _$BatchFollowDataCopyWithImpl; + @useResult + $Res call({List created, List follows}); +} + +/// @nodoc +class _$BatchFollowDataCopyWithImpl<$Res> + implements $BatchFollowDataCopyWith<$Res> { + _$BatchFollowDataCopyWithImpl(this._self, this._then); + + final BatchFollowData _self; + final $Res Function(BatchFollowData) _then; + + /// Create a copy of BatchFollowData + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? created = null, + Object? follows = null, + }) { + return _then(BatchFollowData( + created: null == created + ? _self.created + : created // ignore: cast_nullable_to_non_nullable + as List, + follows: null == follows + ? _self.follows + : follows // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +// dart format on diff --git a/packages/stream_feeds/lib/src/models/follow_data.dart b/packages/stream_feeds/lib/src/models/follow_data.dart index f86b384..64d6b0a 100644 --- a/packages/stream_feeds/lib/src/models/follow_data.dart +++ b/packages/stream_feeds/lib/src/models/follow_data.dart @@ -100,6 +100,14 @@ class FollowData with _$FollowData { /// - fid: The feed ID to check against. /// - Returns: true if this is an accepted follow relationship where the source feed matches the given ID. bool isFollowingFeed(FeedId fid) => isFollowing && sourceFeed.fid == fid; + + /// Checks if this is a follow request for the specified feed. + /// + /// - Parameters: + /// - fid: The feed ID to check against. + /// - Returns: true if this is a pending follow request where the target feed matches the given ID. + bool isFollowRequestFor(FeedId fid) => + isFollowRequest && targetFeed.fid == fid; } /// Extension type representing the status of a follow relationship. diff --git a/packages/stream_feeds/lib/src/models/model_updates.dart b/packages/stream_feeds/lib/src/models/model_updates.dart index 21a1d02..775366b 100644 --- a/packages/stream_feeds/lib/src/models/model_updates.dart +++ b/packages/stream_feeds/lib/src/models/model_updates.dart @@ -1,4 +1,6 @@ +import 'package:collection/collection.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_core/stream_core.dart'; part 'model_updates.freezed.dart'; @@ -12,7 +14,7 @@ class ModelUpdates with _$ModelUpdates { /// Creates a new [ModelUpdates] instance. const ModelUpdates({ this.added = const [], - this.removedIds = const [], + this.removedIds = const {}, this.updated = const [], }); @@ -20,11 +22,58 @@ class ModelUpdates with _$ModelUpdates { @override final List added; - /// A list of IDs of items that have been removed from the collection. + /// A set of IDs of items that have been removed from the collection. @override - final List removedIds; + final Set removedIds; /// A list of items that have been updated in the collection. @override final List updated; } + +/// Extension providing utilities for working with [ModelUpdates]. +extension ModelUpdatesExtension on ModelUpdates { + /// Applies these updates to the given [list]. + /// + /// Applies all changes from this [ModelUpdates] instance to the provided list: + /// - Replaces items in [list] that match items in [updated] by their key + /// - Adds items from [added] that don't already exist in [list] + /// - Removes items from [list] whose keys are in [removedIds] + /// + /// The [key] function is used to extract a unique identifier from each item + /// for matching and comparison purposes. + /// + /// When [compare] is provided, the resulting list will be sorted using the + /// comparator. This is useful for maintaining a specific sort order after + /// applying updates. + /// + /// Returns a new list with all updates applied. The original [list] is not modified. + /// + /// Example: + /// ```dart + /// final updates = ModelUpdates( + /// added: [newFollow1, newFollow2], + /// updated: [updatedFollow], + /// removedIds: {'follow-id-to-remove'}, + /// ); + /// + /// final updatedList = updates.applyTo( + /// currentFollows, + /// key: (follow) => follow.id, + /// compare: followsSort.compare, + /// ); + /// ``` + List applyTo( + List list, { + required String Function(T item) key, + Comparator? compare, + }) { + final updatedList = list + .batchReplace(updated, key: key) // replace updated items + .merge(added, key: key) // merge added items + .whereNot((it) => removedIds.contains(key(it))); // remove deleted items + + // Return sorted list if comparator is provided + return compare?.let(updatedList.sorted) ?? updatedList.toList(); + } +} diff --git a/packages/stream_feeds/lib/src/models/model_updates.freezed.dart b/packages/stream_feeds/lib/src/models/model_updates.freezed.dart index 7c111a0..9b9515f 100644 --- a/packages/stream_feeds/lib/src/models/model_updates.freezed.dart +++ b/packages/stream_feeds/lib/src/models/model_updates.freezed.dart @@ -16,7 +16,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$ModelUpdates { List get added; - List get removedIds; + Set get removedIds; List get updated; /// Create a copy of ModelUpdates @@ -57,7 +57,7 @@ abstract mixin class $ModelUpdatesCopyWith { ModelUpdates value, $Res Function(ModelUpdates) _then) = _$ModelUpdatesCopyWithImpl; @useResult - $Res call({List added, List removedIds, List updated}); + $Res call({List added, Set removedIds, List updated}); } /// @nodoc @@ -85,7 +85,7 @@ class _$ModelUpdatesCopyWithImpl removedIds: null == removedIds ? _self.removedIds : removedIds // ignore: cast_nullable_to_non_nullable - as List, + as Set, updated: null == updated ? _self.updated : updated // ignore: cast_nullable_to_non_nullable diff --git a/packages/stream_feeds/lib/src/repository/feeds_repository.dart b/packages/stream_feeds/lib/src/repository/feeds_repository.dart index e6f715a..6826f7e 100644 --- a/packages/stream_feeds/lib/src/repository/feeds_repository.dart +++ b/packages/stream_feeds/lib/src/repository/feeds_repository.dart @@ -4,6 +4,7 @@ import '../generated/api/api.dart' as api; import '../models/activity_data.dart'; import '../models/activity_pin_data.dart'; import '../models/aggregated_activity_data.dart'; +import '../models/batch_follow_data.dart'; import '../models/feed_data.dart'; import '../models/feed_id.dart'; import '../models/feed_member_data.dart'; @@ -63,7 +64,8 @@ class FeedsRepository { feed: response.feed.toModel(), followers: rawFollowers.where((f) => f.isFollowerOf(fid)).toList(), following: rawFollowing.where((f) => f.isFollowingFeed(fid)).toList(), - followRequests: rawFollowers.where((f) => f.isFollowRequest).toList(), + followRequests: + rawFollowers.where((f) => f.isFollowRequestFor(fid)).toList(), members: PaginationResult( items: response.members.map((m) => m.toModel()).toList(), pagination: switch (response.memberPagination) { @@ -229,6 +231,36 @@ class FeedsRepository { return result.map((response) => response.follow.toModel()); } + /// Creates or updates multiple follow relationships in a batch operation. + /// + /// Creates or updates follow relationships using the provided [request] data. + /// + /// Returns a [Result] containing a [BatchFollowData] with the follows or an error. + Future> getOrCreateFollows( + api.FollowBatchRequest request, + ) async { + final result = await _api.getOrCreateFollows(followBatchRequest: request); + + return result.map((response) => response.toModel()); + } + + /// Removes multiple follow relationships in a batch operation. + /// + /// Unfollows multiple feeds using the provided [request] data. + /// + /// Returns a [Result] containing a list of [FollowData] with the unfollowed feeds or an error. + Future>> getOrCreateUnfollows( + api.UnfollowBatchRequest request, + ) async { + final result = await _api.getOrCreateUnfollows( + unfollowBatchRequest: request, + ); + + return result.map( + (response) => response.follows.map((f) => f.toModel()).toList(), + ); + } + /// Accepts a follow request. /// /// Approves a follow request using the provided [request] data. @@ -281,7 +313,7 @@ class FeedsRepository { return result.map( (response) => ModelUpdates( added: response.added.map((m) => m.toModel()).toList(), - removedIds: response.removedIds, + removedIds: response.removedIds.toSet(), updated: response.updated.map((m) => m.toModel()).toList(), ), ); diff --git a/packages/stream_feeds/lib/src/state/event/handler/feed_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/feed_event_handler.dart index 354e628..037584b 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/feed_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/feed_event_handler.dart @@ -171,6 +171,10 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { if (event is FeedMemberRemoved) return; if (event is FeedMemberUpdated) return; + if (event is FollowBatchUpdate) { + return state.onFollowsUpdated(event.updates); + } + if (event is NotificationFeedUpdated) { if (event.fid != query.fid.rawValue) return; return state.onNotificationFeedUpdated( diff --git a/packages/stream_feeds/lib/src/state/event/handler/follow_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/follow_list_event_handler.dart index 599cf26..eaf9a8b 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/follow_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/follow_list_event_handler.dart @@ -1,3 +1,7 @@ +import 'package:stream_core/stream_core.dart'; + +import '../../../models/follow_data.dart'; +import '../../../models/model_updates.dart'; import '../../../utils/filter.dart'; import '../../follow_list_state.dart'; import '../../query/follows_query.dart'; @@ -39,6 +43,22 @@ class FollowListEventHandler implements StateEventHandler { return state.onFollowRemoved(event.follow.id); } + if (event is FollowBatchUpdate) { + // Filter added and updated follows based on the query filter + bool matchesFilter(FollowData follow) => follow.matches(query.filter); + + final added = event.updates.added.where(matchesFilter).toList(); + // We remove elements that used to match the filter but no longer do + final (updated, removed) = event.updates.updated.partition(matchesFilter); + + final removedIds = {...event.updates.removedIds}; + removedIds.addAll(removed.map((it) => it.id)); + + return state.onFollowsUpdated( + ModelUpdates(added: added, updated: updated, removedIds: removedIds), + ); + } + // Handle other follow list events here as needed } } diff --git a/packages/stream_feeds/lib/src/state/event/handler/member_list_event_handler.dart b/packages/stream_feeds/lib/src/state/event/handler/member_list_event_handler.dart index 3943fb3..1c336d3 100644 --- a/packages/stream_feeds/lib/src/state/event/handler/member_list_event_handler.dart +++ b/packages/stream_feeds/lib/src/state/event/handler/member_list_event_handler.dart @@ -54,7 +54,7 @@ class MemberListEventHandler implements StateEventHandler { // We remove elements that used to match the filter but no longer do final (updated, removed) = event.updates.updated.partition(matchesFilter); - final removedIds = [...event.updates.removedIds]; + final removedIds = {...event.updates.removedIds}; removedIds.addAll(removed.map((it) => it.id)); return state.onMembersUpdated( diff --git a/packages/stream_feeds/lib/src/state/event/state_update_event.dart b/packages/stream_feeds/lib/src/state/event/state_update_event.dart index 7a41966..a26b938 100644 --- a/packages/stream_feeds/lib/src/state/event/state_update_event.dart +++ b/packages/stream_feeds/lib/src/state/event/state_update_event.dart @@ -736,6 +736,14 @@ class FollowUpdated extends StateUpdateEvent { final FollowData follow; } +/// Multiple follow relationships were updated in a batch. +class FollowBatchUpdate extends StateUpdateEvent { + const FollowBatchUpdate({required this.updates}); + + /// The batch updates to apply. + final ModelUpdates updates; +} + // endregion // region Special Feed Events diff --git a/packages/stream_feeds/lib/src/state/feed_state.dart b/packages/stream_feeds/lib/src/state/feed_state.dart index 02e46aa..bfef416 100644 --- a/packages/stream_feeds/lib/src/state/feed_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_state.dart @@ -18,6 +18,7 @@ import '../models/feeds_reaction_data.dart'; import '../models/follow_data.dart'; import '../models/get_or_create_feed_data.dart'; import '../models/mark_activity_data.dart'; +import '../models/model_updates.dart'; import '../models/pagination_data.dart'; import '../models/poll_data.dart'; import '../models/poll_vote_data.dart'; @@ -344,6 +345,44 @@ class FeedStateNotifier extends StateNotifier { state = state.updateFollow(follow); } + void onFollowsUpdated(ModelUpdates updates) { + final newFollowing = []; + final newFollowers = []; + final newFollowRequests = []; + + for (final it in updates.added) { + if (it.isFollowerOf(state.fid)) { + newFollowers.add(it); + } else if (it.isFollowingFeed(state.fid)) { + newFollowing.add(it); + } else if (it.isFollowRequestFor(state.fid)) { + newFollowRequests.add(it); + } + } + + final removedFollowRequests = {...updates.removedIds}; + // New accepted followings shouldn't count as follow requests anymore + removedFollowRequests.addAll(newFollowers.map((it) => it.id)); + + final updatedFollowing = updates + .copyWith(added: newFollowing) + .applyTo(state.following, key: (it) => it.id); + + final updatedFollowers = updates + .copyWith(added: newFollowers) + .applyTo(state.followers, key: (it) => it.id); + + final updatedFollowRequests = updates + .copyWith(added: newFollowRequests, removedIds: removedFollowRequests) + .applyTo(state.followRequests, key: (it) => it.id); + + state = state.copyWith( + following: updatedFollowing, + followers: updatedFollowers, + followRequests: updatedFollowRequests, + ); + } + /// Handles updates to the feed state when an unfollow action occurs. void onUnfollow({required FeedId sourceFid, required FeedId targetFid}) { // Remove the follow relationship between sourceFid and targetFid diff --git a/packages/stream_feeds/lib/src/state/follow_list_state.dart b/packages/stream_feeds/lib/src/state/follow_list_state.dart index 3b39ca2..0c8aeaf 100644 --- a/packages/stream_feeds/lib/src/state/follow_list_state.dart +++ b/packages/stream_feeds/lib/src/state/follow_list_state.dart @@ -3,6 +3,7 @@ import 'package:state_notifier/state_notifier.dart'; import 'package:stream_core/stream_core.dart'; import '../models/follow_data.dart'; +import '../models/model_updates.dart'; import '../models/pagination_data.dart'; import '../models/query_configuration.dart'; import 'query/follows_query.dart'; @@ -73,6 +74,17 @@ class FollowListStateNotifier extends StateNotifier { state = state.copyWith(follows: updatedFollows); } + + /// Handles updates to multiple follows. + void onFollowsUpdated(ModelUpdates updates) { + final updatedFollows = updates.applyTo( + state.follows, + key: (it) => it.id, + compare: followsSort.compare, + ); + + state = state.copyWith(follows: updatedFollows); + } } /// An observable state object that manages the current state of a follow list. diff --git a/packages/stream_feeds/lib/src/state/member_list_state.dart b/packages/stream_feeds/lib/src/state/member_list_state.dart index b9a150b..d058ddd 100644 --- a/packages/stream_feeds/lib/src/state/member_list_state.dart +++ b/packages/stream_feeds/lib/src/state/member_list_state.dart @@ -1,4 +1,3 @@ -import 'package:collection/collection.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:state_notifier/state_notifier.dart'; import 'package:stream_core/stream_core.dart'; @@ -78,18 +77,12 @@ class MemberListStateNotifier extends StateNotifier { /// Handles updates to multiple members. void onMembersUpdated(ModelUpdates updates) { - // Merge updated and added members - var updatedMembers = state.members.merge( - updates.updated + updates.added, + final updatedMembers = updates.applyTo( + state.members, key: (it) => it.id, compare: membersSort.compare, ); - // Remove members by their IDs - updatedMembers = updatedMembers.whereNot((it) { - return updates.removedIds.contains(it.id); - }).toList(); - state = state.copyWith(members: updatedMembers); } diff --git a/packages/stream_feeds/test/client/feeds_client_test.dart b/packages/stream_feeds/test/client/feeds_client_test.dart index c543725..30afa83 100644 --- a/packages/stream_feeds/test/client/feeds_client_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_test.dart @@ -519,4 +519,207 @@ void main() { }, ); }); + + // ============================================================ + // FEATURE: Batch Follow Operations + // ============================================================ + + group('getOrCreateFollows', () { + setUpAll(() { + registerFallbackValue( + const FollowBatchRequest(follows: []), + ); + }); + + feedsClientTest( + 'should get or create follows successfully', + body: (tester) async { + const johnFid = FeedId.user('john'); + const janeFid = FeedId.user('jane'); + const bobFid = FeedId.user('bob'); + + final request = FollowBatchRequest( + follows: [ + FollowRequest( + source: johnFid.rawValue, + target: janeFid.rawValue, + ), + FollowRequest( + source: johnFid.rawValue, + target: bobFid.rawValue, + ), + ], + ); + + final createdFollow = createDefaultFollowResponse( + sourceId: johnFid.id, + targetId: janeFid.id, + ); + final existingFollow = createDefaultFollowResponse( + sourceId: johnFid.id, + targetId: bobFid.id, + ); + + final response = createDefaultFollowBatchResponse( + created: [createdFollow], + follows: [createdFollow, existingFollow], + ); + + tester.mockApi( + (api) => api.getOrCreateFollows(followBatchRequest: request), + result: response, + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.client.getOrCreateFollows(request); + + expect(result.isSuccess, isTrue); + final batchFollowData = result.getOrThrow(); + expect(batchFollowData.created.length, equals(1)); + expect(batchFollowData.follows.length, equals(2)); + expect(batchFollowData.created[0].targetFeed.fid.id, equals('jane')); + expect(batchFollowData.follows[0].targetFeed.fid.id, equals('jane')); + expect(batchFollowData.follows[1].targetFeed.fid.id, equals('bob')); + + // Verify the event is emitted + await expectEventEmitted; + + tester.verifyApi( + (api) => api.getOrCreateFollows(followBatchRequest: request), + ); + }, + ); + + feedsClientTest( + 'should handle get or create follows failure', + body: (tester) async { + const johnFid = FeedId.user('john'); + const janeFid = FeedId.user('jane'); + + final request = FollowBatchRequest( + follows: [ + FollowRequest( + source: johnFid.rawValue, + target: janeFid.rawValue, + ), + ], + ); + + tester.mockApiFailure( + (api) => api.getOrCreateFollows(followBatchRequest: request), + error: Exception('Failed to get or create follows'), + ); + + final result = await tester.client.getOrCreateFollows(request); + + expect(result.isFailure, isTrue); + + tester.verifyApi( + (api) => api.getOrCreateFollows(followBatchRequest: request), + ); + }, + ); + }); + + group('getOrCreateUnfollows', () { + setUpAll(() { + registerFallbackValue( + const UnfollowBatchRequest(follows: []), + ); + }); + + feedsClientTest( + 'should get or create unfollows successfully', + body: (tester) async { + const johnFid = FeedId.user('john'); + const janeFid = FeedId.user('jane'); + const bobFid = FeedId.user('bob'); + + final request = UnfollowBatchRequest( + follows: [ + FollowPair( + source: johnFid.rawValue, + target: janeFid.rawValue, + ), + FollowPair( + source: johnFid.rawValue, + target: bobFid.rawValue, + ), + ], + ); + + final unfollowedFollow1 = createDefaultFollowResponse( + sourceId: johnFid.id, + targetId: janeFid.id, + ); + final unfollowedFollow2 = createDefaultFollowResponse( + sourceId: johnFid.id, + targetId: bobFid.id, + ); + + final response = createDefaultUnfollowBatchResponse( + follows: [unfollowedFollow1, unfollowedFollow2], + ); + + tester.mockApi( + (api) => api.getOrCreateUnfollows(unfollowBatchRequest: request), + result: response, + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.client.getOrCreateUnfollows(request); + + expect(result.isSuccess, isTrue); + final unfollowedFollows = result.getOrThrow(); + expect(unfollowedFollows.length, equals(2)); + expect(unfollowedFollows[0].targetFeed.fid.id, equals('jane')); + expect(unfollowedFollows[1].targetFeed.fid.id, equals('bob')); + + // Verify the event is emitted + await expectEventEmitted; + + tester.verifyApi( + (api) => api.getOrCreateUnfollows(unfollowBatchRequest: request), + ); + }, + ); + + feedsClientTest( + 'should handle get or create unfollows failure', + body: (tester) async { + const johnFid = FeedId.user('john'); + const janeFid = FeedId.user('jane'); + + final request = UnfollowBatchRequest( + follows: [ + FollowPair( + source: johnFid.rawValue, + target: janeFid.rawValue, + ), + ], + ); + + tester.mockApiFailure( + (api) => api.getOrCreateUnfollows(unfollowBatchRequest: request), + error: Exception('Failed to get or create unfollows'), + ); + + final result = await tester.client.getOrCreateUnfollows(request); + + expect(result.isFailure, isTrue); + + tester.verifyApi( + (api) => api.getOrCreateUnfollows(unfollowBatchRequest: request), + ); + }, + ); + }); } diff --git a/packages/stream_feeds_test/lib/src/helpers/test_data.dart b/packages/stream_feeds_test/lib/src/helpers/test_data.dart index 9a42ad5..84d03f6 100644 --- a/packages/stream_feeds_test/lib/src/helpers/test_data.dart +++ b/packages/stream_feeds_test/lib/src/helpers/test_data.dart @@ -714,6 +714,26 @@ FollowResponse createDefaultFollowResponse({ ); } +FollowBatchResponse createDefaultFollowBatchResponse({ + List created = const [], + List follows = const [], +}) { + return FollowBatchResponse( + created: created, + duration: '10ms', + follows: follows, + ); +} + +UnfollowBatchResponse createDefaultUnfollowBatchResponse({ + List follows = const [], +}) { + return UnfollowBatchResponse( + duration: '10ms', + follows: follows, + ); +} + BookmarkFolderResponse createDefaultBookmarkFolderResponse({ String id = 'folder-id', String name = 'My Folder',