Skip to content
Merged
1 change: 1 addition & 0 deletions packages/stream_feeds/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions packages/stream_feeds/lib/src/client/feeds_client_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -502,6 +505,42 @@ class StreamFeedsClientImpl implements StreamFeedsClient {
return _cdnClient.deleteImage(url);
}

@override
Future<Result<BatchFollowData>> 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<FollowData>(
added: data.created,
updated: data.follows.where((f) => !createdIds.contains(f.id)).toList(),
);

_stateUpdateEmitter.tryEmit(FollowBatchUpdate(updates: updates));
});

return result;
}

@override
Future<Result<List<FollowData>>> getOrCreateUnfollows(
api.UnfollowBatchRequest request,
) async {
final result = await _feedsRepository.getOrCreateUnfollows(request);

result.onSuccess((data) {
final updates = ModelUpdates<FollowData>(
removedIds: data.map((f) => f.id).toSet(),
);

_stateUpdateEmitter.tryEmit(FollowBatchUpdate(updates: updates));
});

return result;
}

Stream<void> get onReconnectEmitter {
return connectionState.scan(
(state, connectionStatus, i) => switch (connectionStatus) {
Expand Down
73 changes: 73 additions & 0 deletions packages/stream_feeds/lib/src/feeds_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -779,6 +781,77 @@ abstract interface class StreamFeedsClient {
/// Returns a [Result] indicating success or failure of the deletion operation.
Future<Result<void>> 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<Result<BatchFollowData>> 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<Result<List<FollowData>>> getOrCreateUnfollows(
api.UnfollowBatchRequest request,
);

/// The moderation client for managing moderation-related operations.
///
/// Provides access to moderation configurations, content moderation, and moderation-related
Expand Down
2 changes: 2 additions & 0 deletions packages/stream_feeds/lib/src/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
46 changes: 46 additions & 0 deletions packages/stream_feeds/lib/src/models/batch_follow_data.dart
Original file line number Diff line number Diff line change
@@ -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<FollowData> 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<FollowData> 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(),
);
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions packages/stream_feeds/lib/src/models/follow_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 52 additions & 3 deletions packages/stream_feeds/lib/src/models/model_updates.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,19 +14,66 @@ class ModelUpdates<T> with _$ModelUpdates<T> {
/// Creates a new [ModelUpdates] instance.
const ModelUpdates({
this.added = const [],
this.removedIds = const [],
this.removedIds = const {},
this.updated = const [],
});

/// A list of items that have been added to the collection.
@override
final List<T> 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<String> removedIds;
final Set<String> removedIds;

/// A list of items that have been updated in the collection.
@override
final List<T> updated;
}

/// Extension providing utilities for working with [ModelUpdates].
extension ModelUpdatesExtension<T extends Object> on ModelUpdates<T> {
/// 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<FollowData>(
/// added: [newFollow1, newFollow2],
/// updated: [updatedFollow],
/// removedIds: {'follow-id-to-remove'},
/// );
///
/// final updatedList = updates.applyTo(
/// currentFollows,
/// key: (follow) => follow.id,
/// compare: followsSort.compare,
/// );
/// ```
List<T> applyTo(
List<T> list, {
required String Function(T item) key,
Comparator<T>? 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();
}
}
Loading