diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepository.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepository.kt index ea443d2ff..922da592b 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepository.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepository.kt @@ -24,6 +24,7 @@ import io.getstream.feeds.android.client.api.model.FeedMemberData import io.getstream.feeds.android.client.api.model.FeedSuggestionData import io.getstream.feeds.android.client.api.model.FollowData import io.getstream.feeds.android.client.api.model.ModelUpdates +import io.getstream.feeds.android.client.api.model.PaginationData import io.getstream.feeds.android.client.api.state.query.FeedQuery import io.getstream.feeds.android.client.api.state.query.FeedsQuery import io.getstream.feeds.android.client.internal.model.PaginationResult @@ -124,14 +125,15 @@ internal interface FeedsRepository { * @property notificationStatus The notification status for the feed, if available. */ internal data class GetOrCreateInfo( - val activities: PaginationResult, + val pagination: PaginationData, + val activities: List, val activitiesQueryConfig: ActivitiesQueryConfig, + val aggregatedActivities: List, val feed: FeedData, val followers: List, val following: List, val followRequests: List, val members: PaginationResult, val pinnedActivities: List, - val aggregatedActivities: List, val notificationStatus: NotificationStatusResponse?, ) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImpl.kt index 6432155f5..3d5586b53 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImpl.kt @@ -64,14 +64,12 @@ internal class FeedsRepositoryImpl(private val api: FeedsApi) : FeedsRepository ) val rawFollowers = response.followers.map { it.toModel() } GetOrCreateInfo( + pagination = PaginationData(next = response.next, previous = response.prev), activities = - PaginationResult( - models = - response.activities.map { it.toModel() }.sortedWith(ActivitiesSort.Default), - pagination = PaginationData(next = response.next, previous = response.prev), - ), + response.activities.map { it.toModel() }.sortedWith(ActivitiesSort.Default), activitiesQueryConfig = QueryConfiguration(filter = query.activityFilter, sort = ActivitiesSort.Default), + aggregatedActivities = response.aggregatedActivities.map { it.toModel() }, feed = response.feed.toModel(), followers = rawFollowers.filter { it.isFollowerOf(fid) }, following = response.following.map { it.toModel() }.filter { it.isFollowing(fid) }, @@ -82,7 +80,6 @@ internal class FeedsRepositoryImpl(private val api: FeedsApi) : FeedsRepository pagination = response.memberPagination?.toModel() ?: PaginationData.EMPTY, ), pinnedActivities = response.pinnedActivities.map { it.toModel() }, - aggregatedActivities = response.aggregatedActivities.map { it.toModel() }, notificationStatus = response.notificationStatus, ) } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedImpl.kt index 181d4759e..35f5a2061 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedImpl.kt @@ -38,6 +38,7 @@ import io.getstream.feeds.android.client.internal.repository.ActivitiesRepositor import io.getstream.feeds.android.client.internal.repository.BookmarksRepository import io.getstream.feeds.android.client.internal.repository.CommentsRepository import io.getstream.feeds.android.client.internal.repository.FeedsRepository +import io.getstream.feeds.android.client.internal.repository.GetOrCreateInfo import io.getstream.feeds.android.client.internal.repository.PollsRepository import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent import io.getstream.feeds.android.client.internal.state.event.handler.FeedEventHandler @@ -228,8 +229,15 @@ internal class FeedImpl( ) return feedsRepository .getOrCreateFeed(query) - .onSuccess { _state.onQueryMoreActivities(it.activities, it.activitiesQueryConfig) } - .map { it.activities.models } + .onSuccess { + _state.onQueryMoreActivities( + activities = it.activities, + aggregatedActivities = it.aggregatedActivities, + pagination = it.pagination, + queryConfig = it.activitiesQueryConfig, + ) + } + .map(GetOrCreateInfo::activities) } override suspend fun addBookmark( 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 8d105cd31..f4255c190 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 @@ -32,7 +32,6 @@ import io.getstream.feeds.android.client.api.model.PollVoteData import io.getstream.feeds.android.client.api.state.FeedState import io.getstream.feeds.android.client.api.state.query.ActivitiesSort import io.getstream.feeds.android.client.api.state.query.FeedQuery -import io.getstream.feeds.android.client.internal.model.PaginationResult import io.getstream.feeds.android.client.internal.model.QueryConfiguration import io.getstream.feeds.android.client.internal.model.deleteBookmark import io.getstream.feeds.android.client.internal.model.isFollowRequest @@ -53,6 +52,7 @@ import io.getstream.feeds.android.client.internal.state.query.ActivitiesQueryCon import io.getstream.feeds.android.client.internal.utils.mergeSorted 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.client.internal.utils.upsertSorted import io.getstream.feeds.android.network.models.NotificationStatusResponse import kotlinx.coroutines.flow.MutableStateFlow @@ -130,10 +130,10 @@ internal class FeedStateImpl( get() = _activitiesPagination override fun onQueryFeed(result: GetOrCreateInfo) { - _activities.update { result.activities.models } - _activitiesPagination = result.activities.pagination - activitiesQueryConfig = result.activitiesQueryConfig + _activities.update { result.activities } _aggregatedActivities.update { result.aggregatedActivities } + _activitiesPagination = result.pagination + activitiesQueryConfig = result.activitiesQueryConfig _feed.update { result.feed } _followers.update { result.followers } _following.update { result.following } @@ -146,14 +146,19 @@ internal class FeedStateImpl( } override fun onQueryMoreActivities( - result: PaginationResult, + activities: List, + aggregatedActivities: List, + pagination: PaginationData, queryConfig: ActivitiesQueryConfig, ) { - _activitiesPagination = result.pagination + _activitiesPagination = pagination activitiesQueryConfig = queryConfig // Merge the new activities with the existing ones (keeping the sort order) _activities.update { current -> - current.mergeSorted(result.models, ActivityData::id, activitiesSorting) + current.mergeSorted(activities, ActivityData::id, activitiesSorting) + } + _aggregatedActivities.update { current -> + current.upsertAll(aggregatedActivities, AggregatedActivityData::group) } } @@ -386,7 +391,9 @@ internal interface FeedStateUpdates { /** Handles the result of a query for more activities. */ fun onQueryMoreActivities( - result: PaginationResult, + activities: List, + aggregatedActivities: List, + pagination: PaginationData, queryConfig: ActivitiesQueryConfig, ) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/utils/List.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/utils/List.kt index a617c5100..498663345 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/utils/List.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/utils/List.kt @@ -216,6 +216,37 @@ internal fun List.upsertSorted( update: (old: T) -> T = { element }, ): List = upsertSorted(element, idSelector, CompositeComparator(sort), update) +/** + * Upserts all elements from another list into this list based on a specified key. + * + * This function updates existing elements in the list that have the same key (as determined by + * [idSelector]) as elements in [that] list. If an element from [that] does not exist in this list, + * it is appended. + * + * @param that The list of elements to upsert into this list. + * @param idSelector A function that extracts a key from an element. This is used to determine + * whether an element already exists in the list. + * @return A new list containing the upserted elements. Existing elements are updated, and new + * elements are appended. + */ +internal fun List.upsertAll(that: List, idSelector: (T) -> R): List { + // Using LinkedHashMap instead of e.g. HashMap to preserve order + val toUpsert = that.associateByTo(LinkedHashMap(), idSelector) + val result = ArrayList(this.size + that.size) + + // Update what should be updated + forEach { item -> + val key = idSelector(item) + result.add(toUpsert[key] ?: item) + toUpsert.remove(key) + } + + // Insert the rest + result.addAll(toUpsert.values) + + return result +} + /** * Merges two sorted arrays while maintaining the sort order and handling duplicates. * diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImplTest.kt index 0ebf94bb2..654619b68 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/FeedsRepositoryImplTest.kt @@ -75,16 +75,14 @@ internal class FeedsRepositoryImplTest { apiResult = apiResult, repositoryResult = GetOrCreateInfo( - activities = - PaginationResult( - models = emptyList(), - pagination = PaginationData(next = "next", previous = "prev"), - ), + pagination = PaginationData(next = "next", previous = "prev"), + activities = emptyList(), activitiesQueryConfig = QueryConfiguration( filter = query.activityFilter, sort = ActivitiesSort.Default, ), + aggregatedActivities = emptyList(), feed = apiResult.feed.toModel(), followers = emptyList(), following = emptyList(), @@ -92,7 +90,6 @@ internal class FeedsRepositoryImplTest { members = PaginationResult(models = emptyList(), pagination = PaginationData.EMPTY), pinnedActivities = emptyList(), - aggregatedActivities = emptyList(), notificationStatus = null, ), ) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt index 6b1012c30..85d2b9bc1 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt @@ -772,16 +772,17 @@ internal class FeedImplTest { val paginationData = PaginationData(next = "cursor") return GetOrCreateInfo( - activities = PaginationResult(models = activities, pagination = paginationData), + pagination = paginationData, + activities = activities, activitiesQueryConfig = QueryConfiguration(filter = null, sort = ActivitiesSort.Default), + aggregatedActivities = emptyList(), feed = testFeedData, followers = followers, following = following, followRequests = followRequests, members = PaginationResult(models = members, pagination = paginationData), pinnedActivities = emptyList(), - aggregatedActivities = emptyList(), notificationStatus = null, ) } 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 5c4336db5..b751ff24e 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 @@ -88,21 +88,42 @@ internal class FeedStateImplTest { } @Test - fun `on queryMoreActivities, then merge activities`() = runTest { - val initialActivities = listOf(activityData()) - setupInitialState(initialActivities) + fun `on queryMoreActivities, merge with new activities and aggregated activities`() = runTest { + val initialActivities = listOf(activityData("activity-1")) + val initialAggregated = + listOf( + aggregatedActivityData( + group = "group-1", + activities = listOf(activityData("activity-1")), + activityCount = 1, + ) + ) + setupInitialState(activities = initialActivities, aggregatedActivities = initialAggregated) val newActivities = listOf(activityData("activity-2"), activityData("activity-3")) - val newPaginationResult = - PaginationResult( - models = newActivities, - pagination = PaginationData(next = "next-cursor-2", previous = null), + val newAggregated = + listOf( + aggregatedActivityData( + group = "group-2", + activities = listOf(activityData("activity-2")), + ), + aggregatedActivityData( + group = "group-3", + activities = listOf(activityData("activity-3")), + ), ) + val newPagination = PaginationData(next = "next-cursor-2", previous = null) - feedState.onQueryMoreActivities(newPaginationResult, createQueryConfig()) + feedState.onQueryMoreActivities( + activities = newActivities, + aggregatedActivities = newAggregated, + pagination = newPagination, + queryConfig = createQueryConfig(), + ) - assertEquals(3, feedState.activities.value.size) - assertEquals("next-cursor-2", feedState.activitiesPagination?.next) + assertEquals(newPagination, feedState.activitiesPagination) + assertEquals(initialActivities + newActivities, feedState.activities.value) + assertEquals((initialAggregated + newAggregated), feedState.aggregatedActivities.value) } @Test @@ -595,22 +616,19 @@ internal class FeedStateImplTest { pinnedActivities: List = emptyList(), aggregatedActivities: List = emptyList(), ): GetOrCreateInfo { - val paginationResult = - PaginationResult( - models = activities, - pagination = PaginationData(next = "next-cursor", previous = null), - ) + val pagination = PaginationData(next = "next-cursor", previous = null) val queryConfig = createQueryConfig() return GetOrCreateInfo( - activities = paginationResult, + pagination = pagination, + activities = activities, activitiesQueryConfig = queryConfig, + aggregatedActivities = aggregatedActivities, feed = feed, followers = followers, following = following, followRequests = followRequests, pinnedActivities = pinnedActivities, - aggregatedActivities = aggregatedActivities, notificationStatus = null, members = PaginationResult( diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/utils/ListUpsertTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/utils/ListUpsertTest.kt index 41ca0b550..d2ff74f5c 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/utils/ListUpsertTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/utils/ListUpsertTest.kt @@ -21,12 +21,8 @@ import org.junit.Test internal class ListUpsertTest { - data class TestUser(val id: String, val name: String, val age: Int) - - // MARK: - Update Existing Element Tests - @Test - fun testUpsert_updateExistingElement() { + fun `upsert updates existing element in place`() { val originalList = listOf( TestUser("1", "Alice", 25), @@ -35,118 +31,105 @@ internal class ListUpsertTest { ) val updatedUser = TestUser("2", "Bob Updated", 31) - val result = originalList.upsert(updatedUser) { it.id } + val result = originalList.upsert(updatedUser, TestUser::id) - assertEquals(3, result.size) - assertEquals("Alice", result[0].name) - assertEquals("Bob Updated", result[1].name) - assertEquals(31, result[1].age) - assertEquals("Charlie", result[2].name) + val expected = + listOf( + TestUser("1", "Alice", 25), + TestUser("2", "Bob Updated", 31), + TestUser("3", "Charlie", 35), + ) + assertEquals(expected, result) } - // MARK: - Insert New Element Tests - @Test - fun testUpsert_insertNewElement_intoEmptyList() { + fun `upsert inserts new element into empty list`() { val originalList = emptyList() val newUser = TestUser("1", "Alice", 25) - val result = originalList.upsert(newUser) { it.id } + val result = originalList.upsert(newUser, TestUser::id) - assertEquals(1, result.size) - assertEquals("Alice", result[0].name) - assertEquals("1", result[0].id) + val expected = listOf(TestUser("1", "Alice", 25)) + assertEquals(expected, result) } @Test - fun testUpsert_insertNewElement_intoNonEmptyList() { + fun `upsert appends new element to non-empty list`() { val originalList = listOf(TestUser("1", "Alice", 25), TestUser("2", "Bob", 30)) val newUser = TestUser("3", "Charlie", 35) - val result = originalList.upsert(newUser) { it.id } + val result = originalList.upsert(newUser, TestUser::id) - assertEquals(3, result.size) - assertEquals("Alice", result[0].name) - assertEquals("Bob", result[1].name) - assertEquals("Charlie", result[2].name) + val expected = + listOf( + TestUser("1", "Alice", 25), + TestUser("2", "Bob", 30), + TestUser("3", "Charlie", 35), + ) + assertEquals(expected, result) } - // MARK: - Immutability Tests - @Test - fun testUpsert_originalListUnchanged_onUpdate() { + fun `upsert leaves original list unchanged on update`() { val originalList = listOf(TestUser("1", "Alice", 25), TestUser("2", "Bob", 30)) val updatedUser = TestUser("1", "Alice Updated", 26) - val result = originalList.upsert(updatedUser) { it.id } - - // Original list should be unchanged - assertEquals(2, originalList.size) - assertEquals("Alice", originalList[0].name) - assertEquals(25, originalList[0].age) + val result = originalList.upsert(updatedUser, TestUser::id) - // Result should be different assertNotSame(originalList, result) - assertEquals("Alice Updated", result[0].name) - assertEquals(26, result[0].age) + val expectedOriginal = listOf(TestUser("1", "Alice", 25), TestUser("2", "Bob", 30)) + assertEquals(expectedOriginal, originalList) + + val expectedResult = listOf(TestUser("1", "Alice Updated", 26), TestUser("2", "Bob", 30)) + assertEquals(expectedResult, result) } @Test - fun testUpsert_originalListUnchanged_onInsert() { + fun `upsert leaves original list unchanged on insert`() { val originalList = listOf(TestUser("1", "Alice", 25)) val newUser = TestUser("2", "Bob", 30) - val result = originalList.upsert(newUser) { it.id } - - // Original list should be unchanged - assertEquals(1, originalList.size) - assertEquals("Alice", originalList[0].name) + val result = originalList.upsert(newUser, TestUser::id) - // Result should be different assertNotSame(originalList, result) - assertEquals(2, result.size) - assertEquals("Bob", result[1].name) - } + val expectedOriginal = listOf(TestUser("1", "Alice", 25)) + assertEquals(expectedOriginal, originalList) - // MARK: - Edge Cases + val expectedResult = listOf(TestUser("1", "Alice", 25), TestUser("2", "Bob", 30)) + assertEquals(expectedResult, result) + } @Test - fun testUpsert_singleElementList_update() { + fun `upsert updates single element in single-element list`() { val originalList = listOf(TestUser("1", "Alice", 25)) val updatedUser = TestUser("1", "Alice Updated", 26) - val result = originalList.upsert(updatedUser) { it.id } + val result = originalList.upsert(updatedUser, TestUser::id) - assertEquals(1, result.size) - assertEquals("Alice Updated", result[0].name) - assertEquals(26, result[0].age) + val expected = listOf(TestUser("1", "Alice Updated", 26)) + assertEquals(expected, result) } @Test - fun testUpsert_singleElementList_insert() { + fun `upsert appends to single-element list`() { val originalList = listOf(TestUser("1", "Alice", 25)) val newUser = TestUser("2", "Bob", 30) - val result = originalList.upsert(newUser) { it.id } + val result = originalList.upsert(newUser, TestUser::id) - assertEquals(2, result.size) - assertEquals("Alice", result[0].name) - assertEquals("Bob", result[1].name) + val expected = listOf(TestUser("1", "Alice", 25), TestUser("2", "Bob", 30)) + assertEquals(expected, result) } @Test - fun testUpsert_duplicateConsecutiveOperations() { + fun `upsert handles consecutive operations`() { val originalList = listOf(TestUser("1", "Alice", 25)) - // Update the same element twice - var result = originalList.upsert(TestUser("1", "Alice Updated", 26)) { it.id } - result = result.upsert(TestUser("1", "Alice Final", 27)) { it.id } + var result = originalList.upsert(TestUser("1", "Alice Updated", 26), TestUser::id) + result = result.upsert(TestUser("1", "Alice Final", 27), TestUser::id) - assertEquals(1, result.size) - assertEquals("Alice Final", result[0].name) - assertEquals(27, result[0].age) + val expected = listOf(TestUser("1", "Alice Final", 27)) + assertEquals(expected, result) } - // MARK: - Order Preservation Tests - @Test - fun testUpsert_preservesOriginalOrder_onUpdate() { + fun `upsert preserves original order when updating`() { val originalList = listOf( TestUser("1", "Alice", 25), @@ -156,25 +139,55 @@ internal class ListUpsertTest { ) val updatedUser = TestUser("2", "Bob Updated", 31) - val result = originalList.upsert(updatedUser) { it.id } + val result = originalList.upsert(updatedUser, TestUser::id) - assertEquals(4, result.size) - assertEquals("Alice", result[0].name) - assertEquals("Bob Updated", result[1].name) - assertEquals("Charlie", result[2].name) - assertEquals("David", result[3].name) + val expected = + listOf( + TestUser("1", "Alice", 25), + TestUser("2", "Bob Updated", 31), + TestUser("3", "Charlie", 35), + TestUser("4", "David", 40), + ) + assertEquals(expected, result) } @Test - fun testUpsert_appendsToEnd_onInsert() { + fun `upsert appends new element to end of list`() { val originalList = listOf(TestUser("1", "Alice", 25), TestUser("2", "Bob", 30)) val newUser = TestUser("3", "Charlie", 35) - val result = originalList.upsert(newUser) { it.id } + val result = originalList.upsert(newUser, TestUser::id) + + val expected = + listOf( + TestUser("1", "Alice", 25), + TestUser("2", "Bob", 30), + TestUser("3", "Charlie", 35), + ) + assertEquals(expected, result) + } + + @Test + fun `upsertAll updates matching elements in place and appends new ones`() { + val originalList = + listOf( + TestUser("1", "Alice", 25), + TestUser("2", "Bob", 30), + TestUser("3", "Charlie", 35), + ) + + val updates = listOf(TestUser("2", "Bob Updated", 31), TestUser("4", "David", 40)) + val result = originalList.upsertAll(updates, TestUser::id) - assertEquals(3, result.size) - assertEquals("Alice", result[0].name) - assertEquals("Bob", result[1].name) - assertEquals("Charlie", result[2].name) // Added at the end + val expected = + listOf( + TestUser("1", "Alice", 25), + TestUser("2", "Bob Updated", 31), + TestUser("3", "Charlie", 35), + TestUser("4", "David", 40), + ) + assertEquals(expected, result) } + + data class TestUser(val id: String, val name: String, val age: Int) }