From e875ed91e3c30776bc6e3d402e4f2ebf80cc08ce Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 11 Dec 2025 15:16:16 +0100 Subject: [PATCH 1/7] Add ownFollows on feed data --- packages/stream_feeds/lib/src/models/feed_data.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/stream_feeds/lib/src/models/feed_data.dart b/packages/stream_feeds/lib/src/models/feed_data.dart index 3b847e45..1cd95e93 100644 --- a/packages/stream_feeds/lib/src/models/feed_data.dart +++ b/packages/stream_feeds/lib/src/models/feed_data.dart @@ -3,6 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../generated/api/models.dart'; import 'feed_id.dart'; import 'feed_member_data.dart'; +import 'follow_data.dart'; import 'user_data.dart'; part 'feed_data.freezed.dart'; @@ -30,6 +31,7 @@ class FeedData with _$FeedData { required this.name, required this.ownCapabilities, this.ownMembership, + this.ownFollows, required this.pinCount, required this.updatedAt, this.visibility, @@ -92,6 +94,10 @@ class FeedData with _$FeedData { @override final FeedMemberData? ownMembership; + /// The follow relationships of the current user in the feed. + @override + final List? ownFollows; + /// The number of pinned items in the feed. @override final int pinCount; @@ -131,6 +137,7 @@ extension FeedResponseMapper on FeedResponse { name: name, ownCapabilities: ownCapabilities ?? const [], ownMembership: ownMembership?.toModel(), + ownFollows: ownFollows?.map((f) => f.toModel()).toList(), pinCount: pinCount, updatedAt: updatedAt, visibility: visibility, From b53f11078ab304ce23df32467642c6b1bf264e50 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 11 Dec 2025 15:27:48 +0100 Subject: [PATCH 2/7] update freezed class --- packages/stream_feeds/CHANGELOG.md | 1 + .../lib/src/models/feed_data.freezed.dart | 53 +++++++++++-------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index abb04531..02ef3105 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -9,6 +9,7 @@ - Add location filtering support for activities with `ActivitiesFilterField.near` and `ActivitiesFilterField.withinBounds` filter fields. - Add new activity filter fields: `ActivitiesFilterField.feed` and `ActivitiesFilterField.interestTags`. - Export previously missing public APIs: models, state objects, and queries. +- Add `ownFollows` field to `FeedData` to store the follow relationships of the current user in the feed. ## 0.4.0 - [BREAKING] Change `queryFollowSuggestions` return type to `List`. diff --git a/packages/stream_feeds/lib/src/models/feed_data.freezed.dart b/packages/stream_feeds/lib/src/models/feed_data.freezed.dart index 9fd7f663..7a4b20f2 100644 --- a/packages/stream_feeds/lib/src/models/feed_data.freezed.dart +++ b/packages/stream_feeds/lib/src/models/feed_data.freezed.dart @@ -29,6 +29,7 @@ mixin _$FeedData { String get name; List get ownCapabilities; FeedMemberData? get ownMembership; + List? get ownFollows; int get pinCount; DateTime get updatedAt; String? get visibility; @@ -70,6 +71,8 @@ mixin _$FeedData { .equals(other.ownCapabilities, ownCapabilities) && (identical(other.ownMembership, ownMembership) || other.ownMembership == ownMembership) && + const DeepCollectionEquality() + .equals(other.ownFollows, ownFollows) && (identical(other.pinCount, pinCount) || other.pinCount == pinCount) && (identical(other.updatedAt, updatedAt) || @@ -80,30 +83,32 @@ mixin _$FeedData { } @override - int get hashCode => Object.hash( - runtimeType, - createdAt, - createdBy, - deletedAt, - description, - fid, - const DeepCollectionEquality().hash(filterTags), - followerCount, - followingCount, - groupId, - id, - memberCount, - name, - const DeepCollectionEquality().hash(ownCapabilities), - ownMembership, - pinCount, - updatedAt, - visibility, - const DeepCollectionEquality().hash(custom)); + int get hashCode => Object.hashAll([ + runtimeType, + createdAt, + createdBy, + deletedAt, + description, + fid, + const DeepCollectionEquality().hash(filterTags), + followerCount, + followingCount, + groupId, + id, + memberCount, + name, + const DeepCollectionEquality().hash(ownCapabilities), + ownMembership, + const DeepCollectionEquality().hash(ownFollows), + pinCount, + updatedAt, + visibility, + const DeepCollectionEquality().hash(custom) + ]); @override String toString() { - return 'FeedData(createdAt: $createdAt, createdBy: $createdBy, deletedAt: $deletedAt, description: $description, fid: $fid, filterTags: $filterTags, followerCount: $followerCount, followingCount: $followingCount, groupId: $groupId, id: $id, memberCount: $memberCount, name: $name, ownCapabilities: $ownCapabilities, ownMembership: $ownMembership, pinCount: $pinCount, updatedAt: $updatedAt, visibility: $visibility, custom: $custom)'; + return 'FeedData(createdAt: $createdAt, createdBy: $createdBy, deletedAt: $deletedAt, description: $description, fid: $fid, filterTags: $filterTags, followerCount: $followerCount, followingCount: $followingCount, groupId: $groupId, id: $id, memberCount: $memberCount, name: $name, ownCapabilities: $ownCapabilities, ownMembership: $ownMembership, ownFollows: $ownFollows, pinCount: $pinCount, updatedAt: $updatedAt, visibility: $visibility, custom: $custom)'; } } @@ -127,6 +132,7 @@ abstract mixin class $FeedDataCopyWith<$Res> { String name, List ownCapabilities, FeedMemberData? ownMembership, + List? ownFollows, int pinCount, DateTime updatedAt, String? visibility, @@ -159,6 +165,7 @@ class _$FeedDataCopyWithImpl<$Res> implements $FeedDataCopyWith<$Res> { Object? name = null, Object? ownCapabilities = null, Object? ownMembership = freezed, + Object? ownFollows = freezed, Object? pinCount = null, Object? updatedAt = null, Object? visibility = freezed, @@ -221,6 +228,10 @@ class _$FeedDataCopyWithImpl<$Res> implements $FeedDataCopyWith<$Res> { ? _self.ownMembership : ownMembership // ignore: cast_nullable_to_non_nullable as FeedMemberData?, + ownFollows: freezed == ownFollows + ? _self.ownFollows + : ownFollows // ignore: cast_nullable_to_non_nullable + as List?, pinCount: null == pinCount ? _self.pinCount : pinCount // ignore: cast_nullable_to_non_nullable From bc583610f47fc0506871cc1fad789def6816e2d2 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 11 Dec 2025 15:42:10 +0100 Subject: [PATCH 3/7] feat: add `updateWith` to `FeedData` for smarter updates --- .../lib/src/models/feed_data.dart | 25 +++++++++++++++++++ .../lib/src/state/feed_list_state.dart | 10 +++++--- .../lib/src/state/feed_state.dart | 5 +++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/stream_feeds/lib/src/models/feed_data.dart b/packages/stream_feeds/lib/src/models/feed_data.dart index 1cd95e93..e1372956 100644 --- a/packages/stream_feeds/lib/src/models/feed_data.dart +++ b/packages/stream_feeds/lib/src/models/feed_data.dart @@ -145,3 +145,28 @@ extension FeedResponseMapper on FeedResponse { ); } } + +/// Extension functions for [FeedData] to handle common operations. +extension FeedDataMutations on FeedData { + /// Updates this feed with new data while preserving own data. + /// + /// Merges [updated] feed data with this instance, preserving [ownCapabilities], + /// [ownMembership], and [ownFollows] from this instance when not provided. This + /// ensures that user-specific data is not lost when updating from WebSocket events. + /// + /// Returns a new [FeedData] instance with the merged data. + FeedData updateWith( + FeedData updated, { + List? ownCapabilities, + FeedMemberData? ownMembership, + List? ownFollows, + }) { + return updated.copyWith( + // Preserve own data from the current instance if not provided + // as they may not be reliable from WS events. + ownCapabilities: ownCapabilities ?? this.ownCapabilities, + ownMembership: ownMembership ?? this.ownMembership, + ownFollows: ownFollows ?? this.ownFollows, + ); + } +} diff --git a/packages/stream_feeds/lib/src/state/feed_list_state.dart b/packages/stream_feeds/lib/src/state/feed_list_state.dart index 43c15391..6a681e94 100644 --- a/packages/stream_feeds/lib/src/state/feed_list_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_list_state.dart @@ -45,10 +45,12 @@ class FeedListStateNotifier extends StateNotifier { /// Handles updates to a specific feed. void onFeedUpdated(FeedData feed) { - final updatedFeeds = state.feeds.map((it) { - if (it.fid.rawValue != feed.fid.rawValue) return it; - return feed; - }).toList(); + final updatedFeeds = state.feeds.sortedUpsert( + feed, + key: (it) => it.fid.rawValue, + compare: feedsSort.compare, + update: (existing, updated) => existing.updateWith(updated), + ); state = state.copyWith(feeds: updatedFeeds); } diff --git a/packages/stream_feeds/lib/src/state/feed_state.dart b/packages/stream_feeds/lib/src/state/feed_state.dart index 3d26d131..2693d4ae 100644 --- a/packages/stream_feeds/lib/src/state/feed_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_state.dart @@ -341,8 +341,11 @@ class FeedStateNotifier extends StateNotifier { /// Handles updates to the feed state when the feed is updated. void onFeedUpdated(FeedData feed) { + final currentFeed = state.feed; + final updatedFeed = currentFeed?.updateWith(feed) ?? feed; + // Update the feed data in the state - state = state.copyWith(feed: feed); + state = state.copyWith(feed: updatedFeed); } /// Handles updates to the feed state when a follow is added. From d2ef892b12462385fba4bd2783524602001df08c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 11 Dec 2025 16:15:00 +0100 Subject: [PATCH 4/7] test: add test for `FeedUpdatedEvent` to preserve own fields --- .../stream_feeds/test/state/feed_test.dart | 77 +++++++++++++++++++ .../stream_feeds/test/test_utils/fakes.dart | 6 ++ 2 files changed, 83 insertions(+) diff --git a/packages/stream_feeds/test/state/feed_test.dart b/packages/stream_feeds/test/state/feed_test.dart index 7bd2eb3e..0c80096d 100644 --- a/packages/stream_feeds/test/state/feed_test.dart +++ b/packages/stream_feeds/test/state/feed_test.dart @@ -2097,4 +2097,81 @@ void main() { }, ); }); + + // ============================================================ + // FEATURE: Feed Updated Event + // ============================================================ + + group('FeedUpdatedEvent', () { + const feedId = FeedId(group: 'user', id: 'john'); + const userId = 'luke_skywalker'; + + feedTest( + 'should preserve own fields when feed is updated', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + feed: createDefaultFeedResponse( + id: feedId.id, + groupId: feedId.group, + ownCapabilities: [ + FeedOwnCapability.createFeed, + FeedOwnCapability.deleteFeed, + ], + ownMembership: createDefaultFeedMemberResponse( + id: userId, + role: 'admin', + ), + ownFollows: [ + createDefaultFollowResponse(id: 'follow-1'), + createDefaultFollowResponse(id: 'follow-2'), + ], + ), + ), + ), + body: (tester) async { + // Verify initial state has own fields + final initialFeed = tester.feedState.feed; + expect(initialFeed, isNotNull); + expect(initialFeed!.ownCapabilities, hasLength(2)); + expect(initialFeed.ownMembership, isNotNull); + expect(initialFeed.ownFollows, hasLength(2)); + + final originalCapabilities = initialFeed.ownCapabilities; + final originalMembership = initialFeed.ownMembership; + final originalFollows = initialFeed.ownFollows; + + // Emit FeedUpdatedEvent without own fields + await tester.emitEvent( + FeedUpdatedEvent( + type: EventTypes.feedUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + feed: createDefaultFeedResponse( + id: feedId.id, + groupId: feedId.group, + ).copyWith( + name: 'Updated Name', + description: 'Updated Description', + followerCount: 100, + // Note: ownCapabilities, ownMembership, ownFollows are not included + ), + ), + ); + + // Verify own fields are preserved + final updatedFeed = tester.feedState.feed; + expect(updatedFeed, isNotNull); + expect(updatedFeed!.name, equals('Updated Name')); + expect(updatedFeed.description, equals('Updated Description')); + expect(updatedFeed.followerCount, equals(100)); + + // Own fields should be preserved + expect(updatedFeed.ownCapabilities, equals(originalCapabilities)); + expect(updatedFeed.ownMembership, equals(originalMembership)); + expect(updatedFeed.ownFollows, equals(originalFollows)); + }, + ); + }); } diff --git a/packages/stream_feeds/test/test_utils/fakes.dart b/packages/stream_feeds/test/test_utils/fakes.dart index 85401b86..31545f3e 100644 --- a/packages/stream_feeds/test/test_utils/fakes.dart +++ b/packages/stream_feeds/test/test_utils/fakes.dart @@ -209,6 +209,9 @@ FeedResponse createDefaultFeedResponse({ String groupId = 'group', int followerCount = 0, int followingCount = 0, + List? ownCapabilities, + FeedMemberResponse? ownMembership, + List? ownFollows, }) { return FeedResponse( id: id, @@ -224,6 +227,9 @@ FeedResponse createDefaultFeedResponse({ memberCount: 0, pinCount: 0, updatedAt: DateTime.now(), + ownCapabilities: ownCapabilities, + ownMembership: ownMembership, + ownFollows: ownFollows, ); } From f2e05b8296a66202e74b83349ea7bd07b60c27e4 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 12 Dec 2025 10:57:23 +0100 Subject: [PATCH 5/7] keep own fields on currentFeed in ActivityData --- packages/stream_feeds/lib/src/models/activity_data.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/stream_feeds/lib/src/models/activity_data.dart b/packages/stream_feeds/lib/src/models/activity_data.dart index 7833cc14..906d4719 100644 --- a/packages/stream_feeds/lib/src/models/activity_data.dart +++ b/packages/stream_feeds/lib/src/models/activity_data.dart @@ -332,6 +332,10 @@ extension ActivityDataMutations on ActivityData { ownBookmarks: ownBookmarks ?? this.ownBookmarks, ownReactions: ownReactions ?? this.ownReactions, poll: updated.poll?.let((it) => poll?.updateWith(it) ?? it), + currentFeed: updated.currentFeed == null + ? currentFeed + : currentFeed?.updateWith(updated.currentFeed!) ?? + updated.currentFeed, ); } From 7f4918d5ba3bb6efbc60dd72828ad00e5ccc97de Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 12 Dec 2025 15:26:37 +0100 Subject: [PATCH 6/7] chore: minor changes --- .../stream_feeds/lib/src/models/activity_data.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/stream_feeds/lib/src/models/activity_data.dart b/packages/stream_feeds/lib/src/models/activity_data.dart index 906d4719..25f37fef 100644 --- a/packages/stream_feeds/lib/src/models/activity_data.dart +++ b/packages/stream_feeds/lib/src/models/activity_data.dart @@ -332,10 +332,12 @@ extension ActivityDataMutations on ActivityData { ownBookmarks: ownBookmarks ?? this.ownBookmarks, ownReactions: ownReactions ?? this.ownReactions, poll: updated.poll?.let((it) => poll?.updateWith(it) ?? it), - currentFeed: updated.currentFeed == null - ? currentFeed - : currentFeed?.updateWith(updated.currentFeed!) ?? - updated.currentFeed, + // Workaround until the backend fixes the issue with missing currentFeed + // in some WS events + currentFeed: switch (updated.currentFeed) { + final it? => currentFeed?.updateWith(it) ?? it, + _ => currentFeed, + }, ); } From ce3390e9be859ea5beebedadb634f5394040a863 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 12 Dec 2025 15:29:52 +0100 Subject: [PATCH 7/7] chore: minor test improvement --- packages/stream_feeds/test/state/feed_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/stream_feeds/test/state/feed_test.dart b/packages/stream_feeds/test/state/feed_test.dart index b619802f..0edec576 100644 --- a/packages/stream_feeds/test/state/feed_test.dart +++ b/packages/stream_feeds/test/state/feed_test.dart @@ -2109,10 +2109,11 @@ void main() { group('FeedUpdatedEvent', () { const feedId = FeedId(group: 'user', id: 'john'); - const userId = 'luke_skywalker'; + const currentUser = User(id: 'luke_skywalker'); feedTest( 'should preserve own fields when feed is updated', + user: currentUser, build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (it) => it.copyWith( @@ -2124,7 +2125,7 @@ void main() { FeedOwnCapability.deleteFeed, ], ownMembership: createDefaultFeedMemberResponse( - id: userId, + id: currentUser.id, role: 'admin', ), ownFollows: [