From fcec44793e52b54516b49a1c2a3397560607e0a8 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:09:11 +0200 Subject: [PATCH] Compute isRead/isSeen flags based on notification status --- .../api/model/AggregatedActivityData.kt | 6 ++ .../model/AggregatedActivityOperations.kt | 3 + .../client/internal/state/FeedStateImpl.kt | 37 ++++++++++ .../internal/state/FeedStateImplTest.kt | 74 +++++++++++++++++++ .../event/handler/FeedEventHandlerTest.kt | 3 + .../android/client/internal/test/TestData.kt | 18 ++++- .../notification/NotificationsScreen.kt | 3 +- .../notification/NotificationsViewModel.kt | 5 +- 8 files changed, 139 insertions(+), 10 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/AggregatedActivityData.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/AggregatedActivityData.kt index 62e0538ce..df76fdd27 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/AggregatedActivityData.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/AggregatedActivityData.kt @@ -32,6 +32,9 @@ import java.util.Date * @property updatedAt The date and time when this aggregation was last updated. * @property userCount The number of unique users involved in these activities. * @property userCountTruncated Indicates if the user count is truncated. + * @property isRead Whether this group has been read. Relevant for notification feeds. + * @property isSeen Whether this group has been seen. Relevant for notification feeds. + * @property isWatched Whether this group was watched. Relevant for stories. */ public data class AggregatedActivityData( public val activities: List, @@ -42,6 +45,9 @@ public data class AggregatedActivityData( public val updatedAt: Date, public val userCount: Int, public val userCountTruncated: Boolean, + public val isRead: Boolean?, + public val isSeen: Boolean?, + public val isWatched: Boolean?, ) { /** diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/AggregatedActivityOperations.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/AggregatedActivityOperations.kt index 32ccf2b3b..e6756b4c2 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/AggregatedActivityOperations.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/AggregatedActivityOperations.kt @@ -33,5 +33,8 @@ internal fun AggregatedActivityResponse.toModel(): AggregatedActivityData { updatedAt = updatedAt, userCount = userCount, userCountTruncated = userCountTruncated, + isRead = isRead, + isSeen = isSeen, + isWatched = isWatched, ) } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImpl.kt index d0937a184..dd9417512 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImpl.kt @@ -56,6 +56,7 @@ import io.getstream.feeds.android.client.internal.utils.updateIf import io.getstream.feeds.android.client.internal.utils.upsert import io.getstream.feeds.android.client.internal.utils.upsertAll import io.getstream.feeds.android.network.models.NotificationStatusResponse +import java.util.Date import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -293,9 +294,11 @@ internal class FeedStateImpl( it.isFollowRequest && it.targetFeed.fid == fid -> { newRequests += it } + it.isFollowing(fid) -> { newFollowing += it } + it.isFollowerOf(fid) -> { newFollowers += it } @@ -366,10 +369,44 @@ internal class FeedStateImpl( aggregatedActivities: List, notificationStatus: NotificationStatusResponse?, ) { + notificationStatus?.let(::updateReadSeenStatus) updateAggregatedActivities(aggregatedActivities, prependNew = true) _notificationStatus.update { notificationStatus } } + private fun updateReadSeenStatus(notificationStatus: NotificationStatusResponse) { + val lastReadAt = notificationStatus.lastReadAt + val lastSeenAt = notificationStatus.lastSeenAt + val readIds = notificationStatus.readActivities.orEmpty() + val seenIds = notificationStatus.seenActivities.orEmpty() + + updateActivitiesWhere({ true }) { activity -> + val isRead = activity.id.isMarked(activity.updatedAt, lastReadAt, readIds) + val isSeen = activity.id.isMarked(activity.updatedAt, lastSeenAt, seenIds) + + if (activity.isRead != isRead || activity.isSeen != isSeen) { + activity.copy(isRead = isRead, isSeen = isSeen) + } else { + activity + } + } + _aggregatedActivities.update { current -> + current.map { group -> + val isRead = group.group.isMarked(group.updatedAt, lastReadAt, readIds) + val isSeen = group.group.isMarked(group.updatedAt, lastSeenAt, seenIds) + + if (group.isRead != isRead || group.isSeen != isSeen) { + group.copy(isRead = isRead, isSeen = isSeen) + } else { + group + } + } + } + } + + private fun String.isMarked(updated: Date, lastMarked: Date?, markedIds: List) = + (lastMarked != null && updated.before(lastMarked)) || this in markedIds + override fun onStoriesFeedUpdated( activities: List, aggregatedActivities: List, diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImplTest.kt index 16673a4c2..cf0efcecf 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedStateImplTest.kt @@ -46,6 +46,7 @@ import io.getstream.feeds.android.client.internal.test.TestData.pollVoteData import io.getstream.feeds.android.network.models.FeedOwnCapability import io.getstream.feeds.android.network.models.NotificationStatusResponse import io.mockk.mockk +import java.util.Date import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -605,6 +606,8 @@ internal class FeedStateImplTest { activityCount = it, group = "group-$it", userCount = it, + isRead = false, + isSeen = false, ) } @@ -635,6 +638,77 @@ internal class FeedStateImplTest { assertEquals(notificationStatus, feedState.notificationStatus.value) } + @Test + fun `onNotificationFeedUpdated, update isRead and isSeen from notification status`() = runTest { + val old = activityData("old", updatedAt = 1000) + val recent = activityData("recent", updatedAt = 3000) + val markedById = activityData("marked-by-id", updatedAt = 5000) + setupInitialState(activities = listOf(old, recent, markedById)) + + val notificationStatus = + NotificationStatusResponse( + unread = 0, + unseen = 1, + lastReadAt = Date(2000), + lastSeenAt = Date(4000), + readActivities = listOf("marked-by-id"), + seenActivities = listOf("marked-by-id"), + ) + + feedState.onNotificationFeedUpdated(emptyList(), notificationStatus) + + val expected = + listOf( + activityData("old", updatedAt = 1000, isRead = true, isSeen = true), + activityData("recent", updatedAt = 3000, isRead = false, isSeen = true), + activityData("marked-by-id", updatedAt = 5000, isRead = true, isSeen = true), + ) + assertEquals(expected, feedState.activities.value) + } + + @Test + fun `onNotificationFeedUpdated, update isRead and isSeen on aggregated activities`() = runTest { + val oldGroup = aggregatedActivityData(group = "group-old", updatedAt = Date(1000)) + val recentGroup = aggregatedActivityData(group = "group-recent", updatedAt = Date(3000)) + val markedByGroup = aggregatedActivityData(group = "group-marked", updatedAt = Date(5000)) + setupInitialState(aggregatedActivities = listOf(oldGroup, recentGroup, markedByGroup)) + + val notificationStatus = + NotificationStatusResponse( + unread = 0, + unseen = 1, + lastReadAt = Date(2000), + lastSeenAt = Date(4000), + readActivities = listOf("group-marked"), + seenActivities = listOf("group-marked"), + ) + + feedState.onNotificationFeedUpdated(emptyList(), notificationStatus) + + val expected = + listOf( + aggregatedActivityData( + group = "group-old", + updatedAt = Date(1000), + isRead = true, + isSeen = true, + ), + aggregatedActivityData( + group = "group-recent", + updatedAt = Date(3000), + isRead = false, + isSeen = true, + ), + aggregatedActivityData( + group = "group-marked", + updatedAt = Date(5000), + isRead = true, + isSeen = true, + ), + ) + assertEquals(expected, feedState.aggregatedActivities.value) + } + @Test fun `on onStoriesFeedUpdated, update matching activities and upsert groups`() = runTest { val initialActivities = List(3) { activityData("story-$it") } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandlerTest.kt index fd8d59a21..e46b182ad 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandlerTest.kt @@ -117,6 +117,9 @@ internal class FeedEventHandlerTest( updatedAt = Date(), userCount = 1, userCountTruncated = false, + isRead = null, + isSeen = null, + isWatched = null, ) ) private val notificationStatus = NotificationStatusResponse(unread = 0, unseen = 1) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt index 74ee60727..02663111e 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt @@ -225,6 +225,10 @@ internal object TestData { hidden: Boolean = false, user: UserData = userData("user-1"), currentFeed: FeedData? = null, + updatedAt: Long = 1000, + isRead: Boolean? = null, + isSeen: Boolean? = null, + isWatched: Boolean? = null, ): ActivityData = ActivityData( attachments = emptyList(), @@ -245,9 +249,9 @@ internal object TestData { hidden = hidden, id = id, interestTags = emptyList(), - isRead = null, - isSeen = null, - isWatched = null, + isRead = isRead, + isSeen = isSeen, + isWatched = isWatched, latestReactions = emptyList(), location = null, mentionedUsers = emptyList(), @@ -271,7 +275,7 @@ internal object TestData { shareCount = 0, text = text, type = type, - updatedAt = Date(1000), + updatedAt = Date(updatedAt), user = user, visibility = ActivityDataVisibility.Public, visibilityTag = null, @@ -286,6 +290,9 @@ internal object TestData { updatedAt: Date = Date(1000), userCount: Int = 1, userCountTruncated: Boolean = false, + isRead: Boolean? = null, + isSeen: Boolean? = null, + isWatched: Boolean? = null, ): AggregatedActivityData = AggregatedActivityData( activities = activities, @@ -296,6 +303,9 @@ internal object TestData { updatedAt = updatedAt, userCount = userCount, userCountTruncated = userCountTruncated, + isRead = isRead, + isSeen = isSeen, + isWatched = isWatched, ) fun appData(name: String = "Test App"): AppData = diff --git a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/notification/NotificationsScreen.kt b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/notification/NotificationsScreen.kt index 92fcfd951..8717a1015 100644 --- a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/notification/NotificationsScreen.kt +++ b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/notification/NotificationsScreen.kt @@ -108,10 +108,9 @@ private fun NotificationsScreen( val activities by state.aggregatedActivities.collectAsStateWithLifecycle() LazyColumn { items(activities) { - val isActivityRead = notificationStatus?.readActivities?.contains(it.group) == true NotificationItem( data = it, - isActivityRead = isActivityRead, + isActivityRead = it.isRead == true, onMarkRead = { onMarkAggregatedActivityRead(it) }, ) } diff --git a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/notification/NotificationsViewModel.kt b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/notification/NotificationsViewModel.kt index a18aa7c94..1495fa8a1 100644 --- a/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/notification/NotificationsViewModel.kt +++ b/stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/notification/NotificationsViewModel.kt @@ -67,10 +67,7 @@ class NotificationsViewModel @Inject constructor(loginManager: LoginManager) : V fun onMarkAggregatedActivityRead(activity: AggregatedActivityData) { feed.withFirstContent(viewModelScope) { // Check that the activity is not already read - val notificationStatus = state.notificationStatus.value - if (notificationStatus?.readActivities?.contains(activity.group) == true) { - return@withFirstContent - } + if (activity.isRead == true) return@withFirstContent // Mark the aggregated activity as read val request = MarkActivityRequest(markRead = listOf(activity.group)) markActivity(request)