From 6e15d383c5d88932bf2d3f6c5c29da4583a14b8a Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 21 Oct 2025 12:06:18 +0200 Subject: [PATCH 1/5] improve following in the sample app --- .../stream_feeds/lib/src/models/feed_id.dart | 15 + .../lib/src/state/feed_state.dart | 24 +- .../user_feed/profile/profile_section.dart | 4 +- .../user_feed/profile/user_profile.dart | 257 +++++++++++------- .../screens/user_feed/user_feed_screen.dart | 23 +- 5 files changed, 218 insertions(+), 105 deletions(-) diff --git a/packages/stream_feeds/lib/src/models/feed_id.dart b/packages/stream_feeds/lib/src/models/feed_id.dart index 2fbdc532..3cfd2738 100644 --- a/packages/stream_feeds/lib/src/models/feed_id.dart +++ b/packages/stream_feeds/lib/src/models/feed_id.dart @@ -14,6 +14,21 @@ class FeedId with _$FeedId { required this.id, }) : rawValue = '$group:$id'; + /// The user's timeline feed containing posts from followed users + const FeedId.timeline(String id) : this(group: 'timeline', id: id); + + /// Notifications feed + const FeedId.notification(String id) : this(group: 'notification', id: id); + + /// The user's feed containing stories from followed users + const FeedId.stories(String id) : this(group: 'stories', id: id); + + /// The user's own stories + const FeedId.story(String id) : this(group: 'story', id: id); + + /// TThe user's own posts + const FeedId.user(String id) : this(group: 'user', id: id); + /// Creates a feed identifier from a raw string value. /// /// The string should be in the format `"group:id"`. If the string diff --git a/packages/stream_feeds/lib/src/state/feed_state.dart b/packages/stream_feeds/lib/src/state/feed_state.dart index 73d74ff4..b5fef7d1 100644 --- a/packages/stream_feeds/lib/src/state/feed_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_state.dart @@ -349,21 +349,29 @@ class FeedStateNotifier extends StateNotifier { } if (follow.isFollowingFeed(state.fid)) { + final updatedCount = follow.sourceFeed.followingCount; final updatedFollowing = state.following.upsert( follow, key: (it) => it.id, ); - return state.copyWith(following: updatedFollowing); + return state.copyWith( + following: updatedFollowing, + feed: state.feed?.copyWith(followingCount: updatedCount), + ); } if (follow.isFollowerOf(state.fid)) { + final updatedCount = follow.targetFeed.followerCount; final updatedFollowers = state.followers.upsert( follow, key: (it) => it.id, ); - return state.copyWith(followers: updatedFollowers); + return state.copyWith( + followers: updatedFollowers, + feed: state.feed?.copyWith(followerCount: updatedCount), + ); } // If the follow doesn't match any known categories, @@ -372,6 +380,17 @@ class FeedStateNotifier extends StateNotifier { } FeedState _removeFollow(FollowData follow, FeedState state) { + var feed = state.feed; + + if (follow.isFollowerOf(state.fid)) { + final followerCount = follow.targetFeed.followerCount; + feed = feed?.copyWith(followerCount: followerCount); + } + if (follow.isFollowingFeed(state.fid)) { + final followingCount = follow.sourceFeed.followingCount; + feed = feed?.copyWith(followingCount: followingCount); + } + final updatedFollowing = state.following.where((it) { return it.id != follow.id; }).toList(); @@ -385,6 +404,7 @@ class FeedStateNotifier extends StateNotifier { }).toList(); return state.copyWith( + feed: feed, following: updatedFollowing, followers: updatedFollowers, followRequests: updatedFollowRequests, diff --git a/sample_app/lib/screens/user_feed/profile/profile_section.dart b/sample_app/lib/screens/user_feed/profile/profile_section.dart index 66a7e526..2a76a5a6 100644 --- a/sample_app/lib/screens/user_feed/profile/profile_section.dart +++ b/sample_app/lib/screens/user_feed/profile/profile_section.dart @@ -9,12 +9,14 @@ class ProfileSection extends StatelessWidget { required this.items, required this.emptyMessage, required this.itemBuilder, + this.count, }); final String title; final List items; final String emptyMessage; final Widget Function(T item) itemBuilder; + final int? count; @override Widget build(BuildContext context) { @@ -24,7 +26,7 @@ class ProfileSection extends StatelessWidget { children: [ _SectionHeader( title: title, - count: items.length, + count: count ?? items.length, ), DecoratedBox( decoration: BoxDecoration( diff --git a/sample_app/lib/screens/user_feed/profile/user_profile.dart b/sample_app/lib/screens/user_feed/profile/user_profile.dart index f129bec1..25d1cd12 100644 --- a/sample_app/lib/screens/user_feed/profile/user_profile.dart +++ b/sample_app/lib/screens/user_feed/profile/user_profile.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_state_notifier/flutter_state_notifier.dart'; @@ -66,109 +68,172 @@ class _UserProfileState extends State { Widget build(BuildContext context) { return StateNotifierBuilder( stateNotifier: widget.timelineFeed.notifier, - builder: (context, state, child) { - final feedMembers = state.members; - final followRequests = state.followRequests; - final following = state.following; - final currentUser = client.user; - - final followIncludesCurrentUser = - following.any((it) => it.targetFeed.id == currentUser.id) || - (followSuggestions?.any((it) => it.fid.id == currentUser.id) ?? - false); - - return SingleChildScrollView( - controller: widget.scrollController, - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 24, - children: [ - // Profile Header Section - ProfileHeader( - user: currentUser, - membersCount: state.feed?.memberCount ?? 0, - followingCount: state.feed?.followingCount ?? 0, - followersCount: state.feed?.followerCount ?? 0, + builder: (context, timelineState, child) { + return StateNotifierBuilder( + stateNotifier: widget.userFeed.notifier, + builder: (context, userState, child) { + return _UserProfileContent( + client: client, + timelineState: timelineState, + userState: userState, + followSuggestions: followSuggestions, + scrollController: widget.scrollController, + onAcceptFollow: (fid) => widget.userFeed.acceptFollow( + sourceFid: fid, ), - - // Members Section - ProfileSection( - title: 'Members', - items: feedMembers, - emptyMessage: 'No members yet', - itemBuilder: (member) => MemberListItem(member: member), + onRejectFollow: (fid) => widget.userFeed.rejectFollow( + sourceFid: fid, ), - - // Follow Requests Section - ProfileSection( - title: 'Follow Requests', - items: followRequests, - emptyMessage: 'No pending requests', - itemBuilder: (followRequest) => FollowRequestListItem( - followRequest: followRequest, - onAcceptPressed: () => widget.timelineFeed.acceptFollow( - sourceFid: followRequest.sourceFeed.fid, - ), - onRejectPressed: () => widget.timelineFeed.rejectFollow( - sourceFid: followRequest.sourceFeed.fid, + onFollow: (targetFeed) async { + final result = await widget.timelineFeed.follow( + targetFid: targetFeed.fid, + createNotificationActivity: true, + ); + + // Remove the followed user from suggestions + result.onSuccess( + (_) => _updateFollowSuggestions([ + ...?followSuggestions?.where((it) => it != targetFeed), + ]), + ); + }, + onUnfollow: (targetFeed) async { + final result = await widget.timelineFeed.unfollow( + targetFid: targetFeed.fid, + ); + + // Add the unfollowed user back to suggestions + result.onSuccess( + (_) => _updateFollowSuggestions( + [...?followSuggestions, targetFeed], ), - ), - ), + ); + }, + ); + }, + ); + }, + ); + } +} - // Following Section - ProfileSection( - title: 'Following', - items: following, - emptyMessage: 'Not following anyone yet', - itemBuilder: (follow) => FollowingListItem( - follow: follow, - onUnfollowPressed: () async { - final result = await widget.timelineFeed.unfollow( - targetFid: follow.targetFeed.fid, - ); - - // Add the unfollowed user back to suggestions - result.onSuccess( - (_) => _updateFollowSuggestions( - [...?followSuggestions, follow.targetFeed], - ), - ); - }, - ), - ), +class _UserProfileContent extends StatelessWidget { + const _UserProfileContent({ + required this.client, + required this.timelineState, + required this.userState, + required this.followSuggestions, + required this.scrollController, + required this.onAcceptFollow, + required this.onRejectFollow, + required this.onFollow, + required this.onUnfollow, + }); - // Follow Suggestions Section - ProfileSection( - title: 'Suggested', - items: [ - if (!followIncludesCurrentUser && - widget.userFeed.state.feed != null) - widget.userFeed.state.feed!, - ...(followSuggestions ?? []), - ], - emptyMessage: 'No suggestions available', - itemBuilder: (suggestion) => SuggestionListItem( - suggestion: suggestion, - onFollowPressed: () async { - final result = await widget.timelineFeed.follow( - targetFid: suggestion.fid, - createNotificationActivity: true, - ); - - // Remove the followed user from suggestions - result.onSuccess( - (_) => _updateFollowSuggestions([ - ...?followSuggestions?.where((it) => it != suggestion), - ]), - ); - }, - ), - ), + final StreamFeedsClient client; + final FeedState timelineState; + final FeedState userState; + final List? followSuggestions; + final ScrollController? scrollController; + + final ValueSetter onAcceptFollow; + final ValueSetter onRejectFollow; + final ValueSetter onFollow; + final ValueSetter onUnfollow; + + @override + Widget build(BuildContext context) { + final currentUser = client.user; + final feedMembers = timelineState.members; + + // We always follow ourselves, so we don't need to show it in the following list + final following = timelineState.following + .where((it) => it.targetFeed.id != currentUser.id) + .toList(); + final followingCount = math.max( + 0, + (timelineState.feed?.followingCount ?? 0) - 1, + ); + final followersCount = math.max( + 0, + (userState.feed?.followerCount ?? 0) - 1, + ); + + final followRequests = userState.followRequests; + + final followIncludesCurrentUser = following + .any((it) => it.targetFeed.id == currentUser.id) || + (followSuggestions?.any((it) => it.fid.id == currentUser.id) ?? false); + + return SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 24, + children: [ + // Profile Header Section + ProfileHeader( + user: currentUser, + membersCount: timelineState.feed?.memberCount ?? 0, + followingCount: followingCount, + followersCount: followersCount, + ), + + // Members Section + ProfileSection( + title: 'Members', + items: feedMembers, + emptyMessage: 'No members yet', + itemBuilder: (member) => MemberListItem(member: member), + ), + + // Follow Requests Section + ProfileSection( + title: 'Follow Requests', + items: followRequests, + emptyMessage: 'No pending requests', + itemBuilder: (followRequest) => FollowRequestListItem( + followRequest: followRequest, + onAcceptPressed: () => + onAcceptFollow(followRequest.sourceFeed.fid), + onRejectPressed: () => + onRejectFollow(followRequest.sourceFeed.fid), + ), + ), + + // Following Section + ProfileSection( + title: 'Following', + count: followingCount, + items: following, + emptyMessage: 'Not following anyone yet', + itemBuilder: (follow) => FollowingListItem( + follow: follow, + onUnfollowPressed: () { + onUnfollow(follow.targetFeed); + }, + ), + ), + + // Follow Suggestions Section + ProfileSection( + title: 'Suggested', + items: [ + if (!followIncludesCurrentUser && userState.feed != null) + userState.feed!, + ...(followSuggestions ?? []), ], + emptyMessage: 'No suggestions available', + itemBuilder: (suggestion) => SuggestionListItem( + suggestion: suggestion, + onFollowPressed: () { + onFollow(suggestion); + }, + ), ), - ); - }, + ], + ), ); } } diff --git a/sample_app/lib/screens/user_feed/user_feed_screen.dart b/sample_app/lib/screens/user_feed/user_feed_screen.dart index 71b85852..35400f32 100644 --- a/sample_app/lib/screens/user_feed/user_feed_screen.dart +++ b/sample_app/lib/screens/user_feed/user_feed_screen.dart @@ -31,25 +31,25 @@ class _UserFeedScreenState extends State { late final userFeed = client.feedFromQuery( FeedQuery( - fid: FeedId(group: 'user', id: client.user.id), + fid: FeedId.user(client.user.id), data: FeedInputData( visibility: FeedVisibility.public, members: [FeedMemberRequestData(userId: client.user.id)], ), activityLimit: 0, - followerLimit: 10, - followingLimit: 10, - memberLimit: 10, ), ); late final timelineFeed = client.feedFromQuery( FeedQuery( - fid: FeedId(group: 'timeline', id: client.user.id), + fid: FeedId.timeline(client.user.id), data: FeedInputData( visibility: FeedVisibility.public, members: [FeedMemberRequestData(userId: client.user.id)], ), + followerLimit: 10, + followingLimit: 10, + memberLimit: 10, ), ); @@ -57,7 +57,9 @@ class _UserFeedScreenState extends State { void initState() { super.initState(); userFeed.getOrCreate(); - timelineFeed.getOrCreate(); + timelineFeed.getOrCreate().then((value) { + _followSelfIfNeeded(timelineFeed); + }); notificationFeed.getOrCreate(); } @@ -69,6 +71,15 @@ class _UserFeedScreenState extends State { super.dispose(); } + void _followSelfIfNeeded(Feed feed) { + if (feed.state.followers.isEmpty) { + feed.follow( + targetFid: FeedId.user(client.user.id), + createNotificationActivity: false, + ); + } + } + Future _onLogout() { final authController = locator(); return authController.disconnect(); From cd782f3d74135aeb4d7b8cfe9fd422ce41259e72 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 21 Oct 2025 14:45:24 +0200 Subject: [PATCH 2/5] Add unit test for follow events --- .../test/client/feeds_client_impl_test.dart | 2 +- .../test/state/activity_test.dart | 115 +----- .../stream_feeds/test/state/feed_test.dart | 348 ++++++++++++++++++ packages/stream_feeds/test/test_utils.dart | 4 + .../test/test_utils/event_types.dart | 10 + .../stream_feeds/test/test_utils/fakes.dart | 153 ++++++++ .../test/{ => test_utils}/mocks.dart | 0 .../{ => test_utils}/ws_test_helpers.dart | 0 8 files changed, 525 insertions(+), 107 deletions(-) create mode 100644 packages/stream_feeds/test/state/feed_test.dart create mode 100644 packages/stream_feeds/test/test_utils.dart create mode 100644 packages/stream_feeds/test/test_utils/event_types.dart create mode 100644 packages/stream_feeds/test/test_utils/fakes.dart rename packages/stream_feeds/test/{ => test_utils}/mocks.dart (100%) rename packages/stream_feeds/test/{ => test_utils}/ws_test_helpers.dart (100%) diff --git a/packages/stream_feeds/test/client/feeds_client_impl_test.dart b/packages/stream_feeds/test/client/feeds_client_impl_test.dart index a3cec53d..ba7449e2 100644 --- a/packages/stream_feeds/test/client/feeds_client_impl_test.dart +++ b/packages/stream_feeds/test/client/feeds_client_impl_test.dart @@ -2,7 +2,7 @@ import 'package:stream_feeds/src/client/feeds_client_impl.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; +import '../test_utils.dart'; void main() { test('Create a feeds client', () { diff --git a/packages/stream_feeds/test/state/activity_test.dart b/packages/stream_feeds/test/state/activity_test.dart index 983f14d8..40b5157a 100644 --- a/packages/stream_feeds/test/state/activity_test.dart +++ b/packages/stream_feeds/test/state/activity_test.dart @@ -9,8 +9,7 @@ import 'package:stream_feeds/src/state/activity_state.dart'; import 'package:stream_feeds/stream_feeds.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; -import '../ws_test_helpers.dart'; +import '../test_utils.dart'; void main() { late StreamFeedsClientImpl client; @@ -47,7 +46,7 @@ void main() { depth: 3, ), ).thenAnswer( - (_) async => const Result.success(defaultCommentsResponse), + (_) async => Result.success(createDefaultCommentsResponse()), ); final activity = client.activity( @@ -102,7 +101,7 @@ void main() { depth: 3, ), ).thenAnswer( - (_) async => const Result.success(defaultCommentsResponse), + (_) async => Result.success(createDefaultCommentsResponse()), ); } @@ -146,7 +145,7 @@ void main() { optionId: firstOptionId, pollId: pollId, ), - type: 'feeds.poll.vote_casted', + type: EventTypes.pollVoteCasted, ).toJson(), ), ); @@ -191,7 +190,7 @@ void main() { optionId: 'optionId1', pollId: 'pollId1', ), - type: 'feeds.poll.vote_casted', + type: EventTypes.pollVoteCasted, ), ), ); @@ -251,7 +250,7 @@ void main() { optionId: 'optionId1', pollId: 'pollId1', ), - type: 'feeds.poll.vote_removed', + type: EventTypes.pollVoteRemoved, ), ), ); @@ -309,7 +308,7 @@ void main() { optionId: 'optionId1', pollId: pollId, ), - type: 'feeds.poll.vote_removed', + type: EventTypes.pollVoteRemoved, ), ), ); @@ -344,7 +343,7 @@ void main() { custom: const {}, fid: 'fid', poll: poll.copyWith(isClosed: true), - type: 'feeds.poll.closed', + type: EventTypes.pollClosed, ), ), ); @@ -378,106 +377,10 @@ void main() { custom: const {}, fid: 'fid', poll: poll, - type: 'feeds.poll.deleted', + type: EventTypes.pollDeleted, ), ), ); }); }); } - -const defaultCommentsResponse = GetCommentsResponse( - comments: [], - next: null, - prev: null, - duration: 'duration', -); - -GetActivityResponse createDefaultActivityResponse({PollResponseData? poll}) => - GetActivityResponse( - activity: ActivityResponse( - id: 'id', - attachments: const [], - bookmarkCount: 0, - commentCount: 0, - comments: const [], - createdAt: DateTime(2021, 1, 1), - custom: const {}, - feeds: const [], - filterTags: const [], - interestTags: const [], - latestReactions: const [], - mentionedUsers: const [], - moderation: null, - notificationContext: null, - ownBookmarks: const [], - ownReactions: const [], - parent: null, - poll: poll, - popularity: 0, - reactionCount: 0, - reactionGroups: const {}, - score: 0, - searchData: const {}, - shareCount: 0, - text: null, - type: 'type', - updatedAt: DateTime(2021, 2, 1), - user: UserResponse( - id: 'id', - name: 'name', - banned: false, - blockedUserIds: const [], - createdAt: DateTime(2021, 1, 1), - custom: const {}, - language: 'language', - online: false, - role: 'role', - teams: const [], - updatedAt: DateTime(2021, 2, 1), - ), - visibility: ActivityResponseVisibility.public, - visibilityTag: null, - ), - duration: 'duration', - ); - -PollResponseData createDefaultPollResponseData({ - List latestAnswers = const [], - Map> latestVotesByOption = const {}, -}) => - PollResponseData( - id: 'id', - name: 'name', - allowAnswers: true, - allowUserSuggestedOptions: true, - answersCount: latestAnswers.length, - createdAt: DateTime.now(), - createdById: 'id', - custom: const {}, - description: 'description', - enforceUniqueVote: true, - latestAnswers: latestAnswers, - latestVotesByOption: latestVotesByOption, - ownVotes: const [], - updatedAt: DateTime.now(), - voteCount: latestVotesByOption.values - .map((e) => e.length) - .fold(0, (v, e) => v + e), - voteCountsByOption: latestVotesByOption.map( - (k, e) => MapEntry(k, e.length), - ), - votingVisibility: 'visibility', - options: const [ - PollOptionResponseData( - id: 'id1', - text: 'text1', - custom: {}, - ), - PollOptionResponseData( - id: 'id2', - text: 'text2', - custom: {}, - ), - ], - ); diff --git a/packages/stream_feeds/test/state/feed_test.dart b/packages/stream_feeds/test/state/feed_test.dart new file mode 100644 index 00000000..72bd8206 --- /dev/null +++ b/packages/stream_feeds/test/state/feed_test.dart @@ -0,0 +1,348 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:async'; +import 'dart:convert'; + +import 'package:mocktail/mocktail.dart'; +import 'package:stream_feeds/src/client/feeds_client_impl.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart'; + +import '../test_utils.dart'; + +void main() { + late StreamFeedsClientImpl client; + late MockDefaultApi feedsApi; + late MockWebSocketChannel webSocketChannel; + + setUp(() { + feedsApi = MockDefaultApi(); + webSocketChannel = MockWebSocketChannel(); + + client = StreamFeedsClientImpl( + apiKey: 'apiKey', + user: const User(id: 'luke_skywalker'), + tokenProvider: TokenProvider.static(UserToken(testToken)), + feedsRestApi: feedsApi, + wsProvider: (options) => webSocketChannel, + ); + }); + + tearDown(() { + client.disconnect(); + }); + + group('Get a Feed', () { + test('get feed', () async { + const feedId = FeedId(group: 'group', id: 'id'); + when( + () => feedsApi.getOrCreateFeed( + feedGroupId: feedId.group, + feedId: feedId.id, + getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), + ), + ).thenAnswer( + (_) async => Result.success(createDefaultGetOrCreateFeedResponse()), + ); + + final feed = client.feed(group: feedId.group, id: feedId.id); + final result = await feed.getOrCreate(); + + expect(result, isA>()); + final feedData = result.getOrThrow(); + + expect(feedData, isA()); + expect(feedData.id, 'id'); + expect(feedData.name, 'name'); + expect(feedData.description, 'description'); + }); + }); + + group('Follow events', () { + late StreamController wsStreamController; + late MockWebSocketSink webSocketSink; + + 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(); + }); + + test('follow target feed should update follower count', () async { + const targetFeedId = FeedId(group: 'group', id: 'target'); + const sourceFeedId = FeedId(group: 'group', id: 'source'); + + when( + () => feedsApi.getOrCreateFeed( + feedGroupId: targetFeedId.group, + feedId: targetFeedId.id, + getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), + ), + ).thenAnswer( + (_) async => Result.success(createDefaultGetOrCreateFeedResponse()), + ); + + final feed = client.feedFromId(targetFeedId); + + final result = await feed.getOrCreate(); + final feedData = result.getOrThrow(); + + expect(feedData.followerCount, 0); + expect(feedData.followingCount, 0); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.feed?.followerCount, 1); + expect(event.feed?.followingCount, 0); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + FollowCreatedEvent( + type: EventTypes.followCreated, + createdAt: DateTime.now(), + custom: const {}, + fid: targetFeedId.toString(), + follow: FollowResponse( + createdAt: DateTime.now(), + custom: const {}, + followerRole: 'followerRole', + pushPreference: FollowResponsePushPreference.none, + requestAcceptedAt: DateTime.now(), + requestRejectedAt: DateTime.now(), + sourceFeed: createDefaultFeedResponse( + id: sourceFeedId.id, + groupId: sourceFeedId.group, + followingCount: 1, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + followerCount: 1, + ), + updatedAt: DateTime.now(), + ), + ), + ), + ); + }); + + test('follow source feed should update following count', () async { + const targetFeedId = FeedId(group: 'group', id: 'target'); + const sourceFeedId = FeedId(group: 'group', id: 'source'); + + when( + () => feedsApi.getOrCreateFeed( + feedGroupId: sourceFeedId.group, + feedId: sourceFeedId.id, + getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), + ), + ).thenAnswer( + (_) async => Result.success(createDefaultGetOrCreateFeedResponse()), + ); + + final feed = client.feedFromId(sourceFeedId); + + final result = await feed.getOrCreate(); + final feedData = result.getOrThrow(); + + expect(feedData.followerCount, 0); + expect(feedData.followingCount, 0); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.feed?.followerCount, 0); + expect(event.feed?.followingCount, 1); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + FollowCreatedEvent( + type: EventTypes.followCreated, + createdAt: DateTime.now(), + custom: const {}, + fid: sourceFeedId.toString(), + follow: FollowResponse( + createdAt: DateTime.now(), + custom: const {}, + followerRole: 'followerRole', + pushPreference: FollowResponsePushPreference.none, + requestAcceptedAt: DateTime.now(), + requestRejectedAt: DateTime.now(), + sourceFeed: createDefaultFeedResponse( + id: sourceFeedId.id, + groupId: sourceFeedId.group, + followingCount: 1, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + followerCount: 1, + ), + updatedAt: DateTime.now(), + ), + ), + ), + ); + }); + + test('follow deleted target feed should update follower count', () async { + const targetFeedId = FeedId(group: 'group', id: 'target'); + const sourceFeedId = FeedId(group: 'group', id: 'source'); + + when( + () => feedsApi.getOrCreateFeed( + feedGroupId: targetFeedId.group, + feedId: targetFeedId.id, + getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), + ), + ).thenAnswer( + (_) async => Result.success( + createDefaultGetOrCreateFeedResponse( + followerCount: 1, + followingCount: 1, + ), + ), + ); + + final feed = client.feedFromId(targetFeedId); + + final result = await feed.getOrCreate(); + final feedData = result.getOrThrow(); + + expect(feedData.followerCount, 1); + expect(feedData.followingCount, 1); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.feed?.followerCount, 0); + expect(event.feed?.followingCount, 1); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + FollowDeletedEvent( + type: EventTypes.followDeleted, + createdAt: DateTime.now(), + custom: const {}, + fid: targetFeedId.toString(), + follow: FollowResponse( + createdAt: DateTime.now(), + custom: const {}, + followerRole: 'followerRole', + pushPreference: FollowResponsePushPreference.none, + requestAcceptedAt: DateTime.now(), + requestRejectedAt: DateTime.now(), + sourceFeed: createDefaultFeedResponse( + id: sourceFeedId.id, + groupId: sourceFeedId.group, + followingCount: 0, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + followerCount: 0, + ), + updatedAt: DateTime.now(), + ), + ), + ), + ); + }); + + test('follow deleted source feed should update following count', () async { + const targetFeedId = FeedId(group: 'group', id: 'target'); + const sourceFeedId = FeedId(group: 'group', id: 'source'); + + when( + () => feedsApi.getOrCreateFeed( + feedGroupId: sourceFeedId.group, + feedId: sourceFeedId.id, + getOrCreateFeedRequest: any(named: 'getOrCreateFeedRequest'), + ), + ).thenAnswer( + (_) async => Result.success( + createDefaultGetOrCreateFeedResponse( + followingCount: 1, + followerCount: 1, + ), + ), + ); + + final feed = client.feedFromId(sourceFeedId); + + final result = await feed.getOrCreate(); + final feedData = result.getOrThrow(); + + expect(feedData.followerCount, 1); + expect(feedData.followingCount, 1); + + feed.notifier.stream.listen( + expectAsync1( + (event) { + expect(event, isA()); + expect(event.feed?.followerCount, 1); + expect(event.feed?.followingCount, 0); + }, + ), + ); + + wsStreamController.add( + jsonEncode( + FollowDeletedEvent( + type: EventTypes.followDeleted, + createdAt: DateTime.now(), + custom: const {}, + fid: sourceFeedId.toString(), + follow: FollowResponse( + createdAt: DateTime.now(), + custom: const {}, + followerRole: 'followerRole', + pushPreference: FollowResponsePushPreference.none, + requestAcceptedAt: DateTime.now(), + requestRejectedAt: DateTime.now(), + sourceFeed: createDefaultFeedResponse( + id: sourceFeedId.id, + groupId: sourceFeedId.group, + followingCount: 0, + ), + status: FollowResponseStatus.accepted, + targetFeed: createDefaultFeedResponse( + id: targetFeedId.id, + groupId: targetFeedId.group, + followerCount: 0, + ), + updatedAt: DateTime.now(), + ), + ), + ), + ); + }); + }); +} diff --git a/packages/stream_feeds/test/test_utils.dart b/packages/stream_feeds/test/test_utils.dart new file mode 100644 index 00000000..05e92613 --- /dev/null +++ b/packages/stream_feeds/test/test_utils.dart @@ -0,0 +1,4 @@ +export 'test_utils/event_types.dart'; +export 'test_utils/fakes.dart'; +export 'test_utils/mocks.dart'; +export 'test_utils/ws_test_helpers.dart'; diff --git a/packages/stream_feeds/test/test_utils/event_types.dart b/packages/stream_feeds/test/test_utils/event_types.dart new file mode 100644 index 00000000..07374ded --- /dev/null +++ b/packages/stream_feeds/test/test_utils/event_types.dart @@ -0,0 +1,10 @@ +class EventTypes { + static const String followCreated = 'feeds.follow.created'; + static const String followDeleted = 'feeds.follow.deleted'; + static const String followUpdated = 'feeds.follow.updated'; + + static const String pollClosed = 'feeds.poll.closed'; + static const String pollDeleted = 'feeds.poll.deleted'; + 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 new file mode 100644 index 00000000..06796380 --- /dev/null +++ b/packages/stream_feeds/test/test_utils/fakes.dart @@ -0,0 +1,153 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:stream_feeds/stream_feeds.dart'; + +GetCommentsResponse createDefaultCommentsResponse() => + const GetCommentsResponse( + comments: [], + next: null, + prev: null, + duration: 'duration', + ); + +GetActivityResponse createDefaultActivityResponse({PollResponseData? poll}) => + GetActivityResponse( + activity: ActivityResponse( + id: 'id', + attachments: const [], + bookmarkCount: 0, + commentCount: 0, + comments: const [], + createdAt: DateTime(2021, 1, 1), + custom: const {}, + feeds: const [], + filterTags: const [], + interestTags: const [], + latestReactions: const [], + mentionedUsers: const [], + moderation: null, + notificationContext: null, + ownBookmarks: const [], + ownReactions: const [], + parent: null, + poll: poll, + popularity: 0, + reactionCount: 0, + reactionGroups: const {}, + score: 0, + searchData: const {}, + shareCount: 0, + text: null, + type: 'type', + updatedAt: DateTime(2021, 2, 1), + user: UserResponse( + id: 'id', + name: 'name', + banned: false, + blockedUserIds: const [], + createdAt: DateTime(2021, 1, 1), + custom: const {}, + language: 'language', + online: false, + role: 'role', + teams: const [], + updatedAt: DateTime(2021, 2, 1), + ), + visibility: ActivityResponseVisibility.public, + visibilityTag: null, + ), + duration: 'duration', + ); + +PollResponseData createDefaultPollResponseData({ + List latestAnswers = const [], + Map> latestVotesByOption = const {}, +}) => + PollResponseData( + id: 'id', + name: 'name', + allowAnswers: true, + allowUserSuggestedOptions: true, + answersCount: latestAnswers.length, + createdAt: DateTime.now(), + createdById: 'id', + custom: const {}, + description: 'description', + enforceUniqueVote: true, + latestAnswers: latestAnswers, + latestVotesByOption: latestVotesByOption, + ownVotes: const [], + updatedAt: DateTime.now(), + voteCount: latestVotesByOption.values + .map((e) => e.length) + .fold(0, (v, e) => v + e), + voteCountsByOption: latestVotesByOption.map( + (k, e) => MapEntry(k, e.length), + ), + votingVisibility: 'visibility', + options: const [ + PollOptionResponseData( + id: 'id1', + text: 'text1', + custom: {}, + ), + PollOptionResponseData( + id: 'id2', + text: 'text2', + custom: {}, + ), + ], + ); + +GetOrCreateFeedResponse createDefaultGetOrCreateFeedResponse({ + int followerCount = 0, + int followingCount = 0, +}) => + GetOrCreateFeedResponse( + feed: createDefaultFeedResponse( + followerCount: followerCount, + followingCount: followingCount, + ), + activities: const [], + aggregatedActivities: const [], + created: true, + duration: '', + followers: const [], + following: const [], + members: const [], + pinnedActivities: const [], + ); + +FeedResponse createDefaultFeedResponse({ + String id = 'id', + String groupId = 'group', + int followerCount = 0, + int followingCount = 0, +}) => + FeedResponse( + id: id, + groupId: groupId, + feed: FeedId(group: groupId, id: id).toString(), + name: 'name', + description: 'description', + visibility: FeedVisibility.public, + createdAt: DateTime(2021, 1, 1), + createdBy: UserResponse( + id: 'id', + name: 'name', + banned: false, + blockedUserIds: const [], + createdAt: DateTime(2021, 1, 1), + custom: const {}, + language: 'language', + online: false, + role: 'role', + teams: const [], + updatedAt: DateTime(2021, 2, 1), + ), + followerCount: followerCount, + followingCount: followingCount, + memberCount: 0, + pinCount: 0, + updatedAt: DateTime.now(), + ); diff --git a/packages/stream_feeds/test/mocks.dart b/packages/stream_feeds/test/test_utils/mocks.dart similarity index 100% rename from packages/stream_feeds/test/mocks.dart rename to packages/stream_feeds/test/test_utils/mocks.dart diff --git a/packages/stream_feeds/test/ws_test_helpers.dart b/packages/stream_feeds/test/test_utils/ws_test_helpers.dart similarity index 100% rename from packages/stream_feeds/test/ws_test_helpers.dart rename to packages/stream_feeds/test/test_utils/ws_test_helpers.dart From fcae064a0ba10ac44e34b44c388d298a0a8d95a9 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 21 Oct 2025 14:48:27 +0200 Subject: [PATCH 3/5] update changelog --- packages/stream_feeds/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 78567e1c..9567bd0f 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,3 +1,6 @@ +## unreleased +- Update follower and following counts on the feed state when receiving follow websocket events. + ## 0.3.1 - Update API client with renaming `addReaction` to `addActivityReaction` and `deleteReaction` to `deleteActivityReaction`. - Update `activity.currentFeed` capabilities when adding or updating activity from websocket events. From 61bec5286bb723bb02b4574b7869dad61f6b1351 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 21 Oct 2025 14:55:50 +0200 Subject: [PATCH 4/5] remove unnecessary ignore --- packages/stream_feeds/test/state/activity_test.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/stream_feeds/test/state/activity_test.dart b/packages/stream_feeds/test/state/activity_test.dart index 40b5157a..857f542e 100644 --- a/packages/stream_feeds/test/state/activity_test.dart +++ b/packages/stream_feeds/test/state/activity_test.dart @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_redundant_argument_values - import 'dart:async'; import 'dart:convert'; From c0a79b6e75268fa1020e6bdd39dd59b6215236b0 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 21 Oct 2025 15:01:01 +0200 Subject: [PATCH 5/5] Add feed id tests --- .../test/models/feed_id_test.dart | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/stream_feeds/test/models/feed_id_test.dart diff --git a/packages/stream_feeds/test/models/feed_id_test.dart b/packages/stream_feeds/test/models/feed_id_test.dart new file mode 100644 index 00000000..7874e748 --- /dev/null +++ b/packages/stream_feeds/test/models/feed_id_test.dart @@ -0,0 +1,46 @@ +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart'; + +void main() { + test('feed id should be created with group and id', () { + const feedId = FeedId(group: 'group', id: 'id'); + expect(feedId.group, 'group'); + expect(feedId.id, 'id'); + expect(feedId.rawValue, 'group:id'); + }); + + test('feed id should be created with timeline group and id', () { + const feedId = FeedId.timeline('id'); + expect(feedId.group, 'timeline'); + expect(feedId.id, 'id'); + expect(feedId.rawValue, 'timeline:id'); + }); + + test('feed id should be created with notification group and id', () { + const feedId = FeedId.notification('id'); + expect(feedId.group, 'notification'); + expect(feedId.id, 'id'); + expect(feedId.rawValue, 'notification:id'); + }); + + test('feed id should be created with stories group and id', () { + const feedId = FeedId.stories('id'); + expect(feedId.group, 'stories'); + expect(feedId.id, 'id'); + expect(feedId.rawValue, 'stories:id'); + }); + + test('feed id should be created with story group and id', () { + const feedId = FeedId.story('id'); + expect(feedId.group, 'story'); + expect(feedId.id, 'id'); + expect(feedId.rawValue, 'story:id'); + }); + + test('feed id should be created with user group and id', () { + const feedId = FeedId.user('id'); + expect(feedId.group, 'user'); + expect(feedId.id, 'id'); + expect(feedId.rawValue, 'user:id'); + }); +}