diff --git a/docs/code_snippets/05_06_notification_feeds.dart b/docs/code_snippets/05_06_notification_feeds.dart index 784dfacb..142b5819 100644 --- a/docs/code_snippets/05_06_notification_feeds.dart +++ b/docs/code_snippets/05_06_notification_feeds.dart @@ -71,3 +71,41 @@ Future markNotificationsAsRead() async { ), ); } + +Future readPerActivityReadSeenState() async { + // After getOrCreate(), the feed state exposes per-activity and per-group isRead/isSeen flags. + // These are updated automatically when markActivity() is called (via the WS activity.marked event). + final feedState = notificationFeed.state; + + // Check per-group read/seen status for aggregated notification feeds + for (final group in feedState.aggregatedActivities) { + final isRead = group.isRead ?? false; + final isSeen = group.isSeen ?? false; + print('Group ${group.group}: read=$isRead, seen=$isSeen'); + + // Individual activities within the group also carry isRead/isSeen + for (final activity in group.activities) { + print(' Activity ${activity.id}: read=${activity.isRead}, seen=${activity.isSeen}'); + } + } + + // For flat (non-aggregated) notification feeds, check individual activities + for (final activity in feedState.activities) { + final isRead = activity.isRead ?? false; + final isSeen = activity.isSeen ?? false; + print('Activity ${activity.id}: read=$isRead, seen=$isSeen'); + } +} + +Future markSpecificGroupAsRead() async { + // Mark specific notification groups as read using their group IDs. + // The feed state isRead/isSeen flags are updated automatically via the WS activity.marked event. + final feedState = notificationFeed.state; + final unreadGroups = feedState.aggregatedActivities.where((g) => g.isRead != true).map((g) => g.group).toList(); + + if (unreadGroups.isNotEmpty) { + await notificationFeed.markActivity( + request: MarkActivityRequest(markRead: unreadGroups), + ); + } +} diff --git a/docs/code_snippets/08_01_events.dart b/docs/code_snippets/08_01_events.dart index 8ad1dfb5..2507c401 100644 --- a/docs/code_snippets/08_01_events.dart +++ b/docs/code_snippets/08_01_events.dart @@ -1 +1,41 @@ -//TODO +import 'package:stream_feeds/stream_feeds.dart'; + +late StreamFeedsClient client; +late Feed notificationFeed; + +Future listenToClientEvents() async { + // Listen to all WebSocket events from the client + client.events.listen((event) { + print('Received event: ${event.runtimeType}'); + }); +} + +Future listenToFeedEvents() async { + // The feed state stream emits whenever the feed state changes (activities, + // aggregated groups, notification status, etc.) + notificationFeed.stream.listen((state) { + final unread = state.notificationStatus?.unread ?? 0; + final unseen = state.notificationStatus?.unseen ?? 0; + print('Unread: $unread, Unseen: $unseen'); + + // Per-activity isRead/isSeen are updated automatically when the + // activity.marked WebSocket event arrives after markActivity() calls. + for (final group in state.aggregatedActivities) { + print('Group ${group.group}: read=${group.isRead}, seen=${group.isSeen}'); + } + }); +} + +Future listenForActivityMarkedEvents() async { + // The activity.marked WS event fires when activities are marked read/seen. + // The SDK automatically updates the feed state's isRead/isSeen flags. + // Observe changes via the feed state stream: + notificationFeed.stream.listen((state) { + final unreadCount = state.notificationStatus?.unread ?? 0; + print('Unread count updated: $unreadCount'); + + // All aggregated groups with their current read state + final unreadGroups = state.aggregatedActivities.where((g) => g.isRead != true); + print('Unread groups: ${unreadGroups.length}'); + }); +} diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 1b00fdc3..e12722df 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,5 +1,9 @@ ## Upcoming +### Improvements +- `markRead`, `markSeen`, `markAllRead`, and `markAllSeen` now update per-activity and per-group `isRead`/`isSeen` flags on the feed state in addition to the aggregate notification counts. These flags are now kept in sync when the `activity.marked` WebSocket event is received, and are also re-derived whenever `feeds.notification_feed.updated` reports a new notification status (e.g. a mark performed from another device/session). +- `ActivityData.currentFeed` and `FeedData`'s `own_*` fields (`ownMembership`, `ownFollowings`, `ownFollows`, `ownBookmarks`, `ownReactions`) are now updated from `updateActivity`/`updateActivityPartial`/`updateFeed` responses when the request set `enrichOwnFields: true`. Without it, existing state is preserved, since an omitted `own_*` field means "not fetched", not "empty". + ### New fields - Added `isRead` and `isSeen` fields to `ActivityData` and `AggregatedActivityData` for notification-feed read/seen state. - Added `friendReactionCount` and `friendReactions` fields to `ActivityData` to expose reactions from friends. diff --git a/packages/stream_feeds/lib/src/models/activity_data.dart b/packages/stream_feeds/lib/src/models/activity_data.dart index 41c6705e..15ca2b03 100644 --- a/packages/stream_feeds/lib/src/models/activity_data.dart +++ b/packages/stream_feeds/lib/src/models/activity_data.dart @@ -366,26 +366,31 @@ extension ActivityResponseMapper on ActivityResponse { extension ActivityDataMutations on ActivityData { /// Updates this activity with new data while preserving own data. /// - /// Merges [updated] activity data with this instance, preserving [ownBookmarks] and - /// [ownReactions] from this instance when not provided. This ensures that user-specific - /// data is not lost when updating from WebSocket events. + /// Merges [updated] activity data with this instance. If [ownBookmarks]/[ownReactions] are + /// explicitly passed, they take precedence (used by callers that just computed a locally + /// up-to-date value, e.g. [upsertBookmark]). Otherwise, they're taken from [updated] only when + /// [hasOwnFields] is `true`; if `false`, they're preserved from this instance. This matters + /// because these `own_*` fields are only reliably populated when the request that produced + /// [updated] set `enrichOwnFields: true` (or came from a WS event, which never carries them) — + /// an omitted field there means "not fetched", not "empty", so blindly taking it would wipe out + /// state we already know to be correct. [hasOwnFields] is forwarded to [currentFeed]'s own + /// merge for the same reason. /// /// Returns a new [ActivityData] instance with the merged data. ActivityData updateWith( ActivityData updated, { List? ownBookmarks, List? ownReactions, + bool hasOwnFields = false, }) { return updated.copyWith( - // Preserve own data from the current instance if not provided - // as they may not be reliable from WS events. - ownBookmarks: ownBookmarks ?? this.ownBookmarks, - ownReactions: ownReactions ?? this.ownReactions, + ownBookmarks: ownBookmarks ?? (hasOwnFields ? updated.ownBookmarks : this.ownBookmarks), + ownReactions: ownReactions ?? (hasOwnFields ? updated.ownReactions : this.ownReactions), poll: updated.poll?.let((it) => poll?.updateWith(it) ?? it), // Workaround until the backend fixes the issue with missing currentFeed // in some WS events currentFeed: switch (updated.currentFeed) { - final it? => currentFeed?.updateWith(it) ?? it, + final it? => currentFeed?.updateWith(it, hasOwnFields: hasOwnFields) ?? it, _ => currentFeed, }, ); diff --git a/packages/stream_feeds/lib/src/models/feed_data.dart b/packages/stream_feeds/lib/src/models/feed_data.dart index 007bbc25..979582f4 100644 --- a/packages/stream_feeds/lib/src/models/feed_data.dart +++ b/packages/stream_feeds/lib/src/models/feed_data.dart @@ -195,25 +195,24 @@ extension FeedResponseVisibilityMapper on FeedResponseVisibility { extension FeedDataMutations on FeedData { /// Updates this feed with new data while preserving own data. /// - /// Merges [updated] feed data with this instance, preserving [ownCapabilities], - /// [ownMembership], [ownFollowings], and [ownFollows] from this instance when not provided. This - /// ensures that user-specific data is not lost when updating from WebSocket events. + /// Merges [updated] feed data with this instance. [ownMembership], [ownFollowings], and + /// [ownFollows] are taken from [updated] only when [hasOwnFields] is `true`; otherwise they're + /// preserved from this instance. This matters because these `own_*` fields are only reliably + /// populated when the request that produced [updated] set `enrichOwnFields: true` (or came from + /// a WS event, which never carries them) — an omitted field there means "not fetched", not + /// "empty", so blindly taking it would wipe out state we already know to be correct. + /// + /// [ownCapabilities] is intentionally excluded from this gating: it's kept in sync through a + /// separate, always-fresh batch lookup (see `FeedCapabilitiesMixin`), independent of + /// `enrichOwnFields`. /// /// Returns a new [FeedData] instance with the merged data. - FeedData updateWith( - FeedData updated, { - List? ownCapabilities, - FeedMemberData? ownMembership, - List? ownFollowings, - List? ownFollows, - }) { + FeedData updateWith(FeedData updated, {bool hasOwnFields = false}) { 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, - ownFollowings: ownFollowings ?? this.ownFollowings, - ownFollows: ownFollows ?? this.ownFollows, + ownCapabilities: ownCapabilities, + ownMembership: hasOwnFields ? updated.ownMembership : ownMembership, + ownFollowings: hasOwnFields ? updated.ownFollowings : ownFollowings, + ownFollows: hasOwnFields ? updated.ownFollows : ownFollows, ); } } 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 037584b1..873b09e3 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 @@ -49,7 +49,10 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { } final updatedActivity = await withUpdatedFeedCapabilities(event.activity); - return state.onActivityUpdated(updatedActivity ?? event.activity); + return state.onActivityUpdated( + updatedActivity ?? event.activity, + hasOwnFields: event.hasOwnFields, + ); } if (event is ActivityDeleted) { @@ -149,7 +152,7 @@ class FeedEventHandler with FeedCapabilitiesMixin implements StateEventHandler { if (event is FeedUpdated) { if (event.feed.fid.rawValue != query.fid.rawValue) return; - return state.onFeedUpdated(event.feed); + return state.onFeedUpdated(event.feed, hasOwnFields: event.hasOwnFields); } if (event is FollowAdded) { 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 e25faddb..534b9cfd 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 @@ -385,6 +385,7 @@ class ActivityUpdated extends StateUpdateEvent { const ActivityUpdated({ required this.scope, required this.activity, + this.hasOwnFields = false, }); /// The feed scope this event applies to. @@ -392,6 +393,11 @@ class ActivityUpdated extends StateUpdateEvent { /// The updated activity data. final ActivityData activity; + + /// Whether [activity] was fetched with `enrichOwnFields: true`, meaning its `own_*` fields + /// (and its `currentFeed`'s) are authoritative and should overwrite existing state rather than + /// be preserved from it. Always `false` for WS-originated events. + final bool hasOwnFields; } /// An activity was pinned to a feed. @@ -654,10 +660,15 @@ class FeedDeleted extends StateUpdateEvent { /// A feed was updated. class FeedUpdated extends StateUpdateEvent { - const FeedUpdated({required this.feed}); + const FeedUpdated({required this.feed, this.hasOwnFields = false}); /// The updated feed data. final FeedData feed; + + /// Whether [feed] was fetched with `enrichOwnFields: true`, meaning its `own_*` fields are + /// authoritative and should overwrite existing state rather than be preserved from it. Always + /// `false` for WS-originated events. + final bool hasOwnFields; } // endregion diff --git a/packages/stream_feeds/lib/src/state/feed.dart b/packages/stream_feeds/lib/src/state/feed.dart index ed96075a..d2f8ff29 100644 --- a/packages/stream_feeds/lib/src/state/feed.dart +++ b/packages/stream_feeds/lib/src/state/feed.dart @@ -155,7 +155,9 @@ class Feed with Disposable { ); result.onSuccess( - (feedData) => _eventsEmitter.tryEmit(FeedUpdated(feed: feedData)), + (feedData) => _eventsEmitter.tryEmit( + FeedUpdated(feed: feedData, hasOwnFields: request.enrichOwnFields ?? false), + ), ); return result; @@ -219,7 +221,11 @@ class Feed with Disposable { result.onSuccess( (activity) => _eventsEmitter.tryEmit( - ActivityUpdated(scope: FidScope.unknown, activity: activity), + ActivityUpdated( + scope: FidScope.unknown, + activity: activity, + hasOwnFields: request.enrichOwnFields ?? false, + ), ), ); @@ -249,7 +255,11 @@ class Feed with Disposable { result.onSuccess( (activity) => _eventsEmitter.tryEmit( - ActivityUpdated(scope: FidScope.unknown, activity: activity), + ActivityUpdated( + scope: FidScope.unknown, + activity: activity, + hasOwnFields: request.enrichOwnFields ?? false, + ), ), ); diff --git a/packages/stream_feeds/lib/src/state/feed_state.dart b/packages/stream_feeds/lib/src/state/feed_state.dart index ed0c1bbd..53cde551 100644 --- a/packages/stream_feeds/lib/src/state/feed_state.dart +++ b/packages/stream_feeds/lib/src/state/feed_state.dart @@ -98,11 +98,15 @@ class FeedStateNotifier extends StateNotifier { key: (it) => it.group, ); - state = state.copyWith( - activities: updatedActivities, - aggregatedActivities: updatedAggregatedActivities, - activitiesPagination: pagination, - ); + state = state + .copyWith( + activities: updatedActivities, + aggregatedActivities: updatedAggregatedActivities, + activitiesPagination: pagination, + ) + // Re-derive isRead/isSeen for the newly-merged page against the current + // notification status, in case a local mark happened before this page loaded. + .reconcileReadSeen(); } /// Handles updates to the feed state when a new activity is added. @@ -130,10 +134,14 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when an activity is updated. - void onActivityUpdated(ActivityData activity) { + /// + /// [hasOwnFields] should be `true` when [activity] was fetched with `enrichOwnFields: true`, + /// so its `own_*` fields are trusted over what's already in state. See + /// [ActivityDataMutations.updateWith]. + void onActivityUpdated(ActivityData activity, {bool hasOwnFields = false}) { state = state.updateActivitiesWhere( (it) => it.id == activity.id, - update: (it) => it.updateWith(activity), + update: (it) => it.updateWith(activity, hasOwnFields: hasOwnFields), ); } @@ -204,10 +212,17 @@ class FeedStateNotifier extends StateNotifier { key: (it) => it.group, ); - state = state.copyWith( - notificationStatus: notificationStatus, - aggregatedActivities: updatedAggregatedActivities, - ); + state = state + .copyWith( + // The event may omit notification_status entirely; don't let that wipe + // out what we already know. + notificationStatus: notificationStatus ?? state.notificationStatus, + aggregatedActivities: updatedAggregatedActivities, + ) + // Re-derive isRead/isSeen from the (possibly refreshed) notification + // status, so flags stay correct even when this update didn't originate + // from this session's own markActivity() call (e.g. another device). + .reconcileReadSeen(); } /// Handles updates to the feed state when the stories feed is updated. @@ -319,9 +334,13 @@ class FeedStateNotifier extends StateNotifier { } /// Handles updates to the feed state when the feed is updated. - void onFeedUpdated(FeedData feed) { + /// + /// [hasOwnFields] should be `true` when [feed] was fetched with `enrichOwnFields: true`, so + /// its `own_*` fields are trusted over what's already in state. See + /// [FeedDataMutations.updateWith]. + void onFeedUpdated(FeedData feed, {bool hasOwnFields = false}) { final currentFeed = state.feed; - final updatedFeed = currentFeed?.updateWith(feed) ?? feed; + final updatedFeed = currentFeed?.updateWith(feed, hasOwnFields: hasOwnFields) ?? feed; // Update the feed data in the state state = state.copyWith(feed: updatedFeed); @@ -659,90 +678,150 @@ extension on FeedState { /// Marks all activities in this feed state as read. /// - /// Sets the unread count to 0 and marks all aggregated activity groups as read. - /// Updates the last read timestamp to the current time. + /// Sets the unread count to 0, records every aggregated group as read, and updates the last + /// read timestamp to the current time. Per-activity and per-group `isRead` flags are then + /// re-derived by [reconcileReadSeen]. /// /// Returns a new [FeedState] instance with the updated notification status. FeedState markAllRead() { - final aggregatedActivities = [...this.aggregatedActivities]; final readActivities = aggregatedActivities.map((it) => it.group).toList(); - // Set unread count to 0 and update read activities final updatedNotificationStatus = notificationStatus?.copyWith( unread: 0, readActivities: readActivities, lastReadAt: DateTime.timestamp(), ); - return copyWith(notificationStatus: updatedNotificationStatus); + return copyWith( + notificationStatus: updatedNotificationStatus, + ).reconcileReadSeen(); } /// Marks all activities in this feed state as seen. /// - /// Sets the unseen count to 0 and marks all aggregated activity groups as seen. - /// Updates the last seen timestamp to the current time. + /// Sets the unseen count to 0, records every aggregated group as seen, and updates the last + /// seen timestamp to the current time. Per-activity and per-group `isSeen` flags are then + /// re-derived by [reconcileReadSeen]. /// /// Returns a new [FeedState] instance with the updated notification status. FeedState markAllSeen() { - final aggregatedActivities = [...this.aggregatedActivities]; final seenActivities = aggregatedActivities.map((it) => it.group).toList(); - // Set unseen count to 0 and update seen activities final updatedNotificationStatus = notificationStatus?.copyWith( unseen: 0, seenActivities: seenActivities, lastSeenAt: DateTime.timestamp(), ); - return copyWith(notificationStatus: updatedNotificationStatus); + return copyWith( + notificationStatus: updatedNotificationStatus, + ).reconcileReadSeen(); } /// Marks specific activities as read in this feed state. /// - /// Adds the activity IDs in [readIds] to the read activities set and decreases the unread - /// count by the number of newly read activities. Updates the last read timestamp to the - /// current time. + /// Adds the activity/group IDs in [readIds] to the read activities set and decreases the + /// unread count by the number of newly read activities. Note: unlike [markAllRead], the + /// server only advances `lastReadAt` for "mark all" operations, so this leaves it untouched. + /// Per-activity and per-group `isRead` flags are then re-derived by [reconcileReadSeen]. /// /// Returns a new [FeedState] instance with the updated notification status. FeedState markRead(Set readIds) { final readActivities = notificationStatus?.readActivities?.toSet(); final updatedReadActivities = readActivities?.union(readIds).toList(); - // Decrease unread count by the number of newly read activities final unreadCount = notificationStatus?.unread ?? 0; final updatedUnreadCount = max(unreadCount - readIds.length, 0); final updatedNotificationStatus = notificationStatus?.copyWith( unread: updatedUnreadCount, readActivities: updatedReadActivities, - lastReadAt: DateTime.timestamp(), ); - return copyWith(notificationStatus: updatedNotificationStatus); + return copyWith( + notificationStatus: updatedNotificationStatus, + ).reconcileReadSeen(); } /// Marks specific activities as seen in this feed state. /// - /// Adds the activity IDs in [seenIds] to the seen activities set and decreases the unseen - /// count by the number of newly seen activities. Updates the last seen timestamp to the - /// current time. + /// Adds the activity/group IDs in [seenIds] to the seen activities set and decreases the + /// unseen count by the number of newly seen activities. Note: unlike [markAllSeen], the + /// server only advances `lastSeenAt` for "mark all" operations, so this leaves it untouched. + /// Per-activity and per-group `isSeen` flags are then re-derived by [reconcileReadSeen]. /// /// Returns a new [FeedState] instance with the updated notification status. FeedState markSeen(Set seenIds) { final seenActivities = notificationStatus?.seenActivities?.toSet(); final updatedSeenActivities = seenActivities?.union(seenIds).toList(); - // Decrease unseen count by the number of newly seen activities final unseenCount = notificationStatus?.unseen ?? 0; final updatedUnseenCount = max(unseenCount - seenIds.length, 0); final updatedNotificationStatus = notificationStatus?.copyWith( unseen: updatedUnseenCount, seenActivities: updatedSeenActivities, - lastSeenAt: DateTime.timestamp(), ); - return copyWith(notificationStatus: updatedNotificationStatus); + return copyWith( + notificationStatus: updatedNotificationStatus, + ).reconcileReadSeen(); + } + + /// Re-derives per-activity and per-group `isRead`/`isSeen` flags from [notificationStatus]. + /// + /// Mirrors how the server itself derives these flags: an item is read/seen if it was last + /// updated before `lastReadAt`/`lastSeenAt`, or if its ID (flat feeds) or group name + /// (aggregated feeds) is listed in `readActivities`/`seenActivities`. Nested activities within + /// an aggregated group inherit their group's flags, since read/seen is only tracked at the + /// group level for aggregation. + /// + /// Returns a new [FeedState] instance with up-to-date flags, or this instance unchanged if + /// there's no notification status to derive them from. + FeedState reconcileReadSeen() { + final status = notificationStatus; + if (status == null) return this; + + final lastReadAt = status.lastReadAt; + final lastSeenAt = status.lastSeenAt; + final readActivities = {...?status.readActivities}; + final seenActivities = {...?status.seenActivities}; + + bool isRead(String key, DateTime updatedAt) { + if (readActivities.contains(key)) return true; + return lastReadAt != null && updatedAt.isBefore(lastReadAt); + } + + bool isSeen(String key, DateTime updatedAt) { + if (seenActivities.contains(key)) return true; + return lastSeenAt != null && updatedAt.isBefore(lastSeenAt); + } + + final updatedActivities = activities.map((a) { + final read = isRead(a.id, a.updatedAt); + final seen = isSeen(a.id, a.updatedAt); + if (a.isRead == read && a.isSeen == seen) return a; + return a.copyWith(isRead: read, isSeen: seen); + }).toList(); + + final updatedAggregated = aggregatedActivities.map((group) { + final read = isRead(group.group, group.updatedAt); + final seen = isSeen(group.group, group.updatedAt); + + var groupActivitiesChanged = false; + final updatedGroupActivities = group.activities.map((a) { + if (a.isRead == read && a.isSeen == seen) return a; + groupActivitiesChanged = true; + return a.copyWith(isRead: read, isSeen: seen); + }).toList(); + + if (group.isRead == read && group.isSeen == seen && !groupActivitiesChanged) { + return group; + } + return group.copyWith(isRead: read, isSeen: seen, activities: updatedGroupActivities); + }).toList(); + + return copyWith(activities: updatedActivities, aggregatedActivities: updatedAggregated); } /// Marks specific activities as watched in this feed state. diff --git a/packages/stream_feeds/test/state/feed_test.dart b/packages/stream_feeds/test/state/feed_test.dart index 60b9e661..47954b38 100644 --- a/packages/stream_feeds/test/state/feed_test.dart +++ b/packages/stream_feeds/test/state/feed_test.dart @@ -91,6 +91,98 @@ void main() { ), ); + feedTest( + 'updateFeed() - with enrichOwnFields should apply the refreshed own fields to state', + build: (client) => client.feed(group: 'user', id: 'john'), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + feed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + ownFollowings: [createDefaultFollowResponse()], + ), + ), + ), + body: (tester) async { + expect(tester.feedState.feed?.ownFollowings, hasLength(1)); + + tester.mockApi( + (api) => api.updateFeed( + feedGroupId: 'user', + feedId: 'john', + updateFeedRequest: const UpdateFeedRequest(enrichOwnFields: true), + ), + result: UpdateFeedResponse( + duration: '0ms', + // The server enriched the response: no more own follows. + feed: createDefaultFeedResponse(id: 'john', groupId: 'user', ownFollowings: const []), + ), + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.feed.updateFeed( + request: const UpdateFeedRequest(enrichOwnFields: true), + ); + + expect(result.isSuccess, isTrue); + expect(result.getOrThrow().ownFollowings, isEmpty); + + // Wait for the state update triggered by the emitted event to land. + await expectEventEmitted; + expect(tester.feedState.feed?.ownFollowings, isEmpty); + }, + ); + + feedTest( + 'updateFeed() - without enrichOwnFields should preserve existing own fields in state', + build: (client) => client.feed(group: 'user', id: 'john'), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + feed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + ownFollowings: [createDefaultFollowResponse()], + ), + ), + ), + body: (tester) async { + expect(tester.feedState.feed?.ownFollowings, hasLength(1)); + + tester.mockApi( + (api) => api.updateFeed( + feedGroupId: 'user', + feedId: 'john', + updateFeedRequest: const UpdateFeedRequest(custom: {'updated': true}), + ), + result: UpdateFeedResponse( + duration: '0ms', + // enrichOwnFields wasn't set: an absent ownFollowings here means "not + // fetched", not "actually empty" — it must not overwrite local state. + feed: createDefaultFeedResponse(id: 'john', groupId: 'user'), + ), + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.feed.updateFeed( + request: const UpdateFeedRequest(custom: {'updated': true}), + ); + + expect(result.isSuccess, isTrue); + + // Wait for the state update triggered by the emitted event to land. + await expectEventEmitted; + expect(tester.feedState.feed?.ownFollowings, hasLength(1)); + }, + ); + feedTest( 'deleteFeed() - should delete feed', build: (client) => client.feed(group: 'user', id: 'john'), @@ -275,6 +367,341 @@ void main() { ), ); + feedTest( + 'updateActivity() - with enrichOwnFields should apply the refreshed ownReactions to state', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownReactions: [createDefaultReactionResponse(activityId: 'activity-1')], + ), + ], + ), + ), + body: (tester) async { + expect(tester.feedState.activities.first.ownReactions, hasLength(1)); + + tester.mockApi( + (api) => api.updateActivity( + id: 'activity-1', + updateActivityRequest: const UpdateActivityRequest(enrichOwnFields: true), + ), + result: UpdateActivityResponse( + duration: '0ms', + // The server enriched the response: no more own reactions. + activity: createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ), + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.feed.updateActivity( + id: 'activity-1', + request: const UpdateActivityRequest(enrichOwnFields: true), + ); + + expect(result.isSuccess, isTrue); + + // Wait for the state update triggered by the emitted event to land. + await expectEventEmitted; + final activity = tester.feedState.activities.first; + expect(activity.ownReactions, isEmpty); + }, + ); + + feedTest( + 'updateActivity() - without enrichOwnFields should preserve existing ownReactions in state', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownReactions: [createDefaultReactionResponse(activityId: 'activity-1')], + ), + ], + ), + ), + body: (tester) async { + expect(tester.feedState.activities.first.ownReactions, hasLength(1)); + + tester.mockApi( + (api) => api.updateActivity( + id: 'activity-1', + updateActivityRequest: const UpdateActivityRequest(custom: {'updated': true}), + ), + result: UpdateActivityResponse( + duration: '0ms', + // enrichOwnFields wasn't set: an absent ownReactions here means "not + // fetched", not "actually empty" — it must not overwrite local state. + activity: createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ).copyWith(custom: {'updated': true}), + ), + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.feed.updateActivity( + id: 'activity-1', + request: const UpdateActivityRequest(custom: {'updated': true}), + ); + + expect(result.isSuccess, isTrue); + + // Wait for the state update triggered by the emitted event to land. + await expectEventEmitted; + final activity = tester.feedState.activities.first; + expect(activity.custom?['updated'], true); + expect(activity.ownReactions, hasLength(1)); + }, + ); + + feedTest( + 'updateActivity() - with enrichOwnFields should apply the refreshed ownBookmarks to state', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownBookmarks: [createDefaultBookmarkResponse(activityId: 'activity-1')], + ), + ], + ), + ), + body: (tester) async { + expect(tester.feedState.activities.first.ownBookmarks, hasLength(1)); + + tester.mockApi( + (api) => api.updateActivity( + id: 'activity-1', + updateActivityRequest: const UpdateActivityRequest(enrichOwnFields: true), + ), + result: UpdateActivityResponse( + duration: '0ms', + // The server enriched the response: no more own bookmarks. + activity: createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ), + ), + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.feed.updateActivity( + id: 'activity-1', + request: const UpdateActivityRequest(enrichOwnFields: true), + ); + + expect(result.isSuccess, isTrue); + + // Wait for the state update triggered by the emitted event to land. + await expectEventEmitted; + final activity = tester.feedState.activities.first; + expect(activity.ownBookmarks, isEmpty); + }, + ); + + feedTest( + 'updateActivity() - without enrichOwnFields should preserve existing ownBookmarks in state', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ownBookmarks: [createDefaultBookmarkResponse(activityId: 'activity-1')], + ), + ], + ), + ), + body: (tester) async { + expect(tester.feedState.activities.first.ownBookmarks, hasLength(1)); + + tester.mockApi( + (api) => api.updateActivity( + id: 'activity-1', + updateActivityRequest: const UpdateActivityRequest(custom: {'updated': true}), + ), + result: UpdateActivityResponse( + duration: '0ms', + // enrichOwnFields wasn't set: an absent ownBookmarks here means "not + // fetched", not "actually empty" — it must not overwrite local state. + activity: createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ).copyWith(custom: {'updated': true}), + ), + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.feed.updateActivity( + id: 'activity-1', + request: const UpdateActivityRequest(custom: {'updated': true}), + ); + + expect(result.isSuccess, isTrue); + + // Wait for the state update triggered by the emitted event to land. + await expectEventEmitted; + final activity = tester.feedState.activities.first; + expect(activity.custom?['updated'], true); + expect(activity.ownBookmarks, hasLength(1)); + }, + ); + + feedTest( + 'updateActivity() - with enrichOwnFields should apply the refreshed currentFeed own ' + 'fields to state', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ).copyWith( + currentFeed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + ownFollowings: [createDefaultFollowResponse()], + ), + ), + ], + ), + ), + body: (tester) async { + expect(tester.feedState.activities.first.currentFeed?.ownFollowings, hasLength(1)); + + tester.mockApi( + (api) => api.updateActivity( + id: 'activity-1', + updateActivityRequest: const UpdateActivityRequest(enrichOwnFields: true), + ), + result: UpdateActivityResponse( + duration: '0ms', + // The server enriched the response: currentFeed's own follows are refreshed to empty. + activity: + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ).copyWith( + currentFeed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + ownFollowings: const [], + ), + ), + ), + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.feed.updateActivity( + id: 'activity-1', + request: const UpdateActivityRequest(enrichOwnFields: true), + ); + + expect(result.isSuccess, isTrue); + + // Wait for the state update triggered by the emitted event to land. + await expectEventEmitted; + final activity = tester.feedState.activities.first; + expect(activity.currentFeed?.ownFollowings, isEmpty); + }, + ); + + feedTest( + 'updateActivity() - without enrichOwnFields should preserve existing currentFeed own ' + 'fields in state', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + activities: [ + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ).copyWith( + currentFeed: createDefaultFeedResponse( + id: 'john', + groupId: 'user', + ownFollowings: [createDefaultFollowResponse()], + ), + ), + ], + ), + ), + body: (tester) async { + expect(tester.feedState.activities.first.currentFeed?.ownFollowings, hasLength(1)); + + tester.mockApi( + (api) => api.updateActivity( + id: 'activity-1', + updateActivityRequest: const UpdateActivityRequest(custom: {'updated': true}), + ), + result: UpdateActivityResponse( + duration: '0ms', + // enrichOwnFields wasn't set: an absent currentFeed.ownFollowings here means "not + // fetched", not "actually empty" — it must not overwrite local state. + activity: + createDefaultActivityResponse( + id: 'activity-1', + feeds: [feedId.rawValue], + ).copyWith( + custom: {'updated': true}, + currentFeed: createDefaultFeedResponse(id: 'john', groupId: 'user'), + ), + ), + ); + + final expectEventEmitted = expectLater( + tester.client.stateUpdateEvents, + emits(isA()), + ); + + final result = await tester.feed.updateActivity( + id: 'activity-1', + request: const UpdateActivityRequest(custom: {'updated': true}), + ); + + expect(result.isSuccess, isTrue); + + // Wait for the state update triggered by the emitted event to land. + await expectEventEmitted; + final activity = tester.feedState.activities.first; + expect(activity.custom?['updated'], true); + expect(activity.currentFeed?.ownFollowings, hasLength(1)); + }, + ); + feedTest( 'updateActivityPartial() - should partially update activity', build: (client) => client.feedFromId(feedId), @@ -3890,7 +4317,7 @@ void main() { ); feedTest( - 'ActivityMarkEvent - should mark activity as read', + 'ActivityMarkEvent - should mark activity group as read', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (response) => response.copyWith( @@ -3910,26 +4337,39 @@ void main() { expect(tester.feedState.notificationStatus?.unread, 1); expect(tester.feedState.notificationStatus?.readActivities, isEmpty); + // markRead/markSeen address aggregated notification groups by group name, + // not by the ID of an individual activity within the group. await tester.emitEvent( ActivityMarkEvent( type: EventTypes.activityMarked, createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - markRead: const ['notification-1'], + markRead: const ['group1'], ), ); - // Verify activity was marked as read + // Verify notification status was updated final notificationStatus = tester.feedState.notificationStatus; expect(notificationStatus?.unread, 0); - expect(notificationStatus?.readActivities, contains('notification-1')); - expect(notificationStatus?.lastReadAt, isNotNull); + expect(notificationStatus?.readActivities, contains('group1')); + + // Only "mark all" operations advance lastReadAt server-side (FEEDS-674); + // a targeted mark leaves it untouched. + expect(notificationStatus?.lastReadAt, DateTime(2021, 1, 1)); + + // Verify the group and its nested activity were both flagged as read + final group = tester.feedState.aggregatedActivities.first; + expect(group.isRead, isTrue); + expect(group.activities.first.isRead, isTrue); + + // Seen status must be untouched by a read-only mark + expect(group.isSeen, isNot(true)); }, ); feedTest( - 'ActivityMarkEvent - should mark activity as seen', + 'ActivityMarkEvent - should mark activity group as seen', build: (client) => client.feedFromId(feedId), setUp: (tester) => tester.getOrCreate( modifyResponse: (response) => response.copyWith( @@ -3955,15 +4395,68 @@ void main() { createdAt: DateTime.timestamp(), custom: const {}, fid: feedId.rawValue, - markSeen: const ['notification-1'], + markSeen: const ['group1'], ), ); - // Verify activity was marked as seen + // Verify notification status was updated final notificationStatus = tester.feedState.notificationStatus; expect(notificationStatus?.unseen, 0); - expect(notificationStatus?.seenActivities, contains('notification-1')); - expect(notificationStatus?.lastSeenAt, isNotNull); + expect(notificationStatus?.seenActivities, contains('group1')); + expect(notificationStatus?.lastSeenAt, DateTime(2021, 1, 1)); + + // Verify the group and its nested activity were both flagged as seen + final group = tester.feedState.aggregatedActivities.first; + expect(group.isSeen, isTrue); + expect(group.activities.first.isSeen, isTrue); + + // Read status must be untouched by a seen-only mark + expect(group.isRead, isNot(true)); + }, + ); + + feedTest( + 'ActivityMarkEvent - should mark a flat (non-aggregated) activity as read and seen', + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (response) => response.copyWith( + activities: [createDefaultActivityResponse(id: 'notification-1')], + notificationStatus: NotificationStatusResponse( + unseen: 1, + unread: 1, + seenActivities: const [], + readActivities: const [], + lastSeenAt: DateTime(2021, 1, 1), + lastReadAt: DateTime(2021, 1, 1), + ), + ), + ), + body: (tester) async { + // Note: a single ActivityMarkEvent only ever carries one mark type + // (markAllRead/markAllSeen/markRead/markSeen), so read and seen are + // marked via separate events, same as the server would emit them. + await tester.emitEvent( + ActivityMarkEvent( + type: EventTypes.activityMarked, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + markRead: const ['notification-1'], + ), + ); + await tester.emitEvent( + ActivityMarkEvent( + type: EventTypes.activityMarked, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + markSeen: const ['notification-1'], + ), + ); + + final activity = tester.feedState.activities.first; + expect(activity.isRead, isTrue); + expect(activity.isSeen, isTrue); }, ); @@ -4011,6 +4504,11 @@ void main() { final notificationStatus = tester.feedState.notificationStatus; expect(notificationStatus?.unread, 0); expect(notificationStatus?.lastReadAt, isNotNull); + + // Verify every group and its nested activities were flagged as read + final group = tester.feedState.aggregatedActivities.first; + expect(group.isRead, isTrue); + expect(group.activities.every((a) => a.isRead ?? false), isTrue); }, ); @@ -4058,6 +4556,60 @@ void main() { final notificationStatus = tester.feedState.notificationStatus; expect(notificationStatus?.unseen, 0); expect(notificationStatus?.lastSeenAt, isNotNull); + + // Verify every group and its nested activities were flagged as seen + final group = tester.feedState.aggregatedActivities.first; + expect(group.isSeen, isTrue); + expect(group.activities.every((a) => a.isSeen ?? false), isTrue); + }, + ); + + feedTest( + 'NotificationFeedUpdatedEvent - should re-derive isRead/isSeen from a status-only update ' + "(e.g. another device's mark)", + build: (client) => client.feedFromId(feedId), + setUp: (tester) => tester.getOrCreate( + modifyResponse: (it) => it.copyWith( + aggregatedActivities: initialAggregatedActivities, + notificationStatus: NotificationStatusResponse( + unseen: 1, + unread: 1, + seenActivities: const [], + readActivities: const [], + lastSeenAt: DateTime(2021, 1, 1), + lastReadAt: DateTime(2021, 1, 1), + ), + ), + ), + body: (tester) async { + final group = tester.feedState.aggregatedActivities.first; + expect(group.isRead, isNot(true)); + expect(group.isSeen, isNot(true)); + + // Another session/device marked everything read+seen: the server only + // reports the refreshed notification_status, with no aggregated_activities + // payload (mirrors the real-world event shape from stream-feeds-js#278). + await tester.emitEvent( + NotificationFeedUpdatedEvent( + type: EventTypes.notificationFeedUpdated, + createdAt: DateTime.timestamp(), + custom: const {}, + fid: feedId.rawValue, + notificationStatus: NotificationStatusResponse( + unseen: 0, + unread: 0, + lastSeenAt: DateTime.timestamp(), + lastReadAt: DateTime.timestamp(), + ), + ), + ); + + // The existing group is still present (not wiped out by the merge)... + expect(tester.feedState.aggregatedActivities, hasLength(1)); + // ...and its isRead/isSeen were re-derived from the fresh timestamps. + final updatedGroup = tester.feedState.aggregatedActivities.first; + expect(updatedGroup.isRead, isTrue); + expect(updatedGroup.isSeen, isTrue); }, ); });