From 53a4c943db48db09adea6e394c44f2bb11652b82 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 11 Sep 2025 11:14:03 +0200 Subject: [PATCH 01/18] Handle CommentDeletedEvent for CommentList --- .../state/ActivityCommentListStateImpl.kt | 41 ++-------- .../internal/state/CommentListStateImpl.kt | 20 +++++ .../event/handler/CommentListEventHandler.kt | 2 + .../android/client/internal/utils/List.kt | 33 +++++++- .../state/CommentListStateImplTest.kt | 36 +++++++++ .../handler/CommentListEventHandlerTest.kt | 17 ++++ .../internal/utils/ListTreeUpdateTest.kt | 78 +++++++++++++++++++ 7 files changed, 190 insertions(+), 37 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListStateImpl.kt index a5424fb6..e65ffbd9 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListStateImpl.kt @@ -28,6 +28,7 @@ import io.getstream.feeds.android.client.api.state.ActivityCommentListState import io.getstream.feeds.android.client.api.state.query.ActivityCommentsQuery import io.getstream.feeds.android.client.api.state.query.toComparator import io.getstream.feeds.android.client.internal.utils.mergeSorted +import io.getstream.feeds.android.client.internal.utils.treeRemoveFirst import io.getstream.feeds.android.client.internal.utils.treeUpdateFirst import io.getstream.feeds.android.client.internal.utils.upsertSorted import kotlinx.coroutines.flow.MutableStateFlow @@ -96,16 +97,13 @@ internal class ActivityCommentListStateImpl( override fun onCommentRemoved(commentId: String) { _comments.update { current -> - // First check if it's a top-level comment - val filteredTopLevel = current.filter { it.id != commentId } - - if (filteredTopLevel.size != current.size) { - // A top-level comment was removed - filteredTopLevel - } else { - // It might be a nested reply, search and remove recursively - current.map { comment -> removeCommentFromReplies(comment, commentId) } - } + current.treeRemoveFirst( + matcher = { it.id == commentId }, + childrenSelector = { it.replies.orEmpty() }, + updateChildren = { parent, children -> + parent.copy(replies = children, replyCount = parent.replyCount - 1) + }, + ) } } @@ -139,29 +137,6 @@ internal class ActivityCommentListStateImpl( return parent } - private fun removeCommentFromReplies( - comment: ThreadedCommentData, - commentIdToRemove: String, - ): ThreadedCommentData { - // If this comment has no replies, nothing to remove - if (comment.replies.isNullOrEmpty()) { - return comment - } - - // Check if the comment to remove is a direct child - val filteredReplies = comment.replies.filter { it.id != commentIdToRemove } - if (filteredReplies.size != comment.replies.size) { - // Found and removed a direct child, update reply count - return comment.copy(replies = filteredReplies, replyCount = comment.replyCount - 1) - } - - // The comment wasn't a direct child, search recursively in nested replies - val updatedReplies = - comment.replies.map { reply -> removeCommentFromReplies(reply, commentIdToRemove) } - - return comment.copy(replies = updatedReplies) - } - private fun addCommentReaction( comment: ThreadedCommentData, targetId: String, diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/CommentListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/CommentListStateImpl.kt index f1fc15c6..6b6295f4 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/CommentListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/CommentListStateImpl.kt @@ -22,6 +22,7 @@ import io.getstream.feeds.android.client.api.state.CommentListState import io.getstream.feeds.android.client.api.state.query.CommentsQuery import io.getstream.feeds.android.client.api.state.query.toComparator import io.getstream.feeds.android.client.internal.utils.mergeSorted +import io.getstream.feeds.android.client.internal.utils.treeRemoveFirst import io.getstream.feeds.android.client.internal.utils.treeUpdateFirst import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -76,6 +77,18 @@ internal class CommentListStateImpl(override val query: CommentsQuery) : Comment ) } } + + override fun onCommentRemoved(commentId: String) { + _comments.update { current -> + current.treeRemoveFirst( + matcher = { it.id == commentId }, + childrenSelector = { it.replies.orEmpty() }, + updateChildren = { parent, children -> + parent.copy(replies = children, replyCount = parent.replyCount - 1) + }, + ) + } + } } internal interface CommentListMutableState : CommentListState, CommentListStateUpdates @@ -98,4 +111,11 @@ internal interface CommentListStateUpdates { * @param comment The updated comment data. */ fun onCommentUpdated(comment: CommentData) + + /** + * Handles the removal of a comment from the list. + * + * @param commentId The ID of the removed comment. + */ + fun onCommentRemoved(commentId: String) } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandler.kt index fae8fbc6..384bcabd 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandler.kt @@ -18,6 +18,7 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.CommentListStateUpdates import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.network.models.CommentDeletedEvent import io.getstream.feeds.android.network.models.CommentUpdatedEvent import io.getstream.feeds.android.network.models.WSEvent @@ -26,6 +27,7 @@ internal class CommentListEventHandler(private val state: CommentListStateUpdate override fun onEvent(event: WSEvent) { when (event) { + is CommentDeletedEvent -> state.onCommentRemoved(event.comment.id) is CommentUpdatedEvent -> state.onCommentUpdated(event.comment.toModel()) } } 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 5b743bb1..47a5dc59 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 @@ -279,7 +279,6 @@ internal fun List.mergeSorted( * element is found, the list remains unchanged. If a [comparator] is provided, the updated list * will be sorted according to it. * - * @param T The type of elements in the list. * @param matcher A function that determines whether an element should be updated. * @param childrenSelector A function that extracts the children of an element. This is used to * recursively update nested elements. @@ -302,6 +301,28 @@ internal fun List.treeUpdateFirst( ?: this } +/** + * Removes the first element matching the [matcher] from a tree-like list. + * + * This function traverses the tree, finds the first element for which [matcher] returns true and + * removes it from the list. + * + * @param matcher A function that determines whether an element should be removed. + * @param childrenSelector A function that extracts the children of an element. This is used to + * recursively remove nested elements. + * @param updateChildren A function that takes the existing element and the updated children, and + * returns the updated element with the new children. + * @return A list with the first matching element removed. If no matching element was found, the + * original list is returned. + */ +internal fun List.treeRemoveFirst( + matcher: (T) -> Boolean, + childrenSelector: (T) -> List, + updateChildren: (T, List) -> T, +): List { + return internalTreeUpdate(matcher, childrenSelector, null, updateChildren, null) ?: this +} + /** * Internal helper to update an element in a tree-like list. * @@ -310,7 +331,7 @@ internal fun List.treeUpdateFirst( private fun List.internalTreeUpdate( matcher: (T) -> Boolean, childrenSelector: (T) -> List, - updateElement: (T) -> T, + updateElement: ((T) -> T)?, updateChildren: (T, List) -> T, comparator: Comparator?, ): List? { @@ -322,8 +343,12 @@ private fun List.internalTreeUpdate( val index = indexOfFirst(matcher) if (index >= 0) { return toMutableList().apply { - this[index] = updateElement(this[index]) - comparator?.let(::sortWith) + if (updateElement == null) { + removeAt(index) + } else { + this[index] = updateElement(this[index]) + comparator?.let(::sortWith) + } } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/CommentListStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/CommentListStateImplTest.kt index ec02003f..654126f3 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/CommentListStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/CommentListStateImplTest.kt @@ -60,4 +60,40 @@ internal class CommentListStateImplTest { assertEquals(expected, state.comments.value) } + + @Test + fun `on commentRemoved from top level, then remove specific comment`() { + val comment1 = commentData("1", text = "First", createdAt = Date(1)) + val comment2 = commentData("2", text = "Second", createdAt = Date(2)) + val comment3 = commentData("3", text = "Third", createdAt = Date(3)) + val result = + PaginationResult( + models = listOf(comment1, comment2, comment3), + pagination = PaginationData("next", "previous"), + ) + + state.onQueryMoreComments(result) + state.onCommentRemoved("2") + + val expected = listOf(comment1, comment3) + assertEquals(expected, state.comments.value) + } + + @Test + fun `on commentRemoved from nested reply, then remove reply and update parent reply count`() { + val reply1 = commentData("reply1", text = "Reply 1", createdAt = Date(2)) + val reply2 = commentData("reply2", text = "Reply 2", createdAt = Date(3)) + val reply3 = commentData("reply3", text = "Reply 3", createdAt = Date(4)) + val parentComment = + commentData("parent", text = "Parent", createdAt = Date(1)) + .copy(replies = listOf(reply1, reply2, reply3), replyCount = 3) + + val result = PaginationResult(models = listOf(parentComment), pagination = PaginationData()) + + state.onQueryMoreComments(result) + state.onCommentRemoved("reply2") + + val expectedParent = parentComment.copy(replies = listOf(reply1, reply3), replyCount = 2) + assertEquals(listOf(expectedParent), state.comments.value) + } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandlerTest.kt index f8e1e118..1a20b92a 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/CommentListEventHandlerTest.kt @@ -18,6 +18,7 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.CommentListStateUpdates import io.getstream.feeds.android.client.internal.test.TestData.commentResponse +import io.getstream.feeds.android.network.models.CommentDeletedEvent import io.getstream.feeds.android.network.models.CommentUpdatedEvent import io.getstream.feeds.android.network.models.WSEvent import io.mockk.called @@ -47,6 +48,22 @@ internal class CommentListEventHandlerTest { verify { state.onCommentUpdated(comment.toModel()) } } + @Test + fun `on CommentDeletedEvent, then call onCommentRemoved`() { + val comment = commentResponse() + val event = + CommentDeletedEvent( + createdAt = Date(), + fid = "user:feed-1", + comment = comment, + type = "feeds.comment.deleted", + ) + + handler.onEvent(event) + + verify { state.onCommentRemoved(event.comment.id) } + } + @Test fun `on unknown event, then do nothing`() { val unknownEvent = diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/utils/ListTreeUpdateTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/utils/ListTreeUpdateTest.kt index a5b0d923..3d6a50d0 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/utils/ListTreeUpdateTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/utils/ListTreeUpdateTest.kt @@ -208,6 +208,84 @@ internal class ListTreeUpdateTest { assertEquals(expected, updated) } + @Test + fun `on treeRemoveFirst when element is not found, then return original list`() { + val initial = listOf(TestNode("1", 20), TestNode("2", 25), TestNode("3", 30)) + + val result = + initial.treeRemoveFirst( + matcher = { it.id == "4" }, + childrenSelector = TestNode::nodes, + updateChildren = { node, children -> node.copy(nodes = children) }, + ) + + assertEquals(initial, result) + } + + @Test + fun `on treeRemoveFirst when element is found at top level, then remove element`() { + val initial = listOf(TestNode("1", 20), TestNode("2", 25), TestNode("3", 30)) + val expected = listOf(TestNode("1", 20), TestNode("3", 30)) + + val result = + initial.treeRemoveFirst( + matcher = { it.id == "2" }, + childrenSelector = TestNode::nodes, + updateChildren = { node, children -> node.copy(nodes = children) }, + ) + + assertEquals(expected, result) + } + + @Test + fun `on treeRemoveFirst when element is nested, then remove from children`() { + val initial = + listOf( + TestNode("1", 20), + TestNode( + "2", + 25, + listOf(TestNode("5", 50, listOf(TestNode("3", 30))), TestNode("4", 40)), + ), + TestNode("6", 60), + ) + val expected = + listOf( + TestNode("1", 20), + TestNode("2", 25, listOf(TestNode("5", 50), TestNode("4", 40))), + TestNode("6", 60), + ) + + val result = + initial.treeRemoveFirst( + matcher = { it.id == "3" }, + childrenSelector = TestNode::nodes, + updateChildren = { node, children -> node.copy(nodes = children) }, + ) + + assertEquals(expected, result) + } + + @Test + fun `on treeRemoveFirst when multiple elements match, then remove only first match`() { + val initial = + listOf( + TestNode("A", 1, listOf(TestNode("target", 100))), + TestNode("B", 2, listOf(TestNode("target", 200))), + ) + val expected = + listOf(TestNode("A", 1, listOf()), TestNode("B", 2, listOf(TestNode("target", 200)))) + + val result = + initial.treeRemoveFirst( + matcher = { it.id == "target" }, + childrenSelector = TestNode::nodes, + updateChildren = { node, children -> node.copy(nodes = children) }, + ) + + assertEquals(expected, result) + } + private data class TestNode( val id: String, val sortField: Int, From b54e65074d3b3081784be41d4ce34d41e3b503d2 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:26:20 +0200 Subject: [PATCH 02/18] Handle BookmarkDeletedEvent for BookmarkList --- .../internal/state/BookmarkListStateImpl.kt | 7 +++ .../event/handler/BookmarkListEventHandler.kt | 3 ++ .../state/BookmarkListStateImplTest.kt | 44 +++++++++++++++++++ .../handler/BookmarkListEventHandlerTest.kt | 16 +++++++ 4 files changed, 70 insertions(+) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/BookmarkListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/BookmarkListStateImpl.kt index 93b03538..6fad96f4 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/BookmarkListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/BookmarkListStateImpl.kt @@ -28,6 +28,7 @@ import io.getstream.feeds.android.client.internal.utils.mergeSorted import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * An observable state object that manages the current state of a bookmark list. @@ -104,6 +105,10 @@ internal class BookmarkListStateImpl(override val query: BookmarksQuery) : } } } + + override fun onBookmarkRemoved(bookmark: BookmarkData) { + _bookmarks.update { current -> current.filter { it.id != bookmark.id } } + } } /** @@ -127,4 +132,6 @@ internal interface BookmarkListStateUpdates { fun onBookmarkFolderUpdated(folder: BookmarkFolderData) fun onBookmarkUpdated(bookmark: BookmarkData) + + fun onBookmarkRemoved(bookmark: BookmarkData) } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandler.kt index 3e3c29b2..d00507f0 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandler.kt @@ -18,6 +18,7 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.BookmarkListStateUpdates import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.network.models.BookmarkDeletedEvent import io.getstream.feeds.android.network.models.BookmarkFolderDeletedEvent import io.getstream.feeds.android.network.models.BookmarkFolderUpdatedEvent import io.getstream.feeds.android.network.models.BookmarkUpdatedEvent @@ -31,7 +32,9 @@ internal class BookmarkListEventHandler(private val state: BookmarkListStateUpda is BookmarkFolderDeletedEvent -> state.onBookmarkFolderRemoved(event.bookmarkFolder.id) is BookmarkFolderUpdatedEvent -> state.onBookmarkFolderUpdated(event.bookmarkFolder.toModel()) + is BookmarkUpdatedEvent -> state.onBookmarkUpdated(event.bookmark.toModel()) + is BookmarkDeletedEvent -> state.onBookmarkRemoved(event.bookmark.toModel()) } } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/BookmarkListStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/BookmarkListStateImplTest.kt index 2e1c9a10..4a14647c 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/BookmarkListStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/BookmarkListStateImplTest.kt @@ -146,4 +146,48 @@ internal class BookmarkListStateImplTest { assertNull(bookmarkWithoutFolder?.folder) assertEquals(initialBookmarks[1], updatedBookmarks.find { it.id == initialBookmarks[1].id }) } + + @Test + fun `on bookmarkRemoved, then remove specific bookmark`() = runTest { + val initialBookmarks = + listOf( + bookmarkData("activity-1", "user-1"), + bookmarkData("activity-2", "user-2"), + bookmarkData("activity-3", "user-3"), + ) + val paginationResult = + PaginationResult(models = initialBookmarks, pagination = PaginationData()) + val queryConfig = + QueryConfiguration( + filter = null, + sort = BookmarksSort.Default, + ) + bookmarkListState.onQueryMoreBookmarks(paginationResult, queryConfig) + + val bookmarkToRemove = initialBookmarks[1] + bookmarkListState.onBookmarkRemoved(bookmarkToRemove) + + val remainingBookmarks = bookmarkListState.bookmarks.value + assertEquals(2, remainingBookmarks.size) + assertEquals(listOf(initialBookmarks[0], initialBookmarks[2]), remainingBookmarks) + } + + @Test + fun `on bookmarkRemoved with nonexistent bookmark, then keep all bookmarks`() = runTest { + val initialBookmarks = + listOf(bookmarkData("activity-1", "user-1"), bookmarkData("activity-2", "user-2")) + val paginationResult = + PaginationResult(models = initialBookmarks, pagination = PaginationData()) + val queryConfig = + QueryConfiguration( + filter = null, + sort = BookmarksSort.Default, + ) + bookmarkListState.onQueryMoreBookmarks(paginationResult, queryConfig) + + val nonexistentBookmark = bookmarkData("activity-999", "user-999") + bookmarkListState.onBookmarkRemoved(nonexistentBookmark) + + assertEquals(initialBookmarks, bookmarkListState.bookmarks.value) + } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandlerTest.kt index d317189b..324254da 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/BookmarkListEventHandlerTest.kt @@ -19,6 +19,7 @@ import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.BookmarkListStateUpdates import io.getstream.feeds.android.client.internal.test.TestData.bookmarkFolderResponse import io.getstream.feeds.android.client.internal.test.TestData.bookmarkResponse +import io.getstream.feeds.android.network.models.BookmarkDeletedEvent import io.getstream.feeds.android.network.models.BookmarkFolderDeletedEvent import io.getstream.feeds.android.network.models.BookmarkFolderUpdatedEvent import io.getstream.feeds.android.network.models.BookmarkUpdatedEvent @@ -79,6 +80,21 @@ internal class BookmarkListEventHandlerTest { verify { state.onBookmarkUpdated(bookmark.toModel()) } } + @Test + fun `on BookmarkDeletedEvent, then call onBookmarkRemoved`() { + val bookmark = bookmarkResponse() + val event = + BookmarkDeletedEvent( + createdAt = Date(), + bookmark = bookmark, + type = "feeds.bookmark.updated", + ) + + handler.onEvent(event) + + verify { state.onBookmarkRemoved(bookmark.toModel()) } + } + @Test fun `on unknown event, then do nothing`() { val unknownEvent = From cec9fe3afd08fcd48ade88820161c03433a6fa22 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:34:08 +0200 Subject: [PATCH 03/18] Filter events handled by MemberListEventHandler by fid --- .../client/internal/state/MemberListImpl.kt | 2 +- .../state/event/handler/MemberListEventHandler.kt | 15 +++++++++++---- .../event/handler/MemberListEventHandlerTest.kt | 6 ++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/MemberListImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/MemberListImpl.kt index 36d72499..cc1bc0f2 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/MemberListImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/MemberListImpl.kt @@ -45,7 +45,7 @@ internal class MemberListImpl( private val _state: MemberListStateImpl = MemberListStateImpl(query) - private val eventHandler = MemberListEventHandler(_state) + private val eventHandler = MemberListEventHandler(query.fid, _state) init { subscriptionManager.subscribe(eventHandler) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandler.kt index f79a1d3e..a337623f 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandler.kt @@ -15,6 +15,7 @@ */ package io.getstream.feeds.android.client.internal.state.event.handler +import io.getstream.feeds.android.client.api.model.FeedId import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.MemberListStateUpdates import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener @@ -22,16 +23,22 @@ import io.getstream.feeds.android.network.models.FeedMemberRemovedEvent import io.getstream.feeds.android.network.models.FeedMemberUpdatedEvent import io.getstream.feeds.android.network.models.WSEvent -internal class MemberListEventHandler(private val state: MemberListStateUpdates) : - FeedsEventListener { +internal class MemberListEventHandler( + private val fid: FeedId, + private val state: MemberListStateUpdates +) : FeedsEventListener { override fun onEvent(event: WSEvent) { when (event) { is FeedMemberRemovedEvent -> { - state.onMemberRemoved(event.memberId) + if (event.fid == fid.rawValue) { + state.onMemberRemoved(event.memberId) + } } is FeedMemberUpdatedEvent -> { - state.onMemberUpdated(event.member.toModel()) + if (event.fid == fid.rawValue) { + state.onMemberUpdated(event.member.toModel()) + } } } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandlerTest.kt index 328a3295..d8097fe5 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandlerTest.kt @@ -15,6 +15,7 @@ */ package io.getstream.feeds.android.client.internal.state.event.handler +import io.getstream.feeds.android.client.api.model.FeedId import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.MemberListStateUpdates import io.getstream.feeds.android.client.internal.test.TestData.feedMemberResponse @@ -24,13 +25,14 @@ import io.getstream.feeds.android.network.models.WSEvent import io.mockk.called import io.mockk.mockk import io.mockk.verify -import java.util.Date import org.junit.Test +import java.util.Date internal class MemberListEventHandlerTest { + private val fid = FeedId("user:feed-1") private val state: MemberListStateUpdates = mockk(relaxed = true) - private val handler = MemberListEventHandler(state) + private val handler = MemberListEventHandler(fid, state) @Test fun `on FeedMemberRemovedEvent, then call onMemberRemoved`() { From 6331d8d4b954d5284c1fb87a0a9d4f6a32c68b02 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:38:55 +0200 Subject: [PATCH 04/18] Handle FeedMemberAddedEvent for MemberList --- .../internal/state/MemberListStateImpl.kt | 9 ++++ .../event/handler/MemberListEventHandler.kt | 9 +++- .../internal/state/MemberListStateImplTest.kt | 42 +++++++++++++++++++ .../handler/MemberListEventHandlerTest.kt | 2 +- 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/MemberListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/MemberListStateImpl.kt index 0478d63d..1fa23301 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/MemberListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/MemberListStateImpl.kt @@ -26,9 +26,11 @@ import io.getstream.feeds.android.client.api.state.query.MembersFilterField import io.getstream.feeds.android.client.api.state.query.MembersQuery import io.getstream.feeds.android.client.api.state.query.MembersSort import io.getstream.feeds.android.client.internal.utils.mergeSorted +import io.getstream.feeds.android.client.internal.utils.upsert import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * An observable state object that manages the current state of a member list. @@ -66,6 +68,10 @@ internal class MemberListStateImpl(override val query: MembersQuery) : MemberLis _members.value.mergeSorted(result.models, FeedMemberData::id, membersSorting) } + override fun onMemberAdded(member: FeedMemberData) { + _members.update { current -> current.upsert(member, FeedMemberData::id) } + } + override fun onMemberRemoved(memberId: String) { _members.value = _members.value.filter { it.id != memberId } } @@ -124,6 +130,9 @@ internal interface MemberListStateUpdates { queryConfig: QueryConfiguration, ) + /** Handles the addition of a new member. */ + fun onMemberAdded(member: FeedMemberData) + /** Handles the removal of a member by their ID. */ fun onMemberRemoved(memberId: String) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandler.kt index a337623f..b657f487 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandler.kt @@ -19,16 +19,23 @@ import io.getstream.feeds.android.client.api.model.FeedId import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.MemberListStateUpdates import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.network.models.FeedMemberAddedEvent import io.getstream.feeds.android.network.models.FeedMemberRemovedEvent import io.getstream.feeds.android.network.models.FeedMemberUpdatedEvent import io.getstream.feeds.android.network.models.WSEvent internal class MemberListEventHandler( private val fid: FeedId, - private val state: MemberListStateUpdates + private val state: MemberListStateUpdates, ) : FeedsEventListener { override fun onEvent(event: WSEvent) { when (event) { + is FeedMemberAddedEvent -> { + if (event.fid == fid.rawValue) { + state.onMemberAdded(event.member.toModel()) + } + } + is FeedMemberRemovedEvent -> { if (event.fid == fid.rawValue) { state.onMemberRemoved(event.memberId) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/MemberListStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/MemberListStateImplTest.kt index 3947f165..2c431b28 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/MemberListStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/MemberListStateImplTest.kt @@ -133,6 +133,48 @@ internal class MemberListStateImplTest { assertEquals(listOf(updatedMember), finalMembers) } + @Test + fun `on memberAdded, then add member`() = runTest { + val initialMembers = listOf(feedMemberData(), feedMemberData("user-2")) + val paginationResult = + PaginationResult(models = initialMembers, pagination = PaginationData()) + val queryConfig = + QueryConfiguration( + filter = null, + sort = MembersSort.Default, + ) + memberListState.onQueryMoreMembers(paginationResult, queryConfig) + + val newMember = feedMemberData("user-3") + memberListState.onMemberAdded(newMember) + + assertEquals(initialMembers + newMember, memberListState.members.value) + } + + @Test + fun `on memberAdded with existing id, then update member`() = runTest { + val initialMembers = listOf(feedMemberData(), feedMemberData("user-2")) + val paginationResult = + PaginationResult( + models = initialMembers, + pagination = PaginationData(next = "next-cursor", previous = null), + ) + val queryConfig = + QueryConfiguration( + filter = null, + sort = MembersSort.Default, + ) + memberListState.onQueryMoreMembers(paginationResult, queryConfig) + + val updatedMember = feedMemberData("user-1", role = "admin") + memberListState.onMemberAdded(updatedMember) + + val updatedMembers = memberListState.members.value + assertEquals(2, updatedMembers.size) + assertEquals(updatedMember, updatedMembers.find { it.id == updatedMember.id }) + assertEquals(initialMembers[1], updatedMembers.find { it.id == initialMembers[1].id }) + } + @Test fun `on clear, then remove all members`() = runTest { val initialMembers = listOf(feedMemberData(), feedMemberData("user-2")) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandlerTest.kt index d8097fe5..ceae597c 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/MemberListEventHandlerTest.kt @@ -25,8 +25,8 @@ import io.getstream.feeds.android.network.models.WSEvent import io.mockk.called import io.mockk.mockk import io.mockk.verify -import org.junit.Test import java.util.Date +import org.junit.Test internal class MemberListEventHandlerTest { private val fid = FeedId("user:feed-1") From 36fee26adc1019953505c55cf3ed2174f92b85f4 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:08:55 +0200 Subject: [PATCH 05/18] Handle ActivityReactionAddedEvent for ActivityReactionList --- .../state/ActivityReactionListStateImpl.kt | 15 +++++++ .../ActivityReactionListEventHandler.kt | 7 ++++ .../ActivityReactionListEventHandlerTest.kt | 41 ++++++++++++++++++- .../android/client/internal/test/TestData.kt | 4 +- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityReactionListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityReactionListStateImpl.kt index e4dbf728..c4453b74 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityReactionListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityReactionListStateImpl.kt @@ -24,9 +24,11 @@ import io.getstream.feeds.android.client.api.state.query.ActivityReactionsFilter import io.getstream.feeds.android.client.api.state.query.ActivityReactionsQuery import io.getstream.feeds.android.client.api.state.query.ActivityReactionsSort import io.getstream.feeds.android.client.internal.utils.mergeSorted +import io.getstream.feeds.android.client.internal.utils.upsert import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * An observable state object that manages the current state of an activity reaction list. @@ -71,6 +73,12 @@ internal class ActivityReactionListStateImpl(override val query: ActivityReactio _reactions.value.mergeSorted(result.models, FeedsReactionData::id, reactionsSorting) } + override fun onReactionAdded(reaction: FeedsReactionData) { + _reactions.update { current -> + current.upsert(reaction, FeedsReactionData::id) + } + } + override fun onReactionRemoved(reaction: FeedsReactionData) { _reactions.value = _reactions.value.filter { it.id != reaction.id } } @@ -101,6 +109,13 @@ internal interface ActivityReactionListStateUpdates { queryConfig: QueryConfiguration, ) + /** + * Handles the addition of a new reaction to the activity. + * + * @param reaction The reaction that was added. + */ + fun onReactionAdded(reaction: FeedsReactionData) + /** * Handles the addition of a new reaction to the activity. * diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityReactionListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityReactionListEventHandler.kt index d2bd766b..6f0998e9 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityReactionListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityReactionListEventHandler.kt @@ -18,6 +18,7 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.ActivityReactionListStateUpdates import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent import io.getstream.feeds.android.network.models.WSEvent @@ -28,6 +29,12 @@ internal class ActivityReactionListEventHandler( override fun onEvent(event: WSEvent) { when (event) { + is ActivityReactionAddedEvent -> { + if (event.activity.id == activityId) { + state.onReactionAdded(event.reaction.toModel()) + } + } + is ActivityReactionDeletedEvent -> { if (event.activity.id == activityId) { state.onReactionRemoved(event.reaction.toModel()) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityReactionListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityReactionListEventHandlerTest.kt index 2c4c688d..6b50889a 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityReactionListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityReactionListEventHandlerTest.kt @@ -19,6 +19,7 @@ import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.ActivityReactionListStateUpdates import io.getstream.feeds.android.client.internal.test.TestData.activityResponse import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionResponse +import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent import io.getstream.feeds.android.network.models.WSEvent import io.mockk.called @@ -33,9 +34,45 @@ internal class ActivityReactionListEventHandlerTest { private val handler = ActivityReactionListEventHandler(activityId, state) + @Test + fun `on ActivityReactionAddedEvent for matching activity, then call onReactionAdded`() { + val activity = activityResponse(activityId) + val reaction = feedsReactionResponse() + val event = + ActivityReactionAddedEvent( + createdAt = Date(), + fid = "user:feed-1", + activity = activity, + reaction = reaction, + type = "feeds.activity.reaction.added", + ) + + handler.onEvent(event) + + verify { state.onReactionAdded(reaction.toModel()) } + } + + @Test + fun `on ActivityReactionAddedEvent for different activity, then do not call onReactionAdded`() { + val activity = activityResponse("different-activity") + val reaction = feedsReactionResponse() + val event = + ActivityReactionAddedEvent( + createdAt = Date(), + fid = "user:feed-1", + activity = activity, + reaction = reaction, + type = "feeds.activity.reaction.added", + ) + + handler.onEvent(event) + + verify(exactly = 0) { state.onReactionAdded(any()) } + } + @Test fun `on ActivityReactionDeletedEvent for matching activity, then call onReactionRemoved`() { - val activity = activityResponse().copy(id = activityId) + val activity = activityResponse(activityId) val reaction = feedsReactionResponse() val event = ActivityReactionDeletedEvent( @@ -53,7 +90,7 @@ internal class ActivityReactionListEventHandlerTest { @Test fun `on ActivityReactionDeletedEvent for different activity, then do not call onReactionRemoved`() { - val activity = activityResponse().copy(id = "different-activity") + val activity = activityResponse("different-activity") val reaction = feedsReactionResponse() val event = ActivityReactionDeletedEvent( 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 d61dd674..ef583bd0 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 @@ -275,12 +275,12 @@ internal object TestData { updatedAt = Date(1000), ) - fun activityResponse(): ActivityResponse = + fun activityResponse(id: String = ""): ActivityResponse = ActivityResponse( bookmarkCount = 0, commentCount = 0, createdAt = Date(1000), - id = "", + id = id, popularity = 0, reactionCount = 0, score = 0f, From 372602d9603b3a0e01d9c1595cd5d5499ced8122 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 09:36:55 +0200 Subject: [PATCH 06/18] Handle ActivityRemovedFromFeedEvent for Feed --- .../state/event/handler/FeedEventHandler.kt | 7 +++++++ .../state/event/handler/FeedEventHandlerTest.kt | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandler.kt index 5aa9dad7..ef419dcf 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedEventHandler.kt @@ -24,6 +24,7 @@ import io.getstream.feeds.android.network.models.ActivityDeletedEvent import io.getstream.feeds.android.network.models.ActivityPinnedEvent import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent +import io.getstream.feeds.android.network.models.ActivityRemovedFromFeedEvent import io.getstream.feeds.android.network.models.ActivityUnpinnedEvent import io.getstream.feeds.android.network.models.ActivityUpdatedEvent import io.getstream.feeds.android.network.models.BookmarkAddedEvent @@ -74,6 +75,12 @@ internal class FeedEventHandler(private val fid: FeedId, private val state: Feed } } + is ActivityRemovedFromFeedEvent -> { + if (event.fid == fid.rawValue) { + state.onActivityRemoved(event.activity.id) + } + } + is ActivityReactionAddedEvent -> { if (event.fid == fid.rawValue) { state.onReactionAdded(event.reaction.toModel()) 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 2fc84f2e..7379d20f 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 @@ -32,6 +32,7 @@ import io.getstream.feeds.android.network.models.ActivityDeletedEvent import io.getstream.feeds.android.network.models.ActivityPinnedEvent import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent +import io.getstream.feeds.android.network.models.ActivityRemovedFromFeedEvent import io.getstream.feeds.android.network.models.ActivityUnpinnedEvent import io.getstream.feeds.android.network.models.ActivityUpdatedEvent import io.getstream.feeds.android.network.models.BookmarkAddedEvent @@ -95,6 +96,22 @@ internal class FeedEventHandlerTest { verify(exactly = 0) { state.onActivityAdded(any()) } } + @Test + fun `on ActivityRemovedFromFeedEvent for matching feed, then call onActivityRemoved`() { + val activity = activityResponse() + val event = + ActivityRemovedFromFeedEvent( + createdAt = Date(), + fid = fid.rawValue, + activity = activity, + type = "feeds.activity.removed_from_feed", + ) + + handler.onEvent(event) + + verify { state.onActivityRemoved(activity.id) } + } + @Test fun `on ActivityDeletedEvent for matching feed, then call onActivityRemoved`() { val activity = activityResponse() From 3ce4737d44f23ff13ff36c16daa7424fc2452a65 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 09:43:16 +0200 Subject: [PATCH 07/18] Update FeedsReactionData id to include comment id and type --- .../feeds/android/client/api/model/FeedsReactionData.kt | 6 ++++-- .../feeds/android/client/internal/test/TestData.kt | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/FeedsReactionData.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/FeedsReactionData.kt index 6ede74e6..d069b0eb 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/FeedsReactionData.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/FeedsReactionData.kt @@ -30,6 +30,7 @@ import java.util.Date */ public data class FeedsReactionData( val activityId: String, + val commentId: String?, val createdAt: Date, val custom: Map?, val type: String, @@ -37,15 +38,16 @@ public data class FeedsReactionData( val user: UserData, ) { - /** Unique identifier for the reaction, generated from the activity ID and user ID. */ + /** Unique identifier for the reaction. */ public val id: String - get() = "${activityId}${user.id}" + get() = "${activityId}${commentId}${user.id}${type}" } /** Converts a [FeedsReactionResponse] to a [FeedsReactionData] model. */ internal fun FeedsReactionResponse.toModel(): FeedsReactionData = FeedsReactionData( activityId = activityId, + commentId = commentId, createdAt = createdAt, custom = custom, type = type, 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 ef583bd0..624c0ac2 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 @@ -149,6 +149,7 @@ internal object TestData { ): FeedsReactionData = FeedsReactionData( activityId = activityId, + commentId = null, createdAt = createdAt, custom = null, type = type, From 678a438ec2b5c7d31c93c54c129dafae7b84b422 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 09:44:23 +0200 Subject: [PATCH 08/18] Update addReaction logic to increment the count when the reaction was inserted --- .../android/client/api/model/ThreadedCommentData.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt index 6b831610..8314587b 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt @@ -177,16 +177,16 @@ internal fun ThreadedCommentData.addReaction( } else { ownReactions } - val inserted = updatedOwnReactions.size > ownReactions.size val updatedLatestReactions = latestReactions.upsert(reaction, FeedsReactionData::id) + val inserted = + updatedOwnReactions.size > ownReactions.size || + updatedLatestReactions.size > latestReactions.size val reactionGroup = this.reactionGroups[reaction.type] ?: ReactionGroupData(count = 1, reaction.createdAt, reaction.createdAt) - // Increment the count if: - // 1. The reaction is from another user (not own reaction) - // 2. The reaction is from the current user, but it's a new reaction (not an update of existing) + // Increment the count if the reaction was inserted (not an update of existing) val updatedReactionGroup = - if (!ownReaction || inserted) { + if (inserted) { reactionGroup.increment(reaction.createdAt) } else { reactionGroup From 22e56e295fe8c6b3e242020d09077908c88e4186 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:08:51 +0200 Subject: [PATCH 09/18] Unify add reaction logic --- .../android/client/api/model/ActivityData.kt | 38 +++++------ .../client/api/model/ThreadedCommentData.kt | 49 +++++--------- .../client/internal/model/Reactions.kt | 67 +++++++++++++++++++ .../state/ActivityReactionListStateImpl.kt | 4 +- 4 files changed, 100 insertions(+), 58 deletions(-) create mode 100644 stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/Reactions.kt diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ActivityData.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ActivityData.kt index 12e7fb23..6ba57a88 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ActivityData.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ActivityData.kt @@ -15,6 +15,7 @@ */ package io.getstream.feeds.android.client.api.model +import io.getstream.feeds.android.client.internal.model.addReaction import io.getstream.feeds.android.client.internal.utils.upsert import io.getstream.feeds.android.network.models.ActivityLocation import io.getstream.feeds.android.network.models.ActivityResponse @@ -276,28 +277,21 @@ internal fun ActivityData.deleteBookmark( internal fun ActivityData.addReaction( reaction: FeedsReactionData, currentUserId: String, -): ActivityData { - val updatedLatestReactions = this.latestReactions.upsert(reaction, FeedsReactionData::id) - val reactionGroup = - this.reactionGroups[reaction.type] - ?: ReactionGroupData(1, reaction.createdAt, reaction.createdAt) - val updatedReactionGroup = reactionGroup.increment(reaction.createdAt) - val updatedReactionGroups = - this.reactionGroups.toMutableMap().apply { this[reaction.type] = updatedReactionGroup } - val updatedReactionCount = updatedReactionGroups.values.sumOf(ReactionGroupData::count) - val updatedOwnReactions = - if (reaction.user.id == currentUserId) { - this.ownReactions.upsert(reaction, FeedsReactionData::id) - } else { - this.ownReactions - } - return this.copy( - latestReactions = updatedLatestReactions, - reactionGroups = updatedReactionGroups, - reactionCount = updatedReactionCount, - ownReactions = updatedOwnReactions, - ) -} +): ActivityData = + addReaction( + ownReactions = ownReactions, + latestReactions = latestReactions, + reactionGroups = reactionGroups, + reaction = reaction, + currentUserId = currentUserId, + ) { latestReactions, reactionGroups, reactionCount, ownReactions -> + copy( + latestReactions = latestReactions, + reactionGroups = reactionGroups, + reactionCount = reactionCount, + ownReactions = ownReactions, + ) + } /** * Removes a reaction from the activity, updating the latest reactions, reaction groups, reaction diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt index 8314587b..880968ec 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt @@ -16,7 +16,7 @@ package io.getstream.feeds.android.client.api.model import io.getstream.feeds.android.client.api.state.query.CommentsSortDataFields -import io.getstream.feeds.android.client.internal.utils.upsert +import io.getstream.feeds.android.client.internal.model.addReaction import io.getstream.feeds.android.client.internal.utils.upsertSorted import io.getstream.feeds.android.network.models.Attachment import io.getstream.feeds.android.network.models.RepliesMeta @@ -169,38 +169,21 @@ public data class ThreadedCommentData( internal fun ThreadedCommentData.addReaction( reaction: FeedsReactionData, currentUserId: String, -): ThreadedCommentData { - val ownReaction = reaction.user.id == currentUserId - val updatedOwnReactions = - if (ownReaction) { - ownReactions.upsert(reaction, FeedsReactionData::id) - } else { - ownReactions - } - val updatedLatestReactions = latestReactions.upsert(reaction, FeedsReactionData::id) - val inserted = - updatedOwnReactions.size > ownReactions.size || - updatedLatestReactions.size > latestReactions.size - val reactionGroup = - this.reactionGroups[reaction.type] - ?: ReactionGroupData(count = 1, reaction.createdAt, reaction.createdAt) - // Increment the count if the reaction was inserted (not an update of existing) - val updatedReactionGroup = - if (inserted) { - reactionGroup.increment(reaction.createdAt) - } else { - reactionGroup - } - val updatedReactionGroups = - this.reactionGroups.toMutableMap().apply { this[reaction.type] = updatedReactionGroup } - val updatedReactionCount = updatedReactionGroups.values.sumOf(ReactionGroupData::count) - return this.copy( - latestReactions = updatedLatestReactions, - reactionGroups = updatedReactionGroups, - reactionCount = updatedReactionCount, - ownReactions = updatedOwnReactions, - ) -} +): ThreadedCommentData = + addReaction( + ownReactions = ownReactions, + latestReactions = latestReactions, + reactionGroups = reactionGroups, + reaction = reaction, + currentUserId = currentUserId, + ) { latestReactions, reactionGroups, reactionCount, ownReactions -> + copy( + latestReactions = latestReactions, + reactionGroups = reactionGroups, + reactionCount = reactionCount, + ownReactions = ownReactions, + ) + } /** * Removes a reaction from the comment, updating the latest reactions, reaction groups, reaction diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/Reactions.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/Reactions.kt new file mode 100644 index 00000000..1ce8bc60 --- /dev/null +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/Reactions.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.feeds.android.client.internal.model + +import io.getstream.feeds.android.client.api.model.FeedsReactionData +import io.getstream.feeds.android.client.api.model.ReactionGroupData +import io.getstream.feeds.android.client.api.model.increment +import io.getstream.feeds.android.client.internal.utils.upsert + +internal inline fun addReaction( + ownReactions: List, + latestReactions: List, + reactionGroups: Map, + reaction: FeedsReactionData, + currentUserId: String, + update: + ( + latestReactions: List, + reactionGroups: Map, + reactionCount: Int, + ownReactions: List, + ) -> T, +): T { + val ownReaction = reaction.user.id == currentUserId + val updatedOwnReactions = + if (ownReaction) { + ownReactions.upsert(reaction, FeedsReactionData::id) + } else { + ownReactions + } + val updatedLatestReactions = latestReactions.upsert(reaction, FeedsReactionData::id) + val inserted = + updatedOwnReactions.size > ownReactions.size || + updatedLatestReactions.size > latestReactions.size + val reactionGroup = + reactionGroups[reaction.type] + ?: ReactionGroupData(count = 1, reaction.createdAt, reaction.createdAt) + // Increment the count if the reaction was inserted (not an update of existing) + val updatedReactionGroup = + if (inserted) { + reactionGroup.increment(reaction.createdAt) + } else { + reactionGroup + } + val updatedReactionGroups = + reactionGroups.toMutableMap().apply { this[reaction.type] = updatedReactionGroup } + val updatedReactionCount = updatedReactionGroups.values.sumOf(ReactionGroupData::count) + return update( + updatedLatestReactions, + updatedReactionGroups, + updatedReactionCount, + updatedOwnReactions, + ) +} diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityReactionListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityReactionListStateImpl.kt index c4453b74..67419c04 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityReactionListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityReactionListStateImpl.kt @@ -74,9 +74,7 @@ internal class ActivityReactionListStateImpl(override val query: ActivityReactio } override fun onReactionAdded(reaction: FeedsReactionData) { - _reactions.update { current -> - current.upsert(reaction, FeedsReactionData::id) - } + _reactions.update { current -> current.upsert(reaction, FeedsReactionData::id) } } override fun onReactionRemoved(reaction: FeedsReactionData) { From a9160ef9bacdfb2d023fffccf1ac906f674626ff Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:12:22 +0200 Subject: [PATCH 10/18] Update removeReaction logic to decrement the count when the reaction was removed --- .../feeds/android/client/api/model/ThreadedCommentData.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt index 880968ec..10e77329 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt @@ -204,8 +204,8 @@ internal fun ThreadedCommentData.removeReaction( } else { this.ownReactions } - val removed = updatedOwnReactions.size < this.ownReactions.size val updatedLatestReactions = this.latestReactions.filter { it.id != reaction.id } + val removed = updatedOwnReactions.size < ownReactions.size || updatedLatestReactions.size < latestReactions.size val reactionGroup = this.reactionGroups[reaction.type] if (reactionGroup == null) { // If there is no reaction group for this type, just update latest and own reactions. @@ -215,11 +215,9 @@ internal fun ThreadedCommentData.removeReaction( ownReactions = updatedOwnReactions, ) } - // Decrement the count if: - // 1. The reaction is from another user (not own reaction) - // 2. The reaction is from the current user, and it was already present + // Decrement the count if the reaction was actually removed val updatedReactionGroup = - if (!ownReaction || removed) { + if (removed) { reactionGroup.decrement(reaction.createdAt) } else { reactionGroup From 396c024ce9ff56774c6e90eaede2cdf28d2e551a Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:26:23 +0200 Subject: [PATCH 11/18] Unify remove reaction logic --- .../android/client/api/model/ActivityData.kt | 44 +++++---------- .../client/api/model/ThreadedCommentData.kt | 52 +++++------------- .../client/internal/model/Reactions.kt | 55 +++++++++++++++++++ 3 files changed, 83 insertions(+), 68 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ActivityData.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ActivityData.kt index 6ba57a88..e16827c2 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ActivityData.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ActivityData.kt @@ -16,6 +16,7 @@ package io.getstream.feeds.android.client.api.model import io.getstream.feeds.android.client.internal.model.addReaction +import io.getstream.feeds.android.client.internal.model.removeReaction import io.getstream.feeds.android.client.internal.utils.upsert import io.getstream.feeds.android.network.models.ActivityLocation import io.getstream.feeds.android.network.models.ActivityResponse @@ -304,35 +305,18 @@ internal fun ActivityData.addReaction( internal fun ActivityData.removeReaction( reaction: FeedsReactionData, currentUserId: String, -): ActivityData { - val updatedLatestReactions = this.latestReactions.filter { it.id != reaction.id } - val updatedOwnReactions = - if (reaction.user.id == currentUserId) { - this.ownReactions.filter { it.id != reaction.id } - } else { - this.ownReactions - } - val reactionGroup = this.reactionGroups[reaction.type] - if (reactionGroup == null) { - // If there is no reaction group for this type, just update latest and own reactions. - // Note: This is only a hypothetical case, as we should always have a reaction group. - return this.copy( - latestReactions = updatedLatestReactions, - ownReactions = updatedOwnReactions, +): ActivityData = + removeReaction( + ownReactions = ownReactions, + latestReactions = latestReactions, + reactionGroups = reactionGroups, + reaction = reaction, + currentUserId = currentUserId, + ) { latestReactions, reactionGroups, reactionCount, ownReactions -> + copy( + latestReactions = latestReactions, + reactionGroups = reactionGroups ?: this.reactionGroups, + reactionCount = reactionCount ?: this.reactionCount, + ownReactions = ownReactions, ) } - val updatedReactionGroup = reactionGroup.decrement(reaction.createdAt) - val updatedReactionGroups = - if (updatedReactionGroup.isEmpty) { - this.reactionGroups - reaction.type // Remove empty group - } else { - this.reactionGroups.toMutableMap().apply { this[reaction.type] = updatedReactionGroup } - } - val updatedReactionCount = updatedReactionGroups.values.sumOf(ReactionGroupData::count) - return this.copy( - latestReactions = updatedLatestReactions, - reactionGroups = updatedReactionGroups, - reactionCount = updatedReactionCount, - ownReactions = updatedOwnReactions, - ) -} diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt index 10e77329..dd2af436 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ThreadedCommentData.kt @@ -17,6 +17,7 @@ package io.getstream.feeds.android.client.api.model import io.getstream.feeds.android.client.api.state.query.CommentsSortDataFields import io.getstream.feeds.android.client.internal.model.addReaction +import io.getstream.feeds.android.client.internal.model.removeReaction import io.getstream.feeds.android.client.internal.utils.upsertSorted import io.getstream.feeds.android.network.models.Attachment import io.getstream.feeds.android.network.models.RepliesMeta @@ -196,46 +197,21 @@ internal fun ThreadedCommentData.addReaction( internal fun ThreadedCommentData.removeReaction( reaction: FeedsReactionData, currentUserId: String, -): ThreadedCommentData { - val ownReaction = reaction.user.id == currentUserId - val updatedOwnReactions = - if (ownReaction) { - this.ownReactions.filter { it.id != reaction.id } - } else { - this.ownReactions - } - val updatedLatestReactions = this.latestReactions.filter { it.id != reaction.id } - val removed = updatedOwnReactions.size < ownReactions.size || updatedLatestReactions.size < latestReactions.size - val reactionGroup = this.reactionGroups[reaction.type] - if (reactionGroup == null) { - // If there is no reaction group for this type, just update latest and own reactions. - // Note: This is only a hypothetical case, as we should always have a reaction group. - return this.copy( - latestReactions = updatedLatestReactions, - ownReactions = updatedOwnReactions, +): ThreadedCommentData = + removeReaction( + ownReactions = ownReactions, + latestReactions = latestReactions, + reactionGroups = reactionGroups, + reaction = reaction, + currentUserId = currentUserId, + ) { latestReactions, reactionGroups, reactionCount, ownReactions -> + copy( + latestReactions = latestReactions, + reactionGroups = reactionGroups ?: this.reactionGroups, + reactionCount = reactionCount ?: this.reactionCount, + ownReactions = ownReactions, ) } - // Decrement the count if the reaction was actually removed - val updatedReactionGroup = - if (removed) { - reactionGroup.decrement(reaction.createdAt) - } else { - reactionGroup - } - val updatedReactionGroups = - if (updatedReactionGroup.isEmpty) { - this.reactionGroups - reaction.type // Remove empty group - } else { - this.reactionGroups.toMutableMap().apply { this[reaction.type] = updatedReactionGroup } - } - val updatedReactionCount = updatedReactionGroups.values.sumOf(ReactionGroupData::count) - return this.copy( - latestReactions = updatedLatestReactions, - reactionGroups = updatedReactionGroups, - reactionCount = updatedReactionCount, - ownReactions = updatedOwnReactions, - ) -} /** * Adds a reply to the comment, updating the replies list and reply count. diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/Reactions.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/Reactions.kt index 1ce8bc60..92c407e0 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/Reactions.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/Reactions.kt @@ -17,7 +17,9 @@ package io.getstream.feeds.android.client.internal.model import io.getstream.feeds.android.client.api.model.FeedsReactionData import io.getstream.feeds.android.client.api.model.ReactionGroupData +import io.getstream.feeds.android.client.api.model.decrement import io.getstream.feeds.android.client.api.model.increment +import io.getstream.feeds.android.client.api.model.isEmpty import io.getstream.feeds.android.client.internal.utils.upsert internal inline fun addReaction( @@ -65,3 +67,56 @@ internal inline fun addReaction( updatedOwnReactions, ) } + +internal inline fun removeReaction( + ownReactions: List, + latestReactions: List, + reactionGroups: Map, + reaction: FeedsReactionData, + currentUserId: String, + update: + ( + latestReactions: List, + reactionGroups: Map?, + reactionCount: Int?, + ownReactions: List, + ) -> T, +): T { + val ownReaction = reaction.user.id == currentUserId + val updatedOwnReactions = + if (ownReaction) { + ownReactions.filter { it.id != reaction.id } + } else { + ownReactions + } + val updatedLatestReactions = latestReactions.filter { it.id != reaction.id } + val removed = + updatedOwnReactions.size < ownReactions.size || + updatedLatestReactions.size < latestReactions.size + val reactionGroup = reactionGroups[reaction.type] + if (reactionGroup == null) { + // If there is no reaction group for this type, just update latest and own reactions. + // Note: This is only a hypothetical case, as we should always have a reaction group. + return update(updatedLatestReactions, null, null, updatedOwnReactions) + } + // Decrement the count if the reaction was actually removed + val updatedReactionGroup = + if (removed) { + reactionGroup.decrement(reaction.createdAt) + } else { + reactionGroup + } + val updatedReactionGroups = + if (updatedReactionGroup.isEmpty) { + reactionGroups - reaction.type // Remove empty group + } else { + reactionGroups.toMutableMap().apply { this[reaction.type] = updatedReactionGroup } + } + val updatedReactionCount = updatedReactionGroups.values.sumOf(ReactionGroupData::count) + return update( + updatedLatestReactions, + updatedReactionGroups, + updatedReactionCount, + updatedOwnReactions, + ) +} From 06289ccefc078bfd5560cfc3157507b2a5de65cc Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 12:14:43 +0200 Subject: [PATCH 12/18] Handle ActivityReactionAdded, ActivityReactionDeleted, ActivityUpdatedEvent, BookmarkAddedEvent, BookmarkDeletedEvent for Activity --- .../client/internal/state/ActivityImpl.kt | 3 +- .../internal/state/ActivityStateImpl.kt | 51 ++++++++++ .../event/handler/ActivityEventHandler.kt | 33 +++++++ .../internal/state/ActivityStateImplTest.kt | 97 +++++++++++++++++++ .../event/handler/ActivityEventHandlerTest.kt | 3 +- 5 files changed, 185 insertions(+), 2 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt index ad81e342..fb7e60fa 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt @@ -74,7 +74,8 @@ internal class ActivityImpl( private val _state: ActivityStateImpl = ActivityStateImpl(currentUserId, commentList.state) - private val eventHandler = ActivityEventHandler(fid = fid, state = _state) + private val eventHandler = + ActivityEventHandler(fid = fid, activityId = activityId, state = _state) init { subscriptionManager.subscribe(eventHandler) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityStateImpl.kt index 22d87a63..e108f501 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityStateImpl.kt @@ -16,13 +16,19 @@ package io.getstream.feeds.android.client.internal.state import io.getstream.feeds.android.client.api.model.ActivityData +import io.getstream.feeds.android.client.api.model.BookmarkData +import io.getstream.feeds.android.client.api.model.FeedsReactionData import io.getstream.feeds.android.client.api.model.PollData import io.getstream.feeds.android.client.api.model.PollOptionData import io.getstream.feeds.android.client.api.model.PollVoteData import io.getstream.feeds.android.client.api.model.ThreadedCommentData +import io.getstream.feeds.android.client.api.model.addBookmark import io.getstream.feeds.android.client.api.model.addOption +import io.getstream.feeds.android.client.api.model.addReaction import io.getstream.feeds.android.client.api.model.castVote +import io.getstream.feeds.android.client.api.model.deleteBookmark import io.getstream.feeds.android.client.api.model.removeOption +import io.getstream.feeds.android.client.api.model.removeReaction import io.getstream.feeds.android.client.api.model.removeVote import io.getstream.feeds.android.client.api.model.updateOption import io.getstream.feeds.android.client.api.state.ActivityCommentListState @@ -30,6 +36,7 @@ import io.getstream.feeds.android.client.api.state.ActivityState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * An observable object representing the current state of an activity. @@ -63,6 +70,22 @@ internal class ActivityStateImpl( _poll.value = activity.poll } + override fun onReactionAdded(reaction: FeedsReactionData) { + _activity.update { current -> current?.addReaction(reaction, currentUserId) } + } + + override fun onReactionRemoved(reaction: FeedsReactionData) { + _activity.update { current -> current?.removeReaction(reaction, currentUserId) } + } + + override fun onBookmarkAdded(bookmark: BookmarkData) { + _activity.update { current -> current?.addBookmark(bookmark, currentUserId) } + } + + override fun onBookmarkRemoved(bookmark: BookmarkData) { + _activity.update { current -> current?.deleteBookmark(bookmark, currentUserId) } + } + override fun onPollClosed(poll: PollData) { if (_poll.value?.id != poll.id) return _poll.value = poll @@ -140,6 +163,34 @@ internal interface ActivityStateUpdates { */ fun onActivityUpdated(activity: ActivityData) + /** + * Called when a reaction is added to the activity. + * + * @param reaction The reaction that was added. + */ + fun onReactionAdded(reaction: FeedsReactionData) + + /** + * Called when a reaction is removed from the activity. + * + * @param reaction The reaction that was removed. + */ + fun onReactionRemoved(reaction: FeedsReactionData) + + /** + * Called when a bookmark is added to the activity. + * + * @param bookmark The bookmark that was added. + */ + fun onBookmarkAdded(bookmark: BookmarkData) + + /** + * Called when a bookmark is removed from the activity. + * + * @param bookmark The bookmark that was deleted. + */ + fun onBookmarkRemoved(bookmark: BookmarkData) + /** * Called when the associated poll is closed. * diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandler.kt index a258bacf..38df1cd9 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandler.kt @@ -19,6 +19,11 @@ import io.getstream.feeds.android.client.api.model.FeedId import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.ActivityStateUpdates import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent +import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent +import io.getstream.feeds.android.network.models.ActivityUpdatedEvent +import io.getstream.feeds.android.network.models.BookmarkAddedEvent +import io.getstream.feeds.android.network.models.BookmarkDeletedEvent import io.getstream.feeds.android.network.models.PollClosedFeedEvent import io.getstream.feeds.android.network.models.PollDeletedFeedEvent import io.getstream.feeds.android.network.models.PollUpdatedFeedEvent @@ -37,6 +42,7 @@ import io.getstream.feeds.android.network.models.WSEvent */ internal class ActivityEventHandler( private val fid: FeedId, + private val activityId: String, private val state: ActivityStateUpdates, ) : FeedsEventListener { @@ -47,6 +53,33 @@ internal class ActivityEventHandler( */ override fun onEvent(event: WSEvent) { when (event) { + is ActivityReactionAddedEvent -> { + if (event.fid != fid.rawValue || event.activity.id != activityId) return + state.onReactionAdded(event.reaction.toModel()) + } + + is ActivityReactionDeletedEvent -> { + if (event.fid != fid.rawValue || event.activity.id != activityId) return + state.onReactionRemoved(event.reaction.toModel()) + } + + is ActivityUpdatedEvent -> { + if (event.fid != fid.rawValue || event.activity.id != activityId) return + state.onActivityUpdated(event.activity.toModel()) + } + + is BookmarkAddedEvent -> { + val eventActivity = event.bookmark.activity + if (fid.rawValue !in eventActivity.feeds || eventActivity.id != activityId) return + state.onBookmarkAdded(event.bookmark.toModel()) + } + + is BookmarkDeletedEvent -> { + val eventActivity = event.bookmark.activity + if (fid.rawValue !in eventActivity.feeds || eventActivity.id != activityId) return + state.onBookmarkRemoved(event.bookmark.toModel()) + } + is PollClosedFeedEvent -> { if (event.fid != fid.rawValue) return state.onPollClosed(event.poll.toModel()) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityStateImplTest.kt index 5cce8cbc..8b17f916 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityStateImplTest.kt @@ -17,9 +17,12 @@ package io.getstream.feeds.android.client.internal.state import io.getstream.feeds.android.client.api.state.ActivityCommentListState import io.getstream.feeds.android.client.internal.test.TestData.activityData +import io.getstream.feeds.android.client.internal.test.TestData.bookmarkData +import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionData import io.getstream.feeds.android.client.internal.test.TestData.pollData import io.getstream.feeds.android.client.internal.test.TestData.pollOptionData import io.getstream.feeds.android.client.internal.test.TestData.pollVoteData +import io.getstream.feeds.android.client.internal.test.TestData.reactionGroupData import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -48,6 +51,100 @@ internal class ActivityStateImplTest { assertEquals(poll, activityState.poll.value) } + @Test + fun `on reactionAdded, then add reaction to activity`() = runTest { + val initialActivity = activityData("activity-1") + activityState.onActivityUpdated(initialActivity) + + val reaction = feedsReactionData("activity-1", "like", currentUserId) + activityState.onReactionAdded(reaction) + + val expectedActivity = + initialActivity.copy( + reactionCount = 1, + ownReactions = listOf(reaction), + latestReactions = listOf(reaction), + reactionGroups = mapOf("like" to reactionGroupData(count = 1)), + ) + assertEquals(expectedActivity, activityState.activity.value) + } + + @Test + fun `on reactionRemoved, then remove reaction from activity`() = runTest { + val initialActivity = activityData("activity-1") + activityState.onActivityUpdated(initialActivity) + + val reaction = feedsReactionData("activity-1", "like", currentUserId) + activityState.onReactionAdded(reaction) + activityState.onReactionRemoved(reaction) + + val expectedActivity = + initialActivity.copy( + reactionCount = 0, + ownReactions = emptyList(), + latestReactions = emptyList(), + reactionGroups = emptyMap(), + ) + assertEquals(expectedActivity, activityState.activity.value) + } + + @Test + fun `on bookmarkAdded, then add bookmark to activity`() = runTest { + val initialActivity = activityData("activity-1") + activityState.onActivityUpdated(initialActivity) + + val bookmark = bookmarkData("activity-1", currentUserId) + activityState.onBookmarkAdded(bookmark) + + val expectedActivity = + initialActivity.copy(bookmarkCount = 1, ownBookmarks = listOf(bookmark)) + assertEquals(expectedActivity, activityState.activity.value) + } + + @Test + fun `on bookmarkRemoved, then remove bookmark from activity`() = runTest { + val initialActivity = activityData("activity-1") + activityState.onActivityUpdated(initialActivity) + + val bookmark = bookmarkData("activity-1", currentUserId) + activityState.onBookmarkAdded(bookmark) + activityState.onBookmarkRemoved(bookmark) + + val expectedActivity = initialActivity.copy(bookmarkCount = 0, ownBookmarks = emptyList()) + assertEquals(expectedActivity, activityState.activity.value) + } + + @Test + fun `on reactionAdded from different user, then update latestReactions but not ownReactions`() = + runTest { + val initialActivity = activityData("activity-1") + activityState.onActivityUpdated(initialActivity) + + val reaction = feedsReactionData("activity-1", "like", "other-user") + activityState.onReactionAdded(reaction) + + val expectedActivity = + initialActivity.copy( + reactionCount = 1, + ownReactions = emptyList(), + latestReactions = listOf(reaction), + reactionGroups = mapOf("like" to reactionGroupData(count = 1)), + ) + assertEquals(expectedActivity, activityState.activity.value) + } + + @Test + fun `on bookmarkAdded from different user, then update count but not ownBookmarks`() = runTest { + val initialActivity = activityData("activity-1") + activityState.onActivityUpdated(initialActivity) + + val bookmark = bookmarkData("activity-1", "other-user") + activityState.onBookmarkAdded(bookmark) + + val expectedActivity = initialActivity.copy(bookmarkCount = 1, ownBookmarks = emptyList()) + assertEquals(expectedActivity, activityState.activity.value) + } + @Test fun `on pollClosed, then update poll`() = runTest { val initialPoll = pollData() diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt index 66cc8c0a..e0392b53 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt @@ -37,7 +37,8 @@ internal class ActivityEventHandlerTest { private val fid = FeedId("user", "activity-1") private val state: ActivityStateUpdates = mockk(relaxed = true) - private val handler = ActivityEventHandler(fid, state) + private val activityId = "test-activity-id" + private val handler = ActivityEventHandler(fid, activityId, state) @Test fun `on PollClosedFeedEvent for matching feed, then call onPollClosed`() { From ff3727ff6f07f8e7d623e0e35b0c0d1a858aee88 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 12:55:25 +0200 Subject: [PATCH 13/18] Reduce duplication in ActivityEventHandlerTest --- .../event/handler/ActivityEventHandlerTest.kt | 199 ++++++------------ 1 file changed, 67 insertions(+), 132 deletions(-) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt index e0392b53..5a2aa427 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt @@ -28,6 +28,7 @@ import io.getstream.feeds.android.network.models.PollVoteChangedFeedEvent import io.getstream.feeds.android.network.models.PollVoteRemovedFeedEvent import io.getstream.feeds.android.network.models.WSEvent import io.mockk.called +import io.mockk.clearMocks import io.mockk.mockk import io.mockk.verify import java.util.Date @@ -36,111 +37,73 @@ import org.junit.Test internal class ActivityEventHandlerTest { private val fid = FeedId("user", "activity-1") + private val differentFid = "user:different-activity" private val state: ActivityStateUpdates = mockk(relaxed = true) private val activityId = "test-activity-id" private val handler = ActivityEventHandler(fid, activityId, state) @Test - fun `on PollClosedFeedEvent for matching feed, then call onPollClosed`() { + fun `on PollClosedFeedEvent, then handle based on feed match`() { val poll = pollResponseData() - val event = + val matchingEvent = PollClosedFeedEvent( createdAt = Date(), fid = fid.rawValue, poll = poll, type = "feeds.poll.closed", ) + val nonMatchingEvent = matchingEvent.copy(fid = differentFid) - handler.onEvent(event) - - verify { state.onPollClosed(poll.toModel()) } + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollClosed(poll.toModel()) }, + ) } @Test - fun `on PollClosedFeedEvent for different feed, then do not call onPollClosed`() { + fun `on PollDeletedFeedEvent, then handle based on feed match`() { val poll = pollResponseData() - val event = - PollClosedFeedEvent( - createdAt = Date(), - fid = "user:different-activity", - poll = poll, - type = "feeds.poll.closed", - ) - - handler.onEvent(event) - - verify(exactly = 0) { state.onPollClosed(any()) } - } - - @Test - fun `on PollDeletedFeedEvent for matching feed, then call onPollDeleted`() { - val poll = pollResponseData() - val event = + val matchingEvent = PollDeletedFeedEvent( createdAt = Date(), fid = fid.rawValue, poll = poll, type = "feeds.poll.deleted", ) + val nonMatchingEvent = matchingEvent.copy(fid = differentFid) - handler.onEvent(event) - - verify { state.onPollDeleted(poll.id) } - } - - @Test - fun `on PollDeletedFeedEvent for different feed, then do not call onPollDeleted`() { - val poll = pollResponseData() - val event = - PollDeletedFeedEvent( - createdAt = Date(), - fid = "user:different-activity", - poll = poll, - type = "feeds.poll.deleted", - ) - - handler.onEvent(event) - - verify(exactly = 0) { state.onPollDeleted(any()) } + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollDeleted(poll.id) }, + ) } @Test - fun `on PollUpdatedFeedEvent for matching feed, then call onPollUpdated`() { + fun `on PollUpdatedFeedEvent, then handle based on feed match`() { val poll = pollResponseData() - val event = + val matchingEvent = PollUpdatedFeedEvent( createdAt = Date(), fid = fid.rawValue, poll = poll, type = "feeds.poll.updated", ) + val nonMatchingEvent = matchingEvent.copy(fid = differentFid) - handler.onEvent(event) - - verify { state.onPollUpdated(poll.toModel()) } + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollUpdated(poll.toModel()) }, + ) } @Test - fun `on PollUpdatedFeedEvent for different feed, then do not call onPollUpdated`() { - val poll = pollResponseData() - val event = - PollUpdatedFeedEvent( - createdAt = Date(), - fid = "user:different-activity", - poll = poll, - type = "feeds.poll.updated", - ) - - handler.onEvent(event) - - verify(exactly = 0) { state.onPollUpdated(any()) } - } - - @Test - fun `on PollVoteCastedFeedEvent for matching feed, then call onPollVoteCasted`() { + fun `on PollVoteCastedFeedEvent, then handle based on feed match`() { val poll = pollResponseData() val pollVote = pollVoteResponseData() - val event = + val matchingEvent = PollVoteCastedFeedEvent( createdAt = Date(), fid = fid.rawValue, @@ -148,35 +111,20 @@ internal class ActivityEventHandlerTest { pollVote = pollVote, type = "feeds.poll.vote.casted", ) + val nonMatchingEvent = matchingEvent.copy(fid = differentFid) - handler.onEvent(event) - - verify { state.onPollVoteCasted(pollVote.toModel(), poll.toModel()) } + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollVoteCasted(pollVote.toModel(), poll.toModel()) }, + ) } @Test - fun `on PollVoteCastedFeedEvent for different feed, then do not call onPollVoteCasted`() { + fun `on PollVoteChangedFeedEvent, then handle based on feed match`() { val poll = pollResponseData() val pollVote = pollVoteResponseData() - val event = - PollVoteCastedFeedEvent( - createdAt = Date(), - fid = "user:different-activity", - poll = poll, - pollVote = pollVote, - type = "feeds.poll.vote.casted", - ) - - handler.onEvent(event) - - verify(exactly = 0) { state.onPollVoteCasted(any(), any()) } - } - - @Test - fun `on PollVoteChangedFeedEvent for matching feed, then call onPollVoteChanged`() { - val poll = pollResponseData() - val pollVote = pollVoteResponseData() - val event = + val matchingEvent = PollVoteChangedFeedEvent( createdAt = Date(), fid = fid.rawValue, @@ -184,35 +132,20 @@ internal class ActivityEventHandlerTest { pollVote = pollVote, type = "feeds.poll.vote.changed", ) + val nonMatchingEvent = matchingEvent.copy(fid = differentFid) - handler.onEvent(event) - - verify { state.onPollVoteChanged(pollVote.toModel(), poll.toModel()) } + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollVoteChanged(pollVote.toModel(), poll.toModel()) }, + ) } @Test - fun `on PollVoteChangedFeedEvent for different feed, then do not call onPollVoteChanged`() { + fun `on PollVoteRemovedFeedEvent, then handle based on feed match`() { val poll = pollResponseData() val pollVote = pollVoteResponseData() - val event = - PollVoteChangedFeedEvent( - createdAt = Date(), - fid = "user:different-activity", - poll = poll, - pollVote = pollVote, - type = "feeds.poll.vote.changed", - ) - - handler.onEvent(event) - - verify(exactly = 0) { state.onPollVoteChanged(any(), any()) } - } - - @Test - fun `on PollVoteRemovedFeedEvent for matching feed, then call onPollVoteRemoved`() { - val poll = pollResponseData() - val pollVote = pollVoteResponseData() - val event = + val matchingEvent = PollVoteRemovedFeedEvent( createdAt = Date(), fid = fid.rawValue, @@ -220,28 +153,13 @@ internal class ActivityEventHandlerTest { pollVote = pollVote, type = "feeds.poll.vote.removed", ) + val nonMatchingEvent = matchingEvent.copy(fid = differentFid) - handler.onEvent(event) - - verify { state.onPollVoteRemoved(pollVote.toModel(), poll.toModel()) } - } - - @Test - fun `on PollVoteRemovedFeedEvent for different feed, then do not call onPollVoteRemoved`() { - val poll = pollResponseData() - val pollVote = pollVoteResponseData() - val event = - PollVoteRemovedFeedEvent( - createdAt = Date(), - fid = "user:different-activity", - poll = poll, - pollVote = pollVote, - type = "feeds.poll.vote.removed", - ) - - handler.onEvent(event) - - verify(exactly = 0) { state.onPollVoteRemoved(any(), any()) } + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onPollVoteRemoved(pollVote.toModel(), poll.toModel()) }, + ) } @Test @@ -255,4 +173,21 @@ internal class ActivityEventHandlerTest { verify { state wasNot called } } + + private fun testEventHandling( + matchingEvent: WSEvent, + nonMatchingEvent: WSEvent, + verifyBlock: () -> Unit, + ) { + // Test matching event + handler.onEvent(matchingEvent) + verify { verifyBlock() } + + // Reset mock for clean verification + clearMocks(state) + + // Test non-matching event + handler.onEvent(nonMatchingEvent) + verify { state wasNot called } + } } From 081a827c39f98680e0ae99d80c556b39d904f8f5 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 13:07:09 +0200 Subject: [PATCH 14/18] Extend ActivityEventHandlerTest to cover new events --- .../event/handler/ActivityEventHandlerTest.kt | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt index 5a2aa427..dc4b6c1f 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityEventHandlerTest.kt @@ -18,8 +18,16 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.FeedId import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.ActivityStateUpdates +import io.getstream.feeds.android.client.internal.test.TestData.activityResponse +import io.getstream.feeds.android.client.internal.test.TestData.bookmarkResponse +import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionResponse import io.getstream.feeds.android.client.internal.test.TestData.pollResponseData import io.getstream.feeds.android.client.internal.test.TestData.pollVoteResponseData +import io.getstream.feeds.android.network.models.ActivityReactionAddedEvent +import io.getstream.feeds.android.network.models.ActivityReactionDeletedEvent +import io.getstream.feeds.android.network.models.ActivityUpdatedEvent +import io.getstream.feeds.android.network.models.BookmarkAddedEvent +import io.getstream.feeds.android.network.models.BookmarkDeletedEvent import io.getstream.feeds.android.network.models.PollClosedFeedEvent import io.getstream.feeds.android.network.models.PollDeletedFeedEvent import io.getstream.feeds.android.network.models.PollUpdatedFeedEvent @@ -42,6 +50,111 @@ internal class ActivityEventHandlerTest { private val activityId = "test-activity-id" private val handler = ActivityEventHandler(fid, activityId, state) + @Test + fun `on ActivityReactionAddedEvent, then handle based on feed and activity match`() { + val activity = activityResponse(activityId) + val reaction = feedsReactionResponse() + val matchingEvent = + ActivityReactionAddedEvent( + createdAt = Date(), + fid = fid.rawValue, + activity = activity, + reaction = reaction, + type = "activity.reaction.added", + ) + val nonMatchingEvent = matchingEvent.copy(fid = differentFid) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onReactionAdded(reaction.toModel()) }, + ) + } + + @Test + fun `on ActivityReactionDeletedEvent, then handle based on feed and activity match`() { + val activity = activityResponse(activityId) + val reaction = feedsReactionResponse() + val matchingEvent = + ActivityReactionDeletedEvent( + createdAt = Date(), + fid = fid.rawValue, + activity = activity, + reaction = reaction, + type = "activity.reaction.deleted", + ) + val nonMatchingEvent = matchingEvent.copy(fid = differentFid) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onReactionRemoved(reaction.toModel()) }, + ) + } + + @Test + fun `on ActivityUpdatedEvent, then handle based on feed and activity match`() { + val activity = activityResponse(activityId) + val matchingEvent = + ActivityUpdatedEvent( + createdAt = Date(), + fid = fid.rawValue, + activity = activity, + type = "activity.updated", + ) + val nonMatchingEvent = matchingEvent.copy(fid = differentFid) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onActivityUpdated(activity.toModel()) }, + ) + } + + @Test + fun `on BookmarkAddedEvent, then handle based on feed and activity match`() { + val bookmark = bookmarkResponse() + val matchingActivity = bookmark.activity.copy(feeds = listOf(fid.rawValue), id = activityId) + val matchingBookmark = bookmark.copy(activity = matchingActivity) + val matchingEvent = + BookmarkAddedEvent( + createdAt = Date(), + bookmark = matchingBookmark, + type = "bookmark.added", + ) + val nonMatchingActivity = matchingActivity.copy(feeds = listOf(differentFid)) + val nonMatchingBookmark = matchingBookmark.copy(activity = nonMatchingActivity) + val nonMatchingEvent = matchingEvent.copy(bookmark = nonMatchingBookmark) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onBookmarkAdded(matchingBookmark.toModel()) }, + ) + } + + @Test + fun `on BookmarkDeletedEvent, then handle based on feed and activity match`() { + val bookmark = bookmarkResponse() + val matchingActivity = bookmark.activity.copy(feeds = listOf(fid.rawValue), id = activityId) + val matchingBookmark = bookmark.copy(activity = matchingActivity) + val matchingEvent = + BookmarkDeletedEvent( + createdAt = Date(), + bookmark = matchingBookmark, + type = "bookmark.deleted", + ) + val nonMatchingActivity = matchingActivity.copy(feeds = listOf(differentFid)) + val nonMatchingBookmark = matchingBookmark.copy(activity = nonMatchingActivity) + val nonMatchingEvent = matchingEvent.copy(bookmark = nonMatchingBookmark) + + testEventHandling( + matchingEvent = matchingEvent, + nonMatchingEvent = nonMatchingEvent, + verifyBlock = { state.onBookmarkRemoved(matchingBookmark.toModel()) }, + ) + } + @Test fun `on PollClosedFeedEvent, then handle based on feed match`() { val poll = pollResponseData() From 65d47b33fa0039c07ac871a3588d6b8aa22397b7 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:00:13 +0200 Subject: [PATCH 15/18] Handle FollowDeletedEvent for FollowList --- .../internal/state/FollowListStateImpl.kt | 8 +++++++ .../event/handler/FollowListEventHandler.kt | 5 +++++ .../internal/state/FollowListStateImplTest.kt | 21 +++++++++++++++++++ .../handler/FollowListEventHandlerTest.kt | 17 +++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImpl.kt index cee8bf53..a1ea1a3b 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImpl.kt @@ -27,6 +27,7 @@ import io.getstream.feeds.android.client.internal.utils.mergeSorted import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * An observable state object that manages the current state of a follow list. @@ -73,6 +74,10 @@ internal class FollowListStateImpl(override val query: FollowsQuery) : FollowLis } } } + + override fun onFollowRemoved(follow: FollowData) { + _follows.update { current -> current.filter { it.id != follow.id } } + } } /** @@ -94,4 +99,7 @@ internal interface FollowListStateUpdates { /** Handles the update of a follow data. */ fun onFollowUpdated(follow: FollowData) + + /** Handles the removal of a follow. */ + fun onFollowRemoved(follow: FollowData) } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FollowListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FollowListEventHandler.kt index 38e22768..7c88212c 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FollowListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FollowListEventHandler.kt @@ -18,6 +18,7 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.FollowListStateUpdates import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.network.models.FollowDeletedEvent import io.getstream.feeds.android.network.models.FollowUpdatedEvent import io.getstream.feeds.android.network.models.WSEvent @@ -29,6 +30,10 @@ internal class FollowListEventHandler(private val state: FollowListStateUpdates) is FollowUpdatedEvent -> { state.onFollowUpdated(event.follow.toModel()) } + + is FollowDeletedEvent -> { + state.onFollowRemoved(event.follow.toModel()) + } } } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImplTest.kt index db739cdd..7a11d0eb 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FollowListStateImplTest.kt @@ -108,4 +108,25 @@ internal class FollowListStateImplTest { assertEquals(initialFollows, followListState.follows.value) } + + @Test + fun `on followRemoved, then remove specific follow`() = runTest { + val initialFollows = listOf(followData(), followData("user-2", "user-3")) + val paginationResult = + PaginationResult( + models = initialFollows, + pagination = PaginationData(next = "next-cursor", previous = null), + ) + val queryConfig = + QueryConfiguration( + filter = null, + sort = FollowsSort.Default, + ) + followListState.onQueryMoreFollows(paginationResult, queryConfig) + + followListState.onFollowRemoved(initialFollows[0]) + + val remainingFollows = followListState.follows.value + assertEquals(listOf(initialFollows[1]), remainingFollows) + } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FollowListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FollowListEventHandlerTest.kt index e3615a5d..9c6282cb 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FollowListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FollowListEventHandlerTest.kt @@ -18,6 +18,7 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.FollowListStateUpdates import io.getstream.feeds.android.client.internal.test.TestData.followResponse +import io.getstream.feeds.android.network.models.FollowDeletedEvent import io.getstream.feeds.android.network.models.FollowUpdatedEvent import io.getstream.feeds.android.network.models.WSEvent import io.mockk.called @@ -47,6 +48,22 @@ internal class FollowListEventHandlerTest { verify { state.onFollowUpdated(follow.toModel()) } } + @Test + fun `on FollowDeletedEvent, then call onFollowRemoved`() { + val follow = followResponse() + val event = + FollowDeletedEvent( + createdAt = Date(), + fid = "user:feed-1", + follow = follow, + type = "feeds.follow.updated", + ) + + handler.onEvent(event) + + verify { state.onFollowRemoved(follow.toModel()) } + } + @Test fun `on unknown event, then do nothing`() { val unknownEvent = From 927ff82ac65efb8f6061cc7856f68ce7a8286c67 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:25:26 +0200 Subject: [PATCH 16/18] Handle FeedDeletedEvent for FeedList --- .../internal/state/FeedListStateImpl.kt | 8 ++ .../event/handler/FeedListEventHandler.kt | 5 ++ .../internal/state/FeedListStateImplTest.kt | 78 +++++++++++-------- .../event/handler/FeedListEventHandlerTest.kt | 11 +++ .../android/client/internal/test/TestData.kt | 8 ++ 5 files changed, 78 insertions(+), 32 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedListStateImpl.kt index 311ec236..b4d3c513 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/FeedListStateImpl.kt @@ -27,6 +27,7 @@ import io.getstream.feeds.android.client.internal.utils.mergeSorted import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * An observable state object that manages the current state of a feed list. @@ -66,6 +67,10 @@ internal class FeedListStateImpl(override val query: FeedsQuery) : FeedListMutab } } + override fun onFeedRemoved(feedId: String) { + _feeds.update { current -> current.filter { it.fid.rawValue != feedId } } + } + override fun onQueryMoreFeeds( result: PaginationResult, queryConfig: QueryConfiguration, @@ -101,4 +106,7 @@ internal interface FeedListStateUpdates { result: PaginationResult, queryConfig: QueryConfiguration, ) + + /** Handles the removal of a feed by its ID. */ + fun onFeedRemoved(feedId: String) } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedListEventHandler.kt index 2d919f6d..a0eab4ad 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedListEventHandler.kt @@ -18,6 +18,7 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.FeedListStateUpdates import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.network.models.FeedDeletedEvent import io.getstream.feeds.android.network.models.FeedUpdatedEvent import io.getstream.feeds.android.network.models.WSEvent @@ -28,6 +29,10 @@ internal class FeedListEventHandler(private val state: FeedListStateUpdates) : F is FeedUpdatedEvent -> { state.onFeedUpdated(event.feed.toModel()) } + + is FeedDeletedEvent -> { + state.onFeedRemoved(event.fid) + } } } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedListStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedListStateImplTest.kt index f8319b22..e43a399c 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedListStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedListStateImplTest.kt @@ -16,12 +16,11 @@ package io.getstream.feeds.android.client.internal.state import io.getstream.feeds.android.client.api.model.FeedData -import io.getstream.feeds.android.client.api.model.PaginationData -import io.getstream.feeds.android.client.api.model.PaginationResult import io.getstream.feeds.android.client.api.model.QueryConfiguration import io.getstream.feeds.android.client.api.state.query.FeedsFilterField import io.getstream.feeds.android.client.api.state.query.FeedsQuery import io.getstream.feeds.android.client.api.state.query.FeedsSort +import io.getstream.feeds.android.client.internal.test.TestData.defaultPaginationResult import io.getstream.feeds.android.client.internal.test.TestData.feedData import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -40,57 +39,72 @@ internal class FeedListStateImplTest { @Test fun `on queryMoreFeeds, then update feeds and pagination`() = runTest { - val feeds = listOf(feedData(), feedData("feed-2", "user", "Test Feed 2")) - val paginationResult = - PaginationResult( - models = feeds, - pagination = PaginationData(next = "next-cursor", previous = null), - ) - val queryConfig = - QueryConfiguration(filter = null, sort = FeedsSort.Default) + val feed1 = feedData(id = "feed-1", groupId = "user", name = "First Feed") + val feed2 = feedData(id = "feed-2", groupId = "user", name = "Second Feed") + val feeds = listOf(feed1, feed2) + val paginationResult = defaultPaginationResult(feeds) - feedListState.onQueryMoreFeeds(paginationResult, queryConfig) + feedListState.onQueryMoreFeeds(paginationResult, defaultQueryConfig) assertEquals(feeds, feedListState.feeds.value) assertEquals("next-cursor", feedListState.pagination?.next) - assertEquals(queryConfig, feedListState.queryConfig) + assertEquals(defaultQueryConfig, feedListState.queryConfig) } @Test fun `on feedUpdated, then update specific feed`() = runTest { - val initialFeeds = listOf(feedData(), feedData("feed-2", "user", "Test Feed 2")) - val paginationResult = - PaginationResult( - models = initialFeeds, - pagination = PaginationData(next = "next-cursor", previous = null), - ) - val queryConfig = - QueryConfiguration(filter = null, sort = FeedsSort.Default) - feedListState.onQueryMoreFeeds(paginationResult, queryConfig) + val feed1 = feedData(id = "feed-1", groupId = "user", name = "First Feed") + val feed2 = feedData(id = "feed-2", groupId = "user", name = "Second Feed") + val initialFeeds = listOf(feed1, feed2) + val paginationResult = defaultPaginationResult(initialFeeds) + feedListState.onQueryMoreFeeds(paginationResult, defaultQueryConfig) val updatedFeed = - feedData("user-1", "user", "Updated Feed", description = "Updated description") + feedData( + id = "feed-1", + groupId = "user", + name = "Updated Feed", + description = "Updated description", + ) feedListState.onFeedUpdated(updatedFeed) val updatedFeeds = feedListState.feeds.value - assertEquals(listOf(updatedFeed, initialFeeds[1]), updatedFeeds) + assertEquals(listOf(updatedFeed, feed2), updatedFeeds) } @Test fun `on feedUpdated with non-existent feed, then keep existing feeds unchanged`() = runTest { - val initialFeeds = listOf(feedData(), feedData("feed-2", "user", "Test Feed 2")) - val paginationResult = - PaginationResult( - models = initialFeeds, - pagination = PaginationData(next = "next-cursor", previous = null), - ) + val feed1 = feedData(id = "feed-1", groupId = "user", name = "First Feed") + val feed2 = feedData(id = "feed-2", groupId = "user", name = "Second Feed") + val initialFeeds = listOf(feed1, feed2) + val paginationResult = defaultPaginationResult(initialFeeds) + feedListState.onQueryMoreFeeds(paginationResult, defaultQueryConfig) + + val nonExistentFeed = + feedData(id = "non-existent", groupId = "user", name = "Non-existent Feed") + feedListState.onFeedUpdated(nonExistentFeed) + + assertEquals(initialFeeds, feedListState.feeds.value) + } + + @Test + fun `on feedRemoved, then remove specific feed`() = runTest { + val feed1 = feedData(id = "feed-1", groupId = "user", name = "First Feed") + val feed2 = feedData(id = "feed-2", groupId = "user", name = "Second Feed") + val initialFeeds = listOf(feed1, feed2) + val paginationResult = defaultPaginationResult(initialFeeds) val queryConfig = QueryConfiguration(filter = null, sort = FeedsSort.Default) feedListState.onQueryMoreFeeds(paginationResult, queryConfig) - val nonExistentFeed = feedData("non-existent", "user", "Non-existent Feed") - feedListState.onFeedUpdated(nonExistentFeed) + feedListState.onFeedRemoved("user:feed-1") - assertEquals(initialFeeds, feedListState.feeds.value) + val remainingFeeds = feedListState.feeds.value + assertEquals(listOf(feed2), remainingFeeds) + } + + companion object { + private val defaultQueryConfig = + QueryConfiguration(filter = null, sort = FeedsSort.Default) } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedListEventHandlerTest.kt index 002e0baf..e471649c 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/FeedListEventHandlerTest.kt @@ -18,6 +18,7 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.FeedListStateUpdates import io.getstream.feeds.android.client.internal.test.TestData.feedResponse +import io.getstream.feeds.android.network.models.FeedDeletedEvent import io.getstream.feeds.android.network.models.FeedUpdatedEvent import io.getstream.feeds.android.network.models.WSEvent import io.mockk.called @@ -47,6 +48,16 @@ internal class FeedListEventHandlerTest { verify { state.onFeedUpdated(feed.toModel()) } } + @Test + fun `on FeedDeletedEvent, then call onFeedRemoved`() { + val event = + FeedDeletedEvent(createdAt = Date(), fid = "user:feed-1", type = "feeds.feed.deleted") + + handler.onEvent(event) + + verify { state.onFeedRemoved("user:feed-1") } + } + @Test fun `on unknown event, then do nothing`() { val unknownEvent = 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 624c0ac2..5f9f971f 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 @@ -30,6 +30,8 @@ import io.getstream.feeds.android.client.api.model.FileUploadConfigData import io.getstream.feeds.android.client.api.model.FollowData import io.getstream.feeds.android.client.api.model.FollowStatus import io.getstream.feeds.android.client.api.model.ModerationConfigData +import io.getstream.feeds.android.client.api.model.PaginationData +import io.getstream.feeds.android.client.api.model.PaginationResult import io.getstream.feeds.android.client.api.model.PollData import io.getstream.feeds.android.client.api.model.PollOptionData import io.getstream.feeds.android.client.api.model.PollVoteData @@ -684,4 +686,10 @@ internal object TestData { updatedAt = Date(1000), user = userResponse(), ) + + fun defaultPaginationResult(list: List): PaginationResult = + PaginationResult( + models = list, + pagination = PaginationData(next = "next-cursor", previous = null), + ) } From 38110f7c446304133720d0b3ad552b11a670ac65 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:45:08 +0200 Subject: [PATCH 17/18] Notify fees state after successfully adding/removing a reaction --- .../feeds/android/client/internal/state/FeedImpl.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 5180a5f5..2199e33c 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 @@ -352,14 +352,18 @@ internal class FeedImpl( activityId: String, request: AddReactionRequest, ): Result { - return activitiesRepository.addReaction(activityId, request) + return activitiesRepository + .addReaction(activityId, request) + .onSuccess(_state::onReactionAdded) } override suspend fun deleteReaction( activityId: String, type: String, ): Result { - return activitiesRepository.deleteReaction(activityId = activityId, type = type) + return activitiesRepository + .deleteReaction(activityId = activityId, type = type) + .onSuccess(_state::onReactionRemoved) } override suspend fun addCommentReaction( From bd30ecf2675d5c017f046ddafd6679c3002db445 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:25:07 +0200 Subject: [PATCH 18/18] Handler PollVoteCastedFeedEvent for PollVoteList --- .../internal/state/PollVoteListStateImpl.kt | 9 +++ .../event/handler/PollVoteListEventHandler.kt | 7 ++ .../state/PollVoteListStateImplTest.kt | 64 ++++++++++++------- .../handler/PollVoteListEventHandlerTest.kt | 37 +++++++++++ 4 files changed, 95 insertions(+), 22 deletions(-) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/PollVoteListStateImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/PollVoteListStateImpl.kt index 0225b0be..13b78011 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/PollVoteListStateImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/PollVoteListStateImpl.kt @@ -24,9 +24,11 @@ import io.getstream.feeds.android.client.api.state.query.PollVotesFilterField import io.getstream.feeds.android.client.api.state.query.PollVotesQuery import io.getstream.feeds.android.client.api.state.query.PollVotesSort import io.getstream.feeds.android.client.internal.utils.mergeSorted +import io.getstream.feeds.android.client.internal.utils.upsertSorted import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * An observable state object that manages the current state of a poll vote list. @@ -63,6 +65,10 @@ internal class PollVoteListStateImpl(override val query: PollVotesQuery) : _votes.value = _votes.value.mergeSorted(result.models, PollVoteData::id, votesSorting) } + override fun pollVoteAdded(vote: PollVoteData) { + _votes.update { current -> current.upsertSorted(vote, PollVoteData::id, votesSorting) } + } + override fun pollVoteRemoved(voteId: String) { _votes.value = _votes.value.filter { it.id != voteId } } @@ -102,6 +108,9 @@ internal interface PollVoteListStateUpdates { queryConfig: QueryConfiguration, ) + /** Handles the addition of a new poll vote to the list. */ + fun pollVoteAdded(vote: PollVoteData) + /** Handles the removal of a poll vote from the list. */ fun pollVoteRemoved(voteId: String) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/PollVoteListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/PollVoteListEventHandler.kt index f30d9537..ab3573b7 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/PollVoteListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/PollVoteListEventHandler.kt @@ -18,6 +18,7 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.PollVoteListStateUpdates import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.network.models.PollVoteCastedFeedEvent import io.getstream.feeds.android.network.models.PollVoteChangedFeedEvent import io.getstream.feeds.android.network.models.PollVoteRemovedFeedEvent import io.getstream.feeds.android.network.models.WSEvent @@ -35,10 +36,16 @@ internal class PollVoteListEventHandler( override fun onEvent(event: WSEvent) { when (event) { + is PollVoteCastedFeedEvent -> { + if (event.poll.id != pollId) return + state.pollVoteAdded(event.pollVote.toModel()) + } + is PollVoteChangedFeedEvent -> { if (event.poll.id != pollId) return state.pollVoteUpdated(event.pollVote.toModel()) } + is PollVoteRemovedFeedEvent -> { if (event.poll.id != pollId) return state.pollVoteRemoved(event.pollVote.id) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/PollVoteListStateImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/PollVoteListStateImplTest.kt index 62f52c7d..1148fa8d 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/PollVoteListStateImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/PollVoteListStateImplTest.kt @@ -15,13 +15,12 @@ */ package io.getstream.feeds.android.client.internal.state -import io.getstream.feeds.android.client.api.model.PaginationData -import io.getstream.feeds.android.client.api.model.PaginationResult import io.getstream.feeds.android.client.api.model.PollVoteData import io.getstream.feeds.android.client.api.model.QueryConfiguration import io.getstream.feeds.android.client.api.state.query.PollVotesFilterField import io.getstream.feeds.android.client.api.state.query.PollVotesQuery import io.getstream.feeds.android.client.api.state.query.PollVotesSort +import io.getstream.feeds.android.client.internal.test.TestData.defaultPaginationResult import io.getstream.feeds.android.client.internal.test.TestData.pollVoteData import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -41,11 +40,7 @@ internal class PollVoteListStateImplTest { @Test fun `on queryMorePollVotes, then update votes and pagination`() = runTest { val votes = listOf(pollVoteData(), pollVoteData("vote-2", "poll-1", "option-2", "user-2")) - val paginationResult = - PaginationResult( - models = votes, - pagination = PaginationData(next = "next-cursor", previous = null), - ) + val paginationResult = defaultPaginationResult(votes) val queryConfig = QueryConfiguration( filter = null, @@ -63,11 +58,7 @@ internal class PollVoteListStateImplTest { fun `on pollVoteUpdated, then update specific vote`() = runTest { val initialVotes = listOf(pollVoteData(), pollVoteData("vote-2", "poll-1", "option-2", "user-2")) - val paginationResult = - PaginationResult( - models = initialVotes, - pagination = PaginationData(next = "next-cursor", previous = null), - ) + val paginationResult = defaultPaginationResult(initialVotes) val queryConfig = QueryConfiguration( filter = null, @@ -87,11 +78,7 @@ internal class PollVoteListStateImplTest { fun `on pollVoteRemoved, then remove specific vote`() = runTest { val initialVotes = listOf(pollVoteData(), pollVoteData("vote-2", "poll-1", "option-2", "user-2")) - val paginationResult = - PaginationResult( - models = initialVotes, - pagination = PaginationData(next = "next-cursor", previous = null), - ) + val paginationResult = defaultPaginationResult(initialVotes) val queryConfig = QueryConfiguration( filter = null, @@ -109,11 +96,7 @@ internal class PollVoteListStateImplTest { fun `on pollVoteUpdated with non-existent vote, then keep unchanged`() = runTest { val initialVotes = listOf(pollVoteData(), pollVoteData("vote-2", "poll-1", "option-2", "user-2")) - val paginationResult = - PaginationResult( - models = initialVotes, - pagination = PaginationData(next = "next-cursor", previous = null), - ) + val paginationResult = defaultPaginationResult(initialVotes) val queryConfig = QueryConfiguration( filter = null, @@ -126,4 +109,41 @@ internal class PollVoteListStateImplTest { assertEquals(initialVotes, pollVoteListState.votes.value) } + + @Test + fun `on pollVoteAdded with new vote, then add vote to list in sorted order`() = runTest { + val initial = + listOf(pollVoteData("vote-1"), pollVoteData("vote-3", "poll-1", "option-3", "user-3")) + val pagination = defaultPaginationResult(initial) + val queryConfig = + QueryConfiguration( + filter = null, + sort = PollVotesSort.Default, + ) + pollVoteListState.onQueryMorePollVotes(pagination, queryConfig) + + val newVote = pollVoteData("vote-2", "poll-1", "option-2", "user-2") + pollVoteListState.pollVoteAdded(newVote) + + val expectedVotes = listOf(initial[0], newVote, initial[1]) + assertEquals(expectedVotes, pollVoteListState.votes.value) + } + + @Test + fun `on pollVoteAdded with existing vote, then update existing vote`() = runTest { + val initial = + pollVoteData("vote-1", "poll-1", "option-1", "user-1", answerText = "Original") + val pagination = defaultPaginationResult(listOf(initial)) + val queryConfig = + QueryConfiguration( + filter = null, + sort = PollVotesSort.Default, + ) + pollVoteListState.onQueryMorePollVotes(pagination, queryConfig) + + val updated = pollVoteData("vote-1", "poll-1", "option-1", "user-1", answerText = "Updated") + pollVoteListState.pollVoteAdded(updated) + + assertEquals(listOf(updated), pollVoteListState.votes.value) + } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/PollVoteListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/PollVoteListEventHandlerTest.kt index 27937844..4825efb4 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/PollVoteListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/PollVoteListEventHandlerTest.kt @@ -19,6 +19,7 @@ import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.PollVoteListStateUpdates import io.getstream.feeds.android.client.internal.test.TestData.pollResponseData import io.getstream.feeds.android.client.internal.test.TestData.pollVoteResponseData +import io.getstream.feeds.android.network.models.PollVoteCastedFeedEvent import io.getstream.feeds.android.network.models.PollVoteChangedFeedEvent import io.getstream.feeds.android.network.models.PollVoteRemovedFeedEvent import io.getstream.feeds.android.network.models.WSEvent @@ -106,6 +107,42 @@ internal class PollVoteListEventHandlerTest { verify(exactly = 0) { state.pollVoteRemoved(any()) } } + @Test + fun `on PollVoteCastedFeedEvent for matching poll, then call pollVoteAdded`() { + val poll = pollResponseData().copy(id = pollId) + val pollVote = pollVoteResponseData() + val event = + PollVoteCastedFeedEvent( + createdAt = Date(), + fid = "user:feed-1", + poll = poll, + pollVote = pollVote, + type = "feeds.poll.vote_casted", + ) + + handler.onEvent(event) + + verify { state.pollVoteAdded(pollVote.toModel()) } + } + + @Test + fun `on PollVoteCastedFeedEvent for different poll, then do not call pollVoteAdded`() { + val poll = pollResponseData().copy(id = "different-poll") + val pollVote = pollVoteResponseData() + val event = + PollVoteCastedFeedEvent( + createdAt = Date(), + fid = "user:feed-1", + poll = poll, + pollVote = pollVote, + type = "feeds.poll.vote_casted", + ) + + handler.onEvent(event) + + verify(exactly = 0) { state.pollVoteAdded(any()) } + } + @Test fun `on unknown event, then do nothing`() { val unknownEvent =