diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt index e13aea005..bfc96f997 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediator.kt @@ -63,6 +63,11 @@ internal class MixedRemoteMediator( timelineResult .sortedByDescending { it.createdAt.value.toEpochMilliseconds() + }.distinctBy { item -> + // A mixed timeline can receive the same logical item from multiple + // sub timelines (for example home + list). Keep one copy so Compose + // never receives duplicate lazy keys for the same item. + item.itemKey } database.connect { diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt index 1245c4a87..d1a98bd79 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/model/UiTimelineV2.kt @@ -65,6 +65,8 @@ public sealed class UiTimelineV2 { override val itemKey: String = buildString { append("Message_") + append(accountType) + append("_") append(statusKey) if (extraKey != null) { append("_") @@ -162,6 +164,8 @@ public sealed class UiTimelineV2 { override val itemKey: String = buildString { append("Feed_") + append(accountType) + append("_") append(url) if (extraKey != null) { append("_") @@ -237,6 +241,8 @@ public sealed class UiTimelineV2 { buildString { append(platformType.name) append("_") + append(accountType) + append("_") append(statusKey) message?.let { append("_") @@ -311,6 +317,8 @@ public sealed class UiTimelineV2 { override val itemKey: String = buildString { append("User_") + append(accountType) + append("_") append(value.key) message?.let { append("_") @@ -340,6 +348,8 @@ public sealed class UiTimelineV2 { override val itemKey: String = buildString { append("UserList_") + append(accountType) + append("_") append(users.hashCode()) post?.let { append("_") diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt index 116c3abd4..a58cf03e8 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/data/datasource/microblog/MixedRemoteMediatorTest.kt @@ -336,6 +336,113 @@ class MixedRemoteMediatorTest : RobolectricTest() { assertEquals(listOf(postA.statusKey, postB.statusKey), post.parents.map { it.statusKey }) } + @Test + fun refreshDeduplicatesSamePostReturnedByMultipleSubTimelines() = + runTest { + val accountKey = MicroBlogKey("timeline", "mastodon.example") + val accountType = AccountType.Specific(accountKey) + val user = profile(MicroBlogKey("user", "mastodon.example"), "User") + val duplicatedPost = + createPost( + accountType = accountType, + user = user, + statusKey = MicroBlogKey("same", "mastodon.example"), + text = "duplicate", + ) + + val first = + FakeLoader("home") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(duplicatedPost), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("No prepend expected") + } + } + val second = + FakeLoader("list") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(duplicatedPost), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("No prepend expected") + } + } + + val mediator = MixedRemoteMediator(db, listOf(first, second)) + val result = mediator.load(pageSize = 20, request = PagingRequest.Refresh) + + assertEquals(1, result.data.size) + assertEquals(duplicatedPost.itemKey, result.data.single().itemKey) + } + + @Test + fun refreshKeepsSamePostFromDifferentAccountsAsSeparateItems() = + runTest { + val firstAccount = AccountType.Specific(MicroBlogKey("timeline-a", "mastodon.example")) + val secondAccount = AccountType.Specific(MicroBlogKey("timeline-b", "mastodon.example")) + val sharedStatusKey = MicroBlogKey("same", "mastodon.example") + + val firstPost = + createPost( + accountType = firstAccount, + user = profile(MicroBlogKey("user-a", "mastodon.example"), "User A"), + statusKey = sharedStatusKey, + text = "duplicate", + ) + val secondPost = + createPost( + accountType = secondAccount, + user = profile(MicroBlogKey("user-b", "mastodon.example"), "User B"), + statusKey = sharedStatusKey, + text = "duplicate", + ) + + val first = + FakeLoader("home_a") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(firstPost), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("No prepend expected") + } + } + val second = + FakeLoader("home_b") { request -> + when (request) { + PagingRequest.Refresh -> + PagingResult( + data = listOf(secondPost), + nextKey = null, + ) + + is PagingRequest.Append -> error("No append expected") + is PagingRequest.Prepend -> error("No prepend expected") + } + } + + val mediator = MixedRemoteMediator(db, listOf(first, second)) + val result = mediator.load(pageSize = 20, request = PagingRequest.Refresh) + + assertEquals(2, result.data.size) + assertEquals( + setOf(firstPost.itemKey, secondPost.itemKey), + result.data.map { it.itemKey }.toSet(), + ) + } + private class FakeLoader( override val pagingKey: String, private val onLoad: suspend (PagingRequest) -> PagingResult,