Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/code_snippets/05_06_notification_feeds.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,41 @@ Future<void> markNotificationsAsRead() async {
),
);
}

Future<void> 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<void> 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),
);
}
}
42 changes: 41 additions & 1 deletion docs/code_snippets/08_01_events.dart
Original file line number Diff line number Diff line change
@@ -1 +1,41 @@
//TODO
import 'package:stream_feeds/stream_feeds.dart';

late StreamFeedsClient client;
late Feed notificationFeed;

Future<void> listenToClientEvents() async {
// Listen to all WebSocket events from the client
client.events.listen((event) {
print('Received event: ${event.runtimeType}');
});
}

Future<void> 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<void> 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}');
});
}
4 changes: 4 additions & 0 deletions packages/stream_feeds/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
21 changes: 13 additions & 8 deletions packages/stream_feeds/lib/src/models/activity_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<BookmarkData>? ownBookmarks,
List<FeedsReactionData>? 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,
},
);
Expand Down
31 changes: 15 additions & 16 deletions packages/stream_feeds/lib/src/models/feed_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeedOwnCapability>? ownCapabilities,
FeedMemberData? ownMembership,
List<FollowData>? ownFollowings,
List<FollowData>? 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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -385,13 +385,19 @@ class ActivityUpdated extends StateUpdateEvent {
const ActivityUpdated({
required this.scope,
required this.activity,
this.hasOwnFields = false,
});

/// The feed scope this event applies to.
final FidScope scope;

/// 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.
Expand Down Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions packages/stream_feeds/lib/src/state/feed.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
),
),
);

Expand Down Expand Up @@ -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,
),
),
);

Expand Down
Loading
Loading