From 42a3aed7843f7953908fdb351b312d8c88957b8a Mon Sep 17 00:00:00 2001 From: Petar Velikov Date: Wed, 20 Nov 2024 07:39:11 +0100 Subject: [PATCH] [PBE-3749] ThreadList improvements (#5455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [PBE-3749] Update ThreadsApi to match the definition. * [PBE-3749] Register "notification.thread_message_new" EventType. * [PBE-3749] Implement initial state-management for 'Query Threads'. * [PBE-3749] Implement ThreadList component. * [PBE-3749] Implement 'Threads' tab in compose sample app. * [PBE-3749] FIx pagination logic and add a threshold. * [PBE-3749] Add queryThreads preconditions checks. * [PBE-3749] Revert ktlint commit. * [PBE-3749] Remove redundant state update in ThreadListController. * [PBE-3749] Add handling for different ChatEvents. * [PBE-3749] Remove redundant coroutine creation and docs. * [PBE-3749] Fix detekt and spotless. * Revert "[PBE-3749] Implement 'Threads' tab in compose sample app." This reverts commit fdb1ac1a * Revert "Revert "[PBE-3749] Implement 'Threads' tab in compose sample app."" This reverts commit fbc9b3da9f152c6197b40343442bcedfbc781c5f. * Revert "[PBE-3749] Implement 'Threads' tab in compose sample app." This reverts commit fdb1ac1a * [PBE-3749] Hide threads-related public apis. * [PBE-3749] Fix PR remarks related DTOs. * [PBE-3749] Fix wrong composable preview. * [PBE-3749] Use inheritScope to create ThreadListController coroutine scope. * [PBE-3749] Implement ChatClient::markThreadRead operation. * [PBE-3749] Update CHANGELOG for markThreadRead. * [PBE-3749] Fix failing test. * [PBE-3749] Separate `markThreadRead` from `markRead`. * [PBE-3749] Implement unreadThreads logic as part of the GlobalState. * [PBE-3749] Add GlobalState::unreadThreadsCount to CHANGELOG.md. * [PBE-3749] Add marking thread as read handling. * [PBE-3749] Fix incrementing unread count for new thread messages. * [PBE-3749] Add ThreadItem customization options. * [PBE-3749] Make Threads API public. * [PBE-3749] Add Threads tab to compose sample app. * [PBE-3749] Add threads state tests. * [PBE-3749] Suppress LongMethod warning. * [PBE-3749] Add ChatClient::markThreadUnread. * [PBE-3749] Add ChatClient::markThreadUnread to CHANGELOG.md. * [PBE-3749] Add stateless ThreadList. * [PBE-3749] Add ThreadList to CHANGELOG and add docusaurus documentation . * [PBE-3749] Ensure threads state is updated on different client operations. * [PBE-3749] Fix failing tests. * [PBE-3749] Add 'Mark thread as unread' handling. * [PBE-3749] apply spotless. * [PBE-3479] Post merge clean-up. * [PBE-3749] Delete docusaurus docs. * [PBE-3749] Update CHANGELOG.md. * WIP * Refactor ThreadParticipants --------- Co-authored-by: PetarVelikov Co-authored-by: Aleksandar Apostolov Co-authored-by: Jc Miñarro --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 0 .../api/stream-chat-android-client.api | 36 +- .../chat/android/client/ChatClient.kt | 1 - .../client/api2/mapping/EventMapping.kt | 6 + .../client/api2/mapping/ThreadMapping.kt | 25 +- .../client/api2/model/dto/EventDtos.kt | 8 +- .../client/api2/model/dto/ThreadDtos.kt | 45 +- .../chat/android/client/events/ChatEvent.kt | 9 +- .../compose/sample/ui/ChannelsActivity.kt | 88 +- .../sample/ui/component/AppBottomBar.kt | 137 ++++ .../src/main/res/drawable/ic_chats.xml | 27 + .../src/main/res/drawable/ic_threads.xml | 27 + .../src/main/res/values/strings.xml | 4 + .../api/stream-chat-android-compose.api | 49 +- .../android/compose/ui/threads/ThreadItem.kt | 144 ++-- .../android/compose/ui/threads/ThreadList.kt | 66 +- .../compose/ui/threads/UnreadThreadsBanner.kt | 17 +- .../viewmodel/threads/ThreadListViewModel.kt | 8 +- .../threads/ThreadsViewModelFactory.kt | 2 +- .../api/stream-chat-android-core.api | 47 ++ .../getstream/chat/android/models/Thread.kt | 27 +- .../chat/android/models/ThreadInfo.kt | 57 ++ .../chat/android/models/ThreadParticipant.kt | 29 + .../api/stream-chat-android-state.api | 2 + .../internal/EventHandlerSequential.kt | 14 +- .../android/state/extensions/ChatClient.kt | 2 +- .../internal/DeleteMessageListenerState.kt | 2 + .../internal/DeleteReactionListenerState.kt | 5 + .../internal/EditMessageListenerState.kt | 8 +- .../internal/QueryThreadsListenerState.kt | 8 +- .../internal/SendAttachmentListenerState.kt | 1 + .../internal/SendGiphyListenerState.kt | 1 + .../internal/SendMessageListenerState.kt | 2 + .../internal/SendReactionListenerState.kt | 10 + .../internal/ShuffleGiphyListenerState.kt | 1 + .../thread/internal/QueryThreadsStateLogic.kt | 160 ---- .../plugin/logic/internal/LogicRegistry.kt | 6 +- .../internal/QueryThreadsLogic.kt | 80 +- .../internal/QueryThreadsStateLogic.kt | 320 ++++++++ .../internal/QueryThreadsMutableState.kt | 79 +- .../state/sync/internal/SyncManager.kt | 1 + .../DeleteMessageListenerStateTest.kt | 33 +- .../DeleteReactionListenerStateTest.kt | 12 +- .../internal/EditMessageListenerStateTest.kt | 43 +- .../internal/SendMessageListenerStateTest.kt | 20 +- .../internal/ShuffleGiphyListenerStateTest.kt | 7 +- .../internal/QueryThreadsLogicTest.kt | 751 ++++++++++++++++++ .../internal/QueryThreadsStateLogicTest.kt | 576 ++++++++++++++ .../internal/QueryThreadsMutableStateTest.kt | 336 ++++++++ .../SendReactionListenerStateTest.kt | 24 + .../api/stream-chat-android-ui-common.api | 18 + .../messages/list/MessageListController.kt | 55 +- .../common/state/threads/ThreadListState.kt | 2 - 54 files changed, 3116 insertions(+), 323 deletions(-) create mode 100644 gradle/libs.versions.toml create mode 100644 stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/AppBottomBar.kt create mode 100644 stream-chat-android-compose-sample/src/main/res/drawable/ic_chats.xml create mode 100644 stream-chat-android-compose-sample/src/main/res/drawable/ic_threads.xml create mode 100644 stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadInfo.kt create mode 100644 stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadParticipant.kt delete mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/thread/internal/QueryThreadsStateLogic.kt rename stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/{channel/thread => querythreads}/internal/QueryThreadsLogic.kt (65%) create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogic.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogicTest.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogicTest.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableStateTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d573c13a4..b19a3c4aec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,7 @@ ### ✅ Added - Add `MessageListViewModel.flagUser()` and `MessageListViewModel.unflagUser()` methods for flagging/un-flagging users. [#5478](https://github.com/GetStream/stream-chat-android/pull/5478) - Add edge-to-edge support for apps targeting Android 15. [#5469](https://github.com/GetStream/stream-chat-android/pull/5469) +- Add `ThreadList` component for showing the list of threads for the user. [#5455](https://github.com/GetStream/stream-chat-android/pull/5455) ### ⚠️ Changed - 🚨 Breaking change: Replace usage of `RippleTheme` with `StreamRippleConfiguration` for customizing ripples via `ChatTheme`. [#5475](https://github.com/GetStream/stream-chat-android/pull/5475) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 133b8082c95..811c063d18e 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -133,6 +133,7 @@ public final class io/getstream/chat/android/client/ChatClient { public final fun queryMembers (Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;)Lio/getstream/result/call/Call; public static synthetic fun queryMembers$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun queryThreads (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;)Lio/getstream/result/call/Call; + public final fun queryThreadsResult (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;)Lio/getstream/result/call/Call; public final fun queryUsers (Lio/getstream/chat/android/client/api/models/QueryUsersRequest;)Lio/getstream/result/call/Call; public final fun reconnectSocket ()Lio/getstream/result/call/Call; public final fun rejectInvite (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; @@ -1464,7 +1465,8 @@ public final class io/getstream/chat/android/client/events/MessageDeletedEvent : } public final class io/getstream/chat/android/client/events/MessageReadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; @@ -1472,14 +1474,16 @@ public final class io/getstream/chat/android/client/events/MessageReadEvent : io public final fun component5 ()Ljava/lang/String; public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/client/events/MessageReadEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/MessageReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/MessageReadEvent; + public final fun component8 ()Lio/getstream/chat/android/models/ThreadInfo; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;)Lio/getstream/chat/android/client/events/MessageReadEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/MessageReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/MessageReadEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; public fun getRawCreatedAt ()Ljava/lang/String; + public final fun getThread ()Lio/getstream/chat/android/models/ThreadInfo; public fun getType ()Ljava/lang/String; public fun getUser ()Lio/getstream/chat/android/models/User; public fun hashCode ()I @@ -1726,9 +1730,13 @@ public final class io/getstream/chat/android/client/events/NotificationInvitedEv } public final class io/getstream/chat/android/client/events/NotificationMarkReadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/lang/String; + public final fun component11 ()Lio/getstream/chat/android/models/ThreadInfo; + public final fun component12 ()Ljava/lang/Integer; + public final fun component13 ()Ljava/lang/Integer; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -1737,30 +1745,35 @@ public final class io/getstream/chat/android/client/events/NotificationMarkReadE public final fun component7 ()Ljava/lang/String; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;II)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; public fun getRawCreatedAt ()Ljava/lang/String; + public final fun getThread ()Lio/getstream/chat/android/models/ThreadInfo; + public final fun getThreadId ()Ljava/lang/String; public fun getTotalUnreadCount ()I public fun getType ()Ljava/lang/String; public fun getUnreadChannels ()I + public final fun getUnreadThreadMessages ()Ljava/lang/Integer; + public final fun getUnreadThreads ()Ljava/lang/Integer; public fun getUser ()Lio/getstream/chat/android/models/User; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class io/getstream/chat/android/client/events/NotificationMarkUnreadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;I)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I public final fun component11 ()Ljava/lang/String; public final fun component12 ()Ljava/util/Date; public final fun component13 ()Ljava/lang/String; + public final fun component14 ()I public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -1769,8 +1782,8 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea public final fun component7 ()Ljava/lang/String; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;I)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;IILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; @@ -1784,6 +1797,7 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea public fun getType ()Ljava/lang/String; public fun getUnreadChannels ()I public final fun getUnreadMessages ()I + public final fun getUnreadThreads ()I public fun getUser ()Lio/getstream/chat/android/models/User; public fun hashCode ()I public fun toString ()Ljava/lang/String; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index 9715cd64b3e..4ec48817485 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -3267,7 +3267,6 @@ internal constructor( * @param query [QueryThreadsRequest] with query parameters to get matching users. */ @CheckResult - @InternalStreamChatApi public fun queryThreadsResult(query: QueryThreadsRequest): Call { return api.queryThreads(query) .doOnStart(userScope) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index 48e59a3f3ce..99f678ae10b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -355,6 +355,7 @@ private fun MessageReadEventDto.toDomain(currentUserId: UserId?): MessageReadEve cid = cid, channelType = channel_type, channelId = channel_id, + thread = thread?.toDomain(currentUserId), ) } @@ -493,6 +494,10 @@ private fun NotificationMarkReadEventDto.toDomain(currentUserId: UserId?): Notif channelId = channel_id, totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + threadId = thread_id, + thread = thread?.toDomain(currentUserId), + unreadThreads = unread_threads, + unreadThreadMessages = unread_thread_messages, ) } @@ -511,6 +516,7 @@ private fun NotificationMarkUnreadEventDto.toDomain(currentUserId: UserId?): Not lastReadMessageId = last_read_message_id, lastReadMessageAt = last_read_at.date, unreadMessages = unread_messages, + unreadThreads = unread_threads, ) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/ThreadMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/ThreadMapping.kt index 26853bc48a0..e67453bda70 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/ThreadMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/ThreadMapping.kt @@ -17,9 +17,11 @@ package io.getstream.chat.android.client.api2.mapping import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadDto +import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadInfoDto import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadParticipantDto import io.getstream.chat.android.models.Thread -import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.ThreadInfo +import io.getstream.chat.android.models.ThreadParticipant import io.getstream.chat.android.models.UserId internal fun DownstreamThreadDto.toDomain(currentUserId: UserId?): Thread = @@ -43,4 +45,23 @@ internal fun DownstreamThreadDto.toDomain(currentUserId: UserId?): Thread = read = read.orEmpty().map { it.toDomain(currentUserId, last_message_at) }, ) -internal fun DownstreamThreadParticipantDto.toDomain(currentUserId: UserId?): User = user.toDomain(currentUserId) +internal fun DownstreamThreadInfoDto.toDomain(currentUserId: UserId?): ThreadInfo = + ThreadInfo( + activeParticipantCount = active_participant_count ?: 0, + cid = channel_cid, + createdAt = created_at, + createdBy = created_by?.toDomain(currentUserId), + createdByUserId = created_by_user_id, + deletedAt = deleted_at, + lastMessageAt = last_message_at, + parentMessage = parent_message?.toDomain(currentUserId), + parentMessageId = parent_message_id, + participantCount = participant_count ?: 0, + replyCount = reply_count ?: 0, + title = title, + updatedAt = updated_at, + ) + +internal fun DownstreamThreadParticipantDto.toDomain(currentUserId: UserId?): ThreadParticipant = ThreadParticipant( + user = user.toDomain(currentUserId), +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index dc9cdba7979..9814f952588 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -150,6 +150,7 @@ internal data class MessageReadEventDto( val cid: String, val channel_type: String, val channel_id: String, + val thread: DownstreamThreadInfoDto? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -266,6 +267,10 @@ internal data class NotificationMarkReadEventDto( val channel_id: String, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val thread_id: String? = null, + val thread: DownstreamThreadInfoDto? = null, + val unread_threads: Int? = null, + val unread_thread_messages: Int? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -277,11 +282,12 @@ internal data class NotificationMarkUnreadEventDto( val channel_type: String, val channel_id: String, val first_unread_message_id: String, - val last_read_message_id: String, + val last_read_message_id: String?, val last_read_at: ExactDate, val unread_messages: Int, val total_unread_count: Int, val unread_channels: Int, + val unread_threads: Int = 0, ) : ChatEventDto() @JsonClass(generateAdapter = true) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt index a9f67511f1d..7817af68c41 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt @@ -21,6 +21,7 @@ import java.util.Date /** * The DTO for a thread. + * Corresponds to [ThreadStateResponse]. * * @param active_participant_count: The number of active participants. * @param channel_cid: The channel CID. @@ -54,7 +55,7 @@ internal data class DownstreamThreadDto( val thread_participants: List?, val last_message_at: Date, val created_at: Date, - val updated_at: Date?, + val updated_at: Date, val deleted_at: Date?, val title: String, val latest_replies: List, @@ -62,14 +63,52 @@ internal data class DownstreamThreadDto( ) /** - * The DTO for Thread Participant. + * The DTO for a shortened thread info. + * Corresponds to [ThreadResponse]. * + * @param active_participant_count: The number of active participants. * @param channel_cid: The channel CID. + * @param created_at: The date when the thread was created. + * @param created_by: The user who created the thread. + * @param created_by_user_id: The ID of the user who created the thread. + * @param deleted_at: The date when the thread was deleted. + * @param last_message_at: The date of the last message in the thread. + * @param parent_message: The parent message. + * @param parent_message_id: The parent message ID. + * @param participant_count: The number of participants in the thread. + * @param reply_count: The number of replies in the thread. + * @param thread_participants: The participants in the thread. + * @param title: The title of the thread. + * @param updated_at: The date when the thread was updated. + */ +@JsonClass(generateAdapter = true) +internal data class DownstreamThreadInfoDto( + val active_participant_count: Int?, + val channel_cid: String, + val created_at: Date, + val created_by: DownstreamUserDto?, + val created_by_user_id: String, + val deleted_at: Date?, + val last_message_at: Date?, + val parent_message: DownstreamMessageDto?, + val parent_message_id: String, + val participant_count: Int?, + val reply_count: Int?, + val title: String, + val updated_at: Date, +) + +/** + * The DTO for Thread Participant. * + * @param channel_cid: The channel CID. + * @param user: The user as the thread participant. (Note: It is not always delivered, sometimes we only get the ID of + * the user - [user_id]). + * @param user_id: The ID of the user (thread participant). */ @JsonClass(generateAdapter = true) internal data class DownstreamThreadParticipantDto( val channel_cid: String, - val user_id: String, val user: DownstreamUserDto, + val user_id: String, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt index 8e208234435..00d7fe7df67 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.Reaction +import io.getstream.chat.android.models.ThreadInfo import io.getstream.chat.android.models.User import io.getstream.chat.android.models.Vote import io.getstream.result.Error @@ -278,6 +279,7 @@ public data class MessageReadEvent( override val cid: String, override val channelType: String, override val channelId: String, + val thread: ThreadInfo? = null, ) : CidEvent(), UserEvent /** @@ -424,6 +426,10 @@ public data class NotificationMarkReadEvent( override val channelId: String, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, + val threadId: String? = null, + val thread: ThreadInfo? = null, + val unreadThreads: Int? = null, + val unreadThreadMessages: Int? = null, ) : CidEvent(), UserEvent, HasUnreadCounts /** @@ -442,7 +448,8 @@ public data class NotificationMarkUnreadEvent( val unreadMessages: Int, val firstUnreadMessageId: String, val lastReadMessageAt: Date, - val lastReadMessageId: String, + val lastReadMessageId: String?, + val unreadThreads: Int = 0, ) : CidEvent(), UserEvent, HasUnreadCounts /** diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/ChannelsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/ChannelsActivity.kt index 3e7a84f1517..fbdc17cf2c6 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/ChannelsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/ChannelsActivity.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,6 +50,8 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.compose.sample.ChatApp import io.getstream.chat.android.compose.sample.ChatHelper import io.getstream.chat.android.compose.sample.R +import io.getstream.chat.android.compose.sample.ui.component.AppBottomBar +import io.getstream.chat.android.compose.sample.ui.component.AppBottomBarOption import io.getstream.chat.android.compose.sample.ui.login.UserLoginActivity import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.state.channels.list.SearchQuery @@ -60,25 +63,31 @@ import io.getstream.chat.android.compose.ui.channels.list.ChannelItem import io.getstream.chat.android.compose.ui.channels.list.ChannelList import io.getstream.chat.android.compose.ui.components.SearchInput import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.threads.ThreadList import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel import io.getstream.chat.android.compose.viewmodel.channels.ChannelViewModelFactory +import io.getstream.chat.android.compose.viewmodel.threads.ThreadListViewModel +import io.getstream.chat.android.compose.viewmodel.threads.ThreadsViewModelFactory import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.state.extensions.globalState import kotlinx.coroutines.launch class ChannelsActivity : BaseConnectedActivity() { - private val factory by lazy { + private val listViewModelFactory by lazy { ChannelViewModelFactory( ChatClient.instance(), QuerySortByField.descByName("last_updated"), null, ) } + private val threadsViewModelFactory by lazy { ThreadsViewModelFactory() } - private val listViewModel: ChannelListViewModel by viewModels { factory } + private val listViewModel: ChannelListViewModel by viewModels { listViewModelFactory } + private val threadsViewModel: ThreadListViewModel by viewModels { threadsViewModelFactory } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -91,34 +100,75 @@ class ChannelsActivity : BaseConnectedActivity() { * or build a custom component yourself, like [MyCustomUi]. */ setContent { + var selectedTab by rememberSaveable { mutableStateOf(AppBottomBarOption.CHATS) } + val globalState = ChatClient.instance().globalState + val unreadChannelsCount by globalState.channelUnreadCount.collectAsState() + val unreadThreadsCount by globalState.unreadThreadsCount.collectAsState() + ChatTheme( dateFormatter = ChatApp.dateFormatter, autoTranslationEnabled = ChatApp.autoTranslationEnabled, allowUIAutomationTest = true, ) { - ChannelsScreen( - viewModelFactory = factory, - title = stringResource(id = R.string.app_name), - isShowingHeader = true, - searchMode = SearchMode.Messages, - onChannelClick = ::openMessages, - onSearchMessageItemClick = ::openMessages, - onBackPressed = ::finish, - onHeaderAvatarClick = { - listViewModel.viewModelScope.launch { - ChatHelper.disconnectUser() - openUserLogin() - } + Scaffold( + bottomBar = { + AppBottomBar( + unreadChannelsCount = unreadChannelsCount, + unreadThreadsCount = unreadThreadsCount, + selectedOption = selectedTab, + onOptionSelected = { selectedTab = it }, + ) }, - onHeaderActionClick = { - listViewModel.refresh() + content = { _ -> + when (selectedTab) { + AppBottomBarOption.CHATS -> ChannelsContent() + AppBottomBarOption.THREADS -> ThreadsContent() + } }, ) + } + } + } + + @Composable + private fun ChannelsContent() { + ChannelsScreen( + viewModelFactory = listViewModelFactory, + title = stringResource(id = R.string.app_name), + isShowingHeader = true, + searchMode = SearchMode.Messages, + onChannelClick = ::openMessages, + onSearchMessageItemClick = ::openMessages, + onBackPressed = ::finish, + onHeaderAvatarClick = { + listViewModel.viewModelScope.launch { + ChatHelper.disconnectUser() + openUserLogin() + } + }, + onHeaderActionClick = { + listViewModel.refresh() + }, + ) // MyCustomUiSimplified() // MyCustomUi() - } - } + } + + @Composable + private fun ThreadsContent() { + ThreadList( + viewModel = threadsViewModel, + modifier = Modifier + .fillMaxSize() + .background(ChatTheme.colors.appBackground), + onThreadClick = { thread -> + val lastMessageInThread = thread.latestReplies.lastOrNull() + if (lastMessageInThread != null) { + openMessages(lastMessageInThread) + } + }, + ) } /** diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/AppBottomBar.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/AppBottomBar.kt new file mode 100644 index 00000000000..df41cda5718 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/AppBottomBar.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2014-2024 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-chat-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.chat.android.compose.sample.ui.component + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.getstream.chat.android.compose.sample.R +import io.getstream.chat.android.compose.ui.components.channels.UnreadCountIndicator +import io.getstream.chat.android.compose.ui.theme.ChatTheme + +/** + * Renders the default app bottom bar for switching between chats/threads. + * + * @param unreadChannelsCount The number of unread channels. + * @param unreadThreadsCount The number of unread threads. + * @param selectedOption The currently selected [AppBottomBarOption]. + * @param onOptionSelected Action when invoked when the user clicks on an [AppBottomBarOption]. + */ +@Composable +fun AppBottomBar( + unreadChannelsCount: Int, + unreadThreadsCount: Int, + selectedOption: AppBottomBarOption, + onOptionSelected: (AppBottomBarOption) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(ChatTheme.colors.barsBackground), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + AppBottomBarOptionTile( + icon = R.drawable.ic_chats, + text = R.string.app_bottom_bar_chats, + isSelected = selectedOption == AppBottomBarOption.CHATS, + onClick = { onOptionSelected(AppBottomBarOption.CHATS) }, + decorationBadge = { + if (unreadChannelsCount > 0) { + UnreadCountIndicator(unreadChannelsCount) + } + }, + ) + AppBottomBarOptionTile( + icon = R.drawable.ic_threads, + text = R.string.app_bottom_bar_threads, + isSelected = selectedOption == AppBottomBarOption.THREADS, + onClick = { onOptionSelected(AppBottomBarOption.THREADS) }, + decorationBadge = { + if (unreadThreadsCount > 0) { + UnreadCountIndicator(unreadThreadsCount) + } + }, + ) + } +} + +/** + * Defines the possible options of the app bottom bar. + */ +enum class AppBottomBarOption { + CHATS, + THREADS, +} + +@Composable +private fun AppBottomBarOptionTile( + @DrawableRes icon: Int, + @StringRes text: Int, + isSelected: Boolean, + onClick: () -> Unit, + decorationBadge: (@Composable () -> Unit)? = null, +) { + val contentColor = if (isSelected) ChatTheme.colors.textHighEmphasis else ChatTheme.colors.textLowEmphasis + Box( + modifier = Modifier + .clickable { onClick() } + .padding(4.dp), + ) { + // Content + Column( + modifier = Modifier.padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = contentColor, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(text), + fontSize = 12.sp, + color = contentColor, + ) + } + // Decoration badge + decorationBadge?.let { + Box(modifier = Modifier.align(Alignment.TopEnd)) { + decorationBadge() + } + } + } +} diff --git a/stream-chat-android-compose-sample/src/main/res/drawable/ic_chats.xml b/stream-chat-android-compose-sample/src/main/res/drawable/ic_chats.xml new file mode 100644 index 00000000000..da4646df7e8 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/res/drawable/ic_chats.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-compose-sample/src/main/res/drawable/ic_threads.xml b/stream-chat-android-compose-sample/src/main/res/drawable/ic_threads.xml new file mode 100644 index 00000000000..0a6c48ebee4 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/res/drawable/ic_threads.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index 511ab19e7e0..6d2b4a92441 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -45,4 +45,8 @@ Owner Member Moderator + + + Chats + Threads \ No newline at end of file diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index bbfa11a657c..14e666078c4 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -3155,19 +3155,25 @@ public final class io/getstream/chat/android/compose/ui/theme/messages/list/Quot public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadItemKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadItemKt; - public static field lambda-1 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function2; - public static field lambda-3 Lkotlin/jvm/functions/Function2; + public static field lambda-1 Lkotlin/jvm/functions/Function4; + public static field lambda-2 Lkotlin/jvm/functions/Function4; + public static field lambda-3 Lkotlin/jvm/functions/Function3; public static field lambda-4 Lkotlin/jvm/functions/Function2; public static field lambda-5 Lkotlin/jvm/functions/Function2; public static field lambda-6 Lkotlin/jvm/functions/Function2; + public static field lambda-7 Lkotlin/jvm/functions/Function2; + public static field lambda-8 Lkotlin/jvm/functions/Function2; + public static field lambda-9 Lkotlin/jvm/functions/Function2; public fun ()V - public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-7$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-8$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-9$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListKt { @@ -3179,6 +3185,7 @@ public final class io/getstream/chat/android/compose/ui/threads/ComposableSingle public static field lambda-5 Lkotlin/jvm/functions/Function2; public static field lambda-6 Lkotlin/jvm/functions/Function2; public static field lambda-7 Lkotlin/jvm/functions/Function2; + public static field lambda-8 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; @@ -3187,6 +3194,7 @@ public final class io/getstream/chat/android/compose/ui/threads/ComposableSingle public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-7$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-8$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$UnreadThreadsBannerKt { @@ -3200,6 +3208,19 @@ public final class io/getstream/chat/android/compose/ui/threads/ComposableSingle public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } +public final class io/getstream/chat/android/compose/ui/threads/ThreadItemKt { + public static final fun ThreadItem (Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/getstream/chat/android/compose/ui/threads/ThreadListKt { + public static final fun ThreadList (Lio/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V + public static final fun ThreadList (Lio/getstream/chat/android/ui/common/state/threads/ThreadListState;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V +} + +public final class io/getstream/chat/android/compose/ui/threads/UnreadThreadsBannerKt { + public static final fun UnreadThreadsBanner (ILandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + public final class io/getstream/chat/android/compose/ui/util/ChannelUtilsKt { public static final fun getLastMessage (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/models/Message; public static final fun getMembersStatusText (Lio/getstream/chat/android/models/Channel;Landroid/content/Context;Lio/getstream/chat/android/models/User;)Ljava/lang/String; @@ -3643,3 +3664,19 @@ public final class io/getstream/chat/android/compose/viewmodel/pinned/PinnedMess public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } +public final class io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel : androidx/lifecycle/ViewModel { + public static final field $stable I + public fun (Lio/getstream/chat/android/ui/common/feature/threads/ThreadListController;)V + public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; + public final fun load ()V + public final fun loadNextPage ()V +} + +public final class io/getstream/chat/android/compose/viewmodel/threads/ThreadsViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { + public static final field $stable I + public fun ()V + public fun (III)V + public synthetic fun (IIIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; +} + diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadItem.kt index b7cd77f015b..15046f0d9cc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadItem.kt @@ -22,9 +22,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -56,6 +56,7 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.ThreadParticipant import io.getstream.chat.android.models.User import java.util.Date @@ -67,14 +68,34 @@ import java.util.Date * @param currentUser The currently logged [User], used for formatting the message in the thread preview. * @param onThreadClick Action invoked when the user clicks on the item. * @param modifier [Modifier] instance for general styling. + * @param titleContent Composable rendering the title of the thread item. Defaults to a 'thread' icon and the name of + * the channel in which the thread resides. + * @param replyToContent Composable rendering the preview of the thread parent message. Defaults to a preview of the + * parent message with a 'replied to:' prefix. + * @param unreadCountContent Composable rendering the badge indicator of unread replies in a thread. Defaults to a red + * circular badge with the unread count inside. + * @param latestReplyContent Composable rendering the preview of the latest reply in the thread. Defaults to a content + * composed of the reply author image, reply author name, preview of the reply text and a timestamp. */ @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun ThreadItem( +public fun ThreadItem( thread: Thread, currentUser: User?, onThreadClick: (Thread) -> Unit, modifier: Modifier = Modifier, + titleContent: @Composable (Channel) -> Unit = { channel -> + DefaultThreadTitle(channel, currentUser) + }, + replyToContent: @Composable RowScope.(parentMessage: Message) -> Unit = { parentMessage -> + DefaultReplyToContent(parentMessage) + }, + unreadCountContent: @Composable RowScope.(unreadCount: Int) -> Unit = { unreadCount -> + DefaultUnreadCountContent(unreadCount) + }, + latestReplyContent: @Composable (reply: Message) -> Unit = { reply -> + DefaultLatestReplyContent(reply) + }, ) { Column( modifier = modifier @@ -87,28 +108,35 @@ internal fun ThreadItem( .padding(horizontal = 8.dp, vertical = 14.dp), ) { thread.channel?.let { channel -> - ThreadTitle(channel, currentUser) + titleContent(channel) } val unreadCount = unreadCountForUser(thread, currentUser) - ParentMessageContent( - parentMessage = thread.parentMessage, - unreadCount = unreadCount, - ) + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + replyToContent(thread.parentMessage) + unreadCountContent(unreadCount) + } thread.latestReplies.lastOrNull()?.let { reply -> - Spacer(modifier = Modifier.height(8.dp)) - LatestReplyContent(reply = reply) + latestReplyContent(reply) } } } +/** + * Default representation of the thread title. + * + * @param channel The [Channel] in which the thread resides. + * @param currentUser The currently logged [User], used for formatting the message in the thread preview. + */ @Composable -private fun ThreadTitle( +internal fun DefaultThreadTitle( channel: Channel, currentUser: User?, - modifier: Modifier = Modifier, ) { val title = ChatTheme.channelNameFormatter.formatChannelName(channel, currentUser) - Row(modifier = modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth()) { Icon( painter = painterResource(id = R.drawable.stream_compose_ic_thread), contentDescription = null, @@ -125,38 +153,52 @@ private fun ThreadTitle( } } +/** + * Default representation of the parent message preview in a thread. + * + * @param parentMessage The parent message of the thread. + */ @Composable -private fun ParentMessageContent( - parentMessage: Message, - unreadCount: Int, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - val prefix = stringResource(id = R.string.stream_compose_replied_to) - val text = formatMessage(parentMessage) - Text( - modifier = Modifier.weight(1f), - text = "$prefix$text", - fontSize = 12.sp, - color = ChatTheme.colors.textLowEmphasis, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = ChatTheme.typography.body, +internal fun RowScope.DefaultReplyToContent(parentMessage: Message) { + val prefix = stringResource(id = R.string.stream_compose_replied_to) + val text = formatMessage(parentMessage) + Text( + modifier = Modifier.weight(1f), + text = "$prefix$text", + fontSize = 12.sp, + color = ChatTheme.colors.textLowEmphasis, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ChatTheme.typography.body, + ) +} + +/** + * Default representation of the unread count badge. + * + * @param unreadCount The number of unread thread replies. + */ +@Composable +internal fun RowScope.DefaultUnreadCountContent(unreadCount: Int) { + if (unreadCount > 0) { + UnreadCountIndicator( + unreadCount = unreadCount, ) - if (unreadCount > 0) { - UnreadCountIndicator( - unreadCount = unreadCount, - ) - } } } +/** + * Default representation of the latest reply content in a thread. + * + * @param reply The latest reply [Message] in the thread. + */ @Composable -private fun LatestReplyContent(reply: Message) { - Row(modifier = Modifier.fillMaxWidth()) { +internal fun DefaultLatestReplyContent(reply: Message) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) { UserAvatar( modifier = Modifier.size(ChatTheme.dimens.channelAvatarSize), user = reply.user, @@ -234,10 +276,13 @@ private fun ThreadItemPreview() { createdBy = user2, replyCount = 3, participantCount = 2, - threadParticipants = listOf(user1, user2), + threadParticipants = listOf( + ThreadParticipant(user1), + ThreadParticipant(user2), + ), lastMessageAt = Date(), createdAt = Date(), - updatedAt = null, + updatedAt = Date(), deletedAt = null, title = "Group ride preparation and discussion", latestReplies = listOf( @@ -264,10 +309,10 @@ private fun ThreadItemPreview() { @Composable @Preview -private fun ThreadTitlePreview() { +private fun DefaultThreadTitlePreview() { ChatTheme { Surface { - ThreadTitle( + DefaultThreadTitle( channel = Channel( id = "messaging:123", type = "messaging", @@ -279,18 +324,27 @@ private fun ThreadTitlePreview() { } } +@Composable +@Preview +private fun DefaultUnreadCountContentPreview() { + ChatTheme { + Row { + DefaultUnreadCountContent(unreadCount = 17) + } + } +} + @Composable @Preview private fun ThreadParentMessageContentPreview() { ChatTheme { - Surface { + Row { val parentMessage = Message( id = "message1", cid = "messaging:123", text = "Hey everyone, who's up for a group ride this Saturday morning?", ) - val unreadCount = 2 - ParentMessageContent(parentMessage, unreadCount) + DefaultReplyToContent(parentMessage) } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadList.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadList.kt index 86738577ee3..46f280b87ba 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadList.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadList.kt @@ -54,6 +54,7 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.viewmodel.threads.ThreadListViewModel import io.getstream.chat.android.models.Thread import io.getstream.chat.android.models.User +import io.getstream.chat.android.ui.common.state.threads.ThreadListState /** * Composable rendering a paginated list of threads. @@ -79,7 +80,7 @@ import io.getstream.chat.android.models.User * Override this to provide a custom loading component shown during the loading of more items. */ @Composable -internal fun ThreadList( +public fun ThreadList( viewModel: ThreadListViewModel, modifier: Modifier = Modifier, currentUser: User? = ChatClient.instance().getCurrentUser(), @@ -103,6 +104,67 @@ internal fun ThreadList( }, ) { val state by viewModel.state.collectAsStateWithLifecycle() + ThreadList( + state = state, + modifier = modifier, + currentUser = currentUser, + onUnreadThreadsBannerClick = onUnreadThreadsBannerClick, + onThreadClick = onThreadClick, + onLoadMore = onLoadMore, + unreadThreadsBanner = unreadThreadsBanner, + itemContent = itemContent, + emptyContent = emptyContent, + loadingContent = loadingContent, + loadingMoreContent = loadingMoreContent, + ) +} + +/** + * Composable rendering a paginated list of threads. + * Optionally, it renders a banner informing about new threads/thread messages outside of the loaded pages of threads. + * + * @param state The [ThreadListState] holding the current thread list state. + * @param modifier [Modifier] instance for general styling. + * @param currentUser The currently logged [User], used for formatting the message in the thread preview. + * @param onUnreadThreadsBannerClick Action invoked when the user clicks on the "Unread threads" banner. + * @param onThreadClick Action invoked when the usr clicks on a thread item in the list. + * @param onLoadMore Action invoked when the current thread page was scrolled to the end, and a next page should be + * loaded. + * @param unreadThreadsBanner Composable rendering the "Unread threads" banner on the top of the list. Override it to + * provide a custom component to be rendered for displaying the number of new unread threads. + * @param itemContent Composable rendering each [Thread] item in the list. Override this to provide a custom component + * for rendering the items. + * @param emptyContent Composable shown when there are no threads to display. Override this to provide custom component + * for rendering the empty state. + * @param loadingContent Composable shown during the initial loading of the threads. Override this to provide a custom + * initial loading state. + * @param loadingMoreContent Composable shown at the bottom of the list during the loading of more threads (pagination). + * Override this to provide a custom loading component shown during the loading of more items. + */ +@Composable +public fun ThreadList( + state: ThreadListState, + modifier: Modifier = Modifier, + currentUser: User? = ChatClient.instance().getCurrentUser(), + onUnreadThreadsBannerClick: () -> Unit, + onThreadClick: (Thread) -> Unit, + onLoadMore: () -> Unit, + unreadThreadsBanner: @Composable (Int) -> Unit = { + DefaultUnreadThreadsBanner(it, onClick = onUnreadThreadsBannerClick) + }, + itemContent: @Composable (Thread) -> Unit = { + DefaultThreadItem(it, currentUser, onThreadClick) + }, + emptyContent: @Composable () -> Unit = { + DefaultThreadListEmptyContent(modifier) + }, + loadingContent: @Composable () -> Unit = { + DefaultThreadListLoadingContent(modifier) + }, + loadingMoreContent: @Composable () -> Unit = { + DefaultThreadListLoadingMoreContent() + }, +) { Scaffold( topBar = { unreadThreadsBanner(state.unseenThreadsCount) @@ -222,7 +284,7 @@ internal fun DefaultThreadItem( @Composable internal fun DefaultThreadListEmptyContent(modifier: Modifier = Modifier) { Column( - modifier = modifier.background(ChatTheme.colors.appBackground), + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/UnreadThreadsBanner.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/UnreadThreadsBanner.kt index 7ab8d722d34..af6d0b2b8fc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/UnreadThreadsBanner.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/UnreadThreadsBanner.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.ui.threads import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -50,7 +51,7 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme * @param onClick Action invoked when the user clicks on the banner. */ @Composable -internal fun UnreadThreadsBanner( +public fun UnreadThreadsBanner( unreadThreads: Int, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, @@ -102,10 +103,16 @@ internal fun UnreadThreadsBanner( private fun UnreadThreadsBannerPreview() { ChatTheme { Surface { - UnreadThreadsBanner( - unreadThreads = 17, - modifier = Modifier.padding(8.dp), - ) + Column { + UnreadThreadsBanner( + unreadThreads = 17, + modifier = Modifier.padding(8.dp), + ) + UnreadThreadsBanner( + unreadThreads = 1, + modifier = Modifier.padding(8.dp), + ) + } } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt index 175a8d0ab59..dd1eadbe1d3 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt @@ -27,18 +27,18 @@ import kotlinx.coroutines.flow.StateFlow * @param controller The [ThreadListController] handling the business logic and the state management for the threads * list. */ -internal class ThreadListViewModel(private val controller: ThreadListController) : ViewModel() { +public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel() { /** * The current thread list state. */ - val state: StateFlow = controller.state + public val state: StateFlow = controller.state /** * Loads the initial data when requested. * Overrides all previously retrieved data. */ - fun load() { + public fun load() { controller.load() } @@ -47,7 +47,7 @@ internal class ThreadListViewModel(private val controller: ThreadListController) * * Does nothing if the end of the list has already been reached or loading is already in progress. */ - fun loadNextPage() { + public fun loadNextPage() { controller.loadNextPage() } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadsViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadsViewModelFactory.kt index 6b6faaf1449..3b866c1ffac 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadsViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadsViewModelFactory.kt @@ -29,7 +29,7 @@ import io.getstream.chat.android.ui.common.feature.threads.ThreadListController * @param threadReplyLimit The number of replies per thread to load. * @param threadParticipantLimit The number of participants per thread to load. */ -internal class ThreadsViewModelFactory( +public class ThreadsViewModelFactory( private val threadLimit: Int = ThreadListController.DEFAULT_THREAD_LIMIT, private val threadReplyLimit: Int = ThreadListController.DEFAULT_THREAD_REPLY_LIMIT, private val threadParticipantLimit: Int = ThreadListController.DEFAULT_THREAD_PARTICIPANT_LIMIT, diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 40d6e2f4978..5f6aebe2e69 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1731,6 +1731,53 @@ public final class io/getstream/chat/android/models/Thread { public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/models/ThreadInfo { + public fun (ILjava/lang/String;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/Message;Ljava/lang/String;IILjava/lang/String;Ljava/util/Date;)V + public final fun component1 ()I + public final fun component10 ()I + public final fun component11 ()I + public final fun component12 ()Ljava/lang/String; + public final fun component13 ()Ljava/util/Date; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/Date; + public final fun component4 ()Lio/getstream/chat/android/models/User; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/util/Date; + public final fun component7 ()Ljava/util/Date; + public final fun component8 ()Lio/getstream/chat/android/models/Message; + public final fun component9 ()Ljava/lang/String; + public final fun copy (ILjava/lang/String;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/Message;Ljava/lang/String;IILjava/lang/String;Ljava/util/Date;)Lio/getstream/chat/android/models/ThreadInfo; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/ThreadInfo;ILjava/lang/String;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/Message;Ljava/lang/String;IILjava/lang/String;Ljava/util/Date;ILjava/lang/Object;)Lio/getstream/chat/android/models/ThreadInfo; + public fun equals (Ljava/lang/Object;)Z + public final fun getActiveParticipantCount ()I + public final fun getCid ()Ljava/lang/String; + public final fun getCreatedAt ()Ljava/util/Date; + public final fun getCreatedBy ()Lio/getstream/chat/android/models/User; + public final fun getCreatedByUserId ()Ljava/lang/String; + public final fun getDeletedAt ()Ljava/util/Date; + public final fun getLastMessageAt ()Ljava/util/Date; + public final fun getParentMessage ()Lio/getstream/chat/android/models/Message; + public final fun getParentMessageId ()Ljava/lang/String; + public final fun getParticipantCount ()I + public final fun getReplyCount ()I + public final fun getTitle ()Ljava/lang/String; + public final fun getUpdatedAt ()Ljava/util/Date; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/models/ThreadParticipant : io/getstream/chat/android/models/UserEntity { + public fun (Lio/getstream/chat/android/models/User;)V + public final fun component1 ()Lio/getstream/chat/android/models/User; + public final fun copy (Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/models/ThreadParticipant; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/ThreadParticipant;Lio/getstream/chat/android/models/User;ILjava/lang/Object;)Lio/getstream/chat/android/models/ThreadParticipant; + public fun equals (Ljava/lang/Object;)Z + public fun getUser ()Lio/getstream/chat/android/models/User; + public fun getUserId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/models/TimeDuration : java/lang/Comparable { public static final field Companion Lio/getstream/chat/android/models/TimeDuration$Companion; public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Thread.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Thread.kt index 7fc2511cd4a..a72d309fde2 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Thread.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Thread.kt @@ -19,6 +19,29 @@ package io.getstream.chat.android.models import androidx.compose.runtime.Immutable import java.util.Date +/** + * Domain model for a thread. Holds all information related to a thread. + * + * @param activeParticipantCount The number of active participants in the thread. + * @param cid Id of the channel in which the thread resides. + * @param channel The [Channel] object holding info about the channel if which the thread resides. + * @param parentMessageId The ID of the parent message of the thread. + * @param parentMessage The parent message of the thread. (Note: This object is not always delivered, sometimes we only + * receive the ID of the parent message - [parentMessageId]). + * @param createdByUserId The ID of the [User] which created the thread. + * @param createdBy The [User] which created the thread. (Note: This object is not always delivered, sometimes we only + * receive the ID of the user - [createdByUserId]). + * @param replyCount The number of replies in the thread. + * @param participantCount The number of participants in the thread. + * @param threadParticipants The list of participants in the thread. + * @param lastMessageAt Date of the last message in the thread. + * @param createdAt Date when the thread was created. + * @param updatedAt Date of the most recent update of the thread. + * @param deletedAt Date when the thread was deleted (null if the thread is not deleted). + * @param title The title of the thread. + * @param latestReplies The list of latest replies in the thread. + * @param read Information about the read status for the participants in the thread. + */ @Immutable public data class Thread( val activeParticipantCount: Int, @@ -30,10 +53,10 @@ public data class Thread( val createdBy: User?, val replyCount: Int, val participantCount: Int, - val threadParticipants: List, + val threadParticipants: List, val lastMessageAt: Date, val createdAt: Date, - val updatedAt: Date?, + val updatedAt: Date, val deletedAt: Date?, val title: String, val latestReplies: List, diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadInfo.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadInfo.kt new file mode 100644 index 00000000000..25a9c8566e9 --- /dev/null +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadInfo.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2014-2024 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-chat-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.chat.android.models + +import androidx.compose.runtime.Immutable +import java.util.Date + +/** + * Domain model for thread info. Holds partial information related to a thread. + * + * @param activeParticipantCount Number of active participants in the thread. + * @param cid Id of the channel in which the thread resides. + * @param createdAt Date when the thread was created. + * @param createdBy The [User] which created the thread. (Note: This object is not always delivered, sometimes we only + * receive the ID of the user - [createdByUserId]). + * @param createdByUserId The ID of the [User] which created the thread. + * @param deletedAt Date when the thread was deleted (null if the thread is not deleted). + * @param lastMessageAt Date of the last message in the thread. + * @param parentMessage The parent message of the thread. (Note: This object is not always delivered, sometimes we only + * receive the ID of the parent message - [parentMessageId]). + * @param parentMessageId The ID of the parent message of the thread. + * @param participantCount The number of participants in the thread. + * @param replyCount The number of replies in the thread. + * @param threadParticipants The list of participants in the thread. + * @param title The title of the thread. + * @param updatedAt Date of the most recent update of the thread. + */ +@Immutable +public data class ThreadInfo( + val activeParticipantCount: Int, + val cid: String, + val createdAt: Date, + val createdBy: User?, + val createdByUserId: String, + val deletedAt: Date?, + val lastMessageAt: Date?, + val parentMessage: Message?, + val parentMessageId: String, + val participantCount: Int, + val replyCount: Int, + val title: String, + val updatedAt: Date, +) diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadParticipant.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadParticipant.kt new file mode 100644 index 00000000000..313327d9986 --- /dev/null +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadParticipant.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2014-2024 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-chat-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.chat.android.models + +import androidx.compose.runtime.Immutable + +/** + * Model holding info about a thread participant. + * + * @param user The [User] as a thread participant (not always delivered). + */ +@Immutable +public data class ThreadParticipant( + override val user: User, +) : UserEntity diff --git a/stream-chat-android-state/api/stream-chat-android-state.api b/stream-chat-android-state/api/stream-chat-android-state.api index 5fc90f7136a..100336a80e4 100644 --- a/stream-chat-android-state/api/stream-chat-android-state.api +++ b/stream-chat-android-state/api/stream-chat-android-state.api @@ -87,6 +87,8 @@ public final class io/getstream/chat/android/state/extensions/ChatClientExtensio public static final fun queryChannelsAsState (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;)Lkotlinx/coroutines/flow/StateFlow; public static final fun queryChannelsAsState (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;Lkotlinx/coroutines/CoroutineScope;)Lkotlinx/coroutines/flow/StateFlow; public static synthetic fun queryChannelsAsState$default (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;Lkotlinx/coroutines/CoroutineScope;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; + public static final fun queryThreadsAsState (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlinx/coroutines/CoroutineScope;)Lkotlinx/coroutines/flow/StateFlow; + public static synthetic fun queryThreadsAsState$default (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlinx/coroutines/CoroutineScope;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; public static final fun setMessageForReply (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lio/getstream/chat/android/models/Message;)Lio/getstream/result/call/Call; public static final fun watchChannelAsState (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;I)Lkotlinx/coroutines/flow/StateFlow; public static final fun watchChannelAsState (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILkotlinx/coroutines/CoroutineScope;)Lkotlinx/coroutines/flow/StateFlow; diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt index a33b67b3a0e..3872d583dac 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt @@ -56,6 +56,7 @@ import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent import io.getstream.chat.android.client.events.NotificationMessageNewEvent import io.getstream.chat.android.client.events.NotificationMutesUpdatedEvent import io.getstream.chat.android.client.events.NotificationRemovedFromChannelEvent +import io.getstream.chat.android.client.events.NotificationThreadMessageNewEvent import io.getstream.chat.android.client.events.PollClosedEvent import io.getstream.chat.android.client.events.PollDeletedEvent import io.getstream.chat.android.client.events.PollUpdatedEvent @@ -296,6 +297,10 @@ internal class EventHandlerSequential( unreadThreadsCount = user.unreadThreads } + val modifyUnreadThreadsCount = { newValue: Int? -> + unreadThreadsCount = newValue ?: unreadThreadsCount + } + val hasReadEventsCapability = parameterizedLazy { cid -> // can we somehow get rid of repos usage here? repos.hasReadEventsCapability(cid) @@ -324,14 +329,21 @@ internal class EventHandlerSequential( modifyValuesFromEvent(event) } } + is NotificationThreadMessageNewEvent -> if (batchEvent.isFromSocketConnection) { + if (hasReadEventsCapability(event.cid)) { + modifyUnreadThreadsCount(event.unreadThreads) + } + } is NotificationMarkReadEvent -> if (batchEvent.isFromSocketConnection) { if (hasReadEventsCapability(event.cid)) { modifyValuesFromEvent(event) + modifyUnreadThreadsCount(event.unreadThreads) } } is NotificationMarkUnreadEvent -> if (batchEvent.isFromSocketConnection) { if (hasReadEventsCapability(event.cid)) { modifyValuesFromEvent(event) + modifyUnreadThreadsCount(event.unreadThreads) } } is NewMessageEvent -> if (batchEvent.isFromSocketConnection) { @@ -426,7 +438,7 @@ internal class EventHandlerSequential( private fun updateQueryThreadsState(batchEvent: BatchEvent) { logger.v { "[updateQueryThreadsState] batchEvent.size: ${batchEvent.size}" } - logicRegistry.queryThreads().handleEvents(batchEvent.sortedEvents) + logicRegistry.threads().handleEvents(batchEvent.sortedEvents) } private fun updateThreadState(batchEvent: BatchEvent) { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index e1aa3c008d6..def74b0d8e8 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -154,7 +154,6 @@ public fun ChatClient.watchChannelAsState( * @param request The [QueryThreadsRequest] used to perform the query threads operation. * @return A [StateFlow] emitting changes in the [QueryThreadsState]. */ -@InternalStreamChatApi public fun ChatClient.queryThreadsAsState( request: QueryThreadsRequest, coroutineScope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), @@ -371,6 +370,7 @@ public fun ChatClient.cancelEphemeralMessage(message: Message): Call { try { require(message.isEphemeral()) { "Only ephemeral message can be canceled" } logic.channelFromMessage(message)?.deleteMessage(message) + logic.threads().deleteMessage(message) logic.threadFromMessage(message)?.removeLocalMessage(message) repositoryFacade.deleteChannelMessage(message) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteMessageListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteMessageListenerState.kt index e29bc31b619..8a60d047d59 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteMessageListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteMessageListenerState.kt @@ -119,11 +119,13 @@ internal class DeleteMessageListenerState( private fun updateMessage(message: Message) { logic.channelFromMessage(message)?.upsertMessage(message) + logic.threads().upsertMessage(message) logic.threadFromMessage(message)?.upsertMessage(message) } private fun deleteMessage(message: Message) { logic.channelFromMessage(message)?.deleteMessage(message) + logic.threads().deleteMessage(message) logic.threadFromMessage(message)?.deleteMessage(message) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteReactionListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteReactionListenerState.kt index efa8617354f..fabe4fd4c3d 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteReactionListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteReactionListenerState.kt @@ -72,6 +72,11 @@ internal class DeleteReactionListenerState( ?.removeMyReaction(reaction = reaction) cachedChannelMessage?.let(channelLogic::upsertMessage) + val threadsLogic = logic.threads() + val cachedThreadsMessage = threadsLogic.getMessage(reaction.messageId) + ?.removeMyReaction(reaction = reaction) + cachedThreadsMessage?.let(threadsLogic::upsertMessage) + val threadLogic = logic.threadFromMessageId(messageId) val cachedThreadMessage = threadLogic?.getMessage(reaction.messageId) ?.removeMyReaction(reaction = reaction) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/EditMessageListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/EditMessageListenerState.kt index d1b91c70569..4a4c1e59f6a 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/EditMessageListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/EditMessageListenerState.kt @@ -43,10 +43,11 @@ internal class EditMessageListenerState( */ override suspend fun onMessageEditRequest(message: Message) { val isOnline = clientState.isNetworkAvailable - val messagesToEdit = message.updateMessageOnlineState(isOnline) + val messageToEdit = message.updateMessageOnlineState(isOnline) - logic.channelFromMessage(messagesToEdit)?.stateLogic()?.upsertMessage(messagesToEdit) - logic.threadFromMessage(messagesToEdit)?.stateLogic()?.upsertMessage(messagesToEdit) + logic.channelFromMessage(messageToEdit)?.stateLogic()?.upsertMessage(messageToEdit) + logic.threads().upsertMessage(messageToEdit) + logic.threadFromMessage(messageToEdit)?.stateLogic()?.upsertMessage(messageToEdit) } /** @@ -62,6 +63,7 @@ internal class EditMessageListenerState( } logic.channelFromMessage(parsedMessage)?.stateLogic()?.upsertMessage(parsedMessage) + logic.threads().upsertMessage(parsedMessage) logic.threadFromMessage(parsedMessage)?.stateLogic()?.upsertMessage(parsedMessage) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryThreadsListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryThreadsListenerState.kt index b6f469b6b49..3ad96b2101b 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryThreadsListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryThreadsListenerState.kt @@ -24,21 +24,21 @@ import io.getstream.result.Result /** * [QueryThreadsListener] implementation for the [StatePlugin]. - * Ensures that the "Query Threads" state is properly populated by using the [LogicRegistry.queryThreads]. + * Ensures that the "Query Threads" state is properly populated by using the [LogicRegistry.threads]. * * @param logic The [LogicRegistry] providing the business logic. */ internal class QueryThreadsListenerState(private val logic: LogicRegistry) : QueryThreadsListener { override suspend fun onQueryThreadsPrecondition(request: QueryThreadsRequest): Result { - return logic.queryThreads().onQueryThreadsPrecondition(request) + return logic.threads().onQueryThreadsPrecondition(request) } override suspend fun onQueryThreadsRequest(request: QueryThreadsRequest) { - logic.queryThreads().onQueryThreadsRequest(request) + logic.threads().onQueryThreadsRequest(request) } override suspend fun onQueryThreadsResult(result: Result, request: QueryThreadsRequest) { - logic.queryThreads().onQueryThreadsResult(result, request) + logic.threads().onQueryThreadsResult(result, request) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendAttachmentListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendAttachmentListenerState.kt index c96db904211..3b2bb5edf5b 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendAttachmentListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendAttachmentListenerState.kt @@ -36,6 +36,7 @@ internal class SendAttachmentListenerState(private val logic: LogicRegistry) : S val channel = logic.channel(channelType, channelId) channel.upsertMessage(message) + logic.threads().upsertMessage(message) logic.threadFromMessage(message)?.upsertMessage(message) // Update flow for currently running queries diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendGiphyListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendGiphyListenerState.kt index 65cf2d0f254..fabf440aec7 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendGiphyListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendGiphyListenerState.kt @@ -39,6 +39,7 @@ internal class SendGiphyListenerState(private val logic: LogicRegistry) : SendGi if (result is Result.Success) { val message = result.value logic.channelFromMessage(message)?.stateLogic()?.deleteMessage(message) + logic.threads().deleteMessage(message) logic.threadFromMessage(message)?.stateLogic()?.deleteMessage(message) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt index f4e144991a8..a8e914e75e1 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerState.kt @@ -77,6 +77,7 @@ internal class SendMessageListenerState(private val logic: LogicRegistry) : Send .copy(syncStatus = SyncStatus.COMPLETED) .also { message -> logic.channelFromMessage(message)?.upsertMessage(message) + logic.threads().upsertMessage(message) logic.threadFromMessage(message)?.upsertMessage(message) } } @@ -105,6 +106,7 @@ internal class SendMessageListenerState(private val logic: LogicRegistry) : Send updatedLocallyAt = Date(), ).also { logic.channelFromMessage(it)?.upsertMessage(it) + logic.threads().upsertMessage(message) logic.threadFromMessage(it)?.upsertMessage(it) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendReactionListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendReactionListenerState.kt index 66af342d2dd..a99c40100b9 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendReactionListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/SendReactionListenerState.kt @@ -68,6 +68,11 @@ internal class SendReactionListenerState( ?.addMyReaction(reaction = reactionToSend, enforceUnique = enforceUnique) cachedChannelMessage?.let(channelLogic::upsertMessage) + val threadsLogic = logic.threads() + val cachedThreadsMessage = threadsLogic.getMessage(reaction.messageId) + ?.addMyReaction(reaction = reactionToSend, enforceUnique = enforceUnique) + cachedThreadsMessage?.let(threadsLogic::upsertMessage) + val threadLogic = logic.threadFromMessageId(reaction.messageId) val cachedThreadMessage = threadLogic?.getMessage(reaction.messageId) ?.addMyReaction(reaction = reactionToSend, enforceUnique = enforceUnique) @@ -92,6 +97,11 @@ internal class SendReactionListenerState( ) } + val threadsLogic = logic.threads() + val cachedThreadsMessage = threadsLogic.getMessage(reaction.messageId) + ?.updateReactionSyncStatus(originReaction = reaction, result = result) + cachedThreadsMessage?.let(threadsLogic::upsertMessage) + val threadLogic = logic.threadFromMessageId(reaction.messageId) threadLogic?.getMessage(reaction.messageId)?.let { message -> threadLogic.upsertMessage( diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/ShuffleGiphyListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/ShuffleGiphyListenerState.kt index 00523859803..d2b6a93c760 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/ShuffleGiphyListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/ShuffleGiphyListenerState.kt @@ -40,6 +40,7 @@ internal class ShuffleGiphyListenerState(private val logic: LogicRegistry) : Shu if (result is Result.Success) { val processedMessage = result.value.copy(syncStatus = SyncStatus.COMPLETED) logic.channelFromMessage(processedMessage)?.upsertMessage(processedMessage) + logic.threads().upsertMessage(processedMessage) logic.threadFromMessage(processedMessage)?.upsertMessage(processedMessage) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/thread/internal/QueryThreadsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/thread/internal/QueryThreadsStateLogic.kt deleted file mode 100644 index 7e389d31a2f..00000000000 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/thread/internal/QueryThreadsStateLogic.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (c) 2014-2024 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-chat-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.chat.android.state.plugin.logic.channel.thread.internal - -import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.Thread -import io.getstream.chat.android.state.plugin.state.querythreads.internal.QueryThreadsMutableState - -/** - * Logic for managing the state of the threads list. - * - * @param mutableState Reference to the global [QueryThreadsMutableState]. - */ -internal class QueryThreadsStateLogic(private val mutableState: QueryThreadsMutableState) { - - /** - * Retrieves the current state of the 'loading' indicator from the [mutableState]. - */ - internal fun isLoading() = mutableState.loading.value - - /** - * Updates the loading state of the [mutableState]. - * - * @param loading The new loading state. - */ - internal fun setLoading(loading: Boolean) = - mutableState.setLoading(loading) - - /** - * Retrieves the current state of the 'loading more' indicator from the [mutableState]. - */ - internal fun isLoadingMore() = mutableState.loadingMore.value - - /** - * Updates the loading more state of the [mutableState]. - * - * @param loading The new loading more state. - */ - internal fun setLoadingMore(loading: Boolean) = - mutableState.setLoadingMore(loading) - - /** - * Retrieves the current state of the thread list from the [mutableState]. - */ - internal fun getThreads() = mutableState.threads.value - - /** - * Updates the thread state of the [mutableState]. - * - * @param threads The new threads state. - */ - internal fun setThreads(threads: List) = - mutableState.setThreads(threads) - - /** - * Appends the new page of [threads] to the current thread list. - * - * @param threads The new page of threads. - */ - internal fun appendThreads(threads: List) = - mutableState.appendThreads(threads) - - /** - * Updates the identifier for the next page of threads in the [mutableState]. - * - * @param next The next page identifier. - */ - internal fun setNext(next: String?) = - mutableState.setNext(next) - - /** - * Adds a new thread to the set of unseen thread IDs in the [mutableState]. - * - * @param id The ID of the new [Thread]. - */ - internal fun addUnseenThreadId(id: String) = - mutableState.addUnseenThreadId(id) - - /** - * Clears the set of unseen thread IDs in the [mutableState]. - */ - internal fun clearUnseenThreadIds() = - mutableState.clearUnseenThreadIds() - - /** - * Updates the parent message of a thread. - * - * @param parent The new state of the thread parent message. - * @return true if matching parent message was found and was updated, false otherwise. - */ - internal fun updateParent(parent: Message): Boolean { - val oldThreads = getThreads() - var threadFound = false - val newThreads = oldThreads.map { - if (it.parentMessageId == parent.id) { - threadFound = true - it.copy( - parentMessage = parent, - deletedAt = parent.deletedAt, - updatedAt = parent.updatedAt, - replyCount = parent.replyCount, - ) - } else { - it - } - } - mutableState.setThreads(newThreads) - return threadFound - } - - /** - * Inserts/updates the given reply into the appropriate thread. - * - * @param reply The reply to upsert. - */ - internal fun upsertReply(reply: Message) { - val oldThreads = getThreads() - val newThreads = oldThreads.map { thread -> - if (thread.parentMessageId == reply.parentId) { - val newReplies = upsertMessageInList(reply, thread.latestReplies) - val sortedNewReplies = newReplies.sortedBy { - it.createdAt ?: it.createdLocallyAt - } - thread.copy(latestReplies = sortedNewReplies) - } else { - thread - } - } - mutableState.setThreads(newThreads) - } - - private fun upsertMessageInList(newMessage: Message, messages: List): List { - // Insert - if (messages.none { it.id == newMessage.id }) { - return messages + listOf(newMessage) - } - // Update - return messages.map { message -> - if (message.id == newMessage.id) { - newMessage - } else { - message - } - } - } -} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt index 60736fc48d7..ec51ebaf8a9 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt @@ -29,13 +29,13 @@ import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelStateLogic import io.getstream.chat.android.state.plugin.logic.channel.internal.SearchLogic -import io.getstream.chat.android.state.plugin.logic.channel.thread.internal.QueryThreadsLogic -import io.getstream.chat.android.state.plugin.logic.channel.thread.internal.QueryThreadsStateLogic import io.getstream.chat.android.state.plugin.logic.channel.thread.internal.ThreadLogic import io.getstream.chat.android.state.plugin.logic.channel.thread.internal.ThreadStateLogic import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsDatabaseLogic import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsStateLogic +import io.getstream.chat.android.state.plugin.logic.querythreads.internal.QueryThreadsLogic +import io.getstream.chat.android.state.plugin.logic.querythreads.internal.QueryThreadsStateLogic import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.state.plugin.state.querychannels.internal.toMutableState @@ -214,7 +214,7 @@ internal class LogicRegistry internal constructor( /** * Provides the [QueryThreadsLogic] handling the business logic and state management related to thread queries. */ - fun queryThreads(): QueryThreadsLogic = queryThreads + fun threads(): QueryThreadsLogic = queryThreads /** Returns [ThreadLogic] of thread replies with parent message that has id equal to [messageId]. */ fun thread(messageId: String): ThreadLogic { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/thread/internal/QueryThreadsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt similarity index 65% rename from stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/thread/internal/QueryThreadsLogic.kt rename to stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt index 1ec2c501fc2..eda895e7dce 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/thread/internal/QueryThreadsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt @@ -14,13 +14,16 @@ * limitations under the License. */ -package io.getstream.chat.android.state.plugin.logic.channel.thread.internal +package io.getstream.chat.android.state.plugin.logic.querythreads.internal import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent +import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent import io.getstream.chat.android.client.events.NotificationThreadMessageNewEvent import io.getstream.chat.android.client.events.ReactionDeletedEvent import io.getstream.chat.android.client.events.ReactionNewEvent @@ -91,7 +94,7 @@ internal class QueryThreadsLogic(private val stateLogic: QueryThreadsStateLogic) when (result) { is Result.Success -> { if (request.isNextPageRequest()) { - stateLogic.appendThreads(result.value.threads) + stateLogic.upsertThreads(result.value.threads) } else { stateLogic.setThreads(result.value.threads) stateLogic.clearUnseenThreadIds() @@ -112,12 +115,37 @@ internal class QueryThreadsLogic(private val stateLogic: QueryThreadsStateLogic) */ internal fun handleEvents(events: List) = events.forEach(::handleEvent) + /** + * Retrieves a [Message] by its ID if it is stored in the Threads state. + */ + internal fun getMessage(messageId: String): Message? = + stateLogic.getMessage(messageId) + + /** + * Upsert the given [Message] in a [Thread] if such exists. + */ + internal fun upsertMessage(message: Message) = updateParentOrReply(message) + + /** + * Upsert the given [Message] from a [Thread] if such exists. + */ + internal fun deleteMessage(message: Message) = + stateLogic.deleteMessage(message) + private fun handleEvent(event: ChatEvent) { when (event) { - is NotificationThreadMessageNewEvent -> addNewThreadMessage(event) + // Destructive operation - remove the threads completely from the list is NotificationChannelDeletedEvent -> deleteThreadsFromChannel(event.cid) - is MessageDeletedEvent -> updateParentOrReply(event.message) + // Informs about a new thread (loaded, not loaded, or newly created thread) + is NotificationThreadMessageNewEvent -> onNewThreadMessageNotification(event) + // (Potentially) Informs about marking a thread as unread + is NotificationMarkUnreadEvent -> markThreadAsUnread(event) + // (Potentially) Informs about reading of a thread + is MessageReadEvent -> markThreadAsRead(event) + // (Potentially) Updates/Inserts a message in a thread + is NewMessageEvent -> updateParentOrReply(event.message) is MessageUpdatedEvent -> updateParentOrReply(event.message) + is MessageDeletedEvent -> updateParentOrReply(event.message) is ReactionNewEvent -> updateParentOrReply(event.message) is ReactionUpdateEvent -> updateParentOrReply(event.message) is ReactionDeletedEvent -> updateParentOrReply(event.message) @@ -127,18 +155,14 @@ internal class QueryThreadsLogic(private val stateLogic: QueryThreadsStateLogic) private fun QueryThreadsRequest.isNextPageRequest() = this.next != null - private fun addNewThreadMessage(event: NotificationThreadMessageNewEvent) { + private fun onNewThreadMessageNotification(event: NotificationThreadMessageNewEvent) { + val newMessageThreadId = event.message.parentId ?: return + // Update the unseenThreadIsd if the relevant thread is not loaded (yet) val threads = stateLogic.getThreads() - val thread = threads.find { it.parentMessageId == event.message.parentId } - if (thread == null) { - // Thread is not (yet) loaded, just update the state of unseenThreadIds - event.message.parentId?.let { parentId -> - stateLogic.addUnseenThreadId(parentId) - } - return + if (threads.none { it.parentMessageId == newMessageThreadId }) { + stateLogic.addUnseenThreadId(newMessageThreadId) } - // Update the thread inline if it is already loaded - stateLogic.upsertReply(reply = event.message) + // If the thread is loaded, it will be updated by message.new + message.updated events } /** @@ -153,6 +177,34 @@ internal class QueryThreadsLogic(private val stateLogic: QueryThreadsStateLogic) } } + /** + * Marks a given thread as read by a user, if the [MessageReadEvent] is delivered for a thread. + * + * @param event The [MessageReadEvent] informing about the read state change. + */ + private fun markThreadAsRead(event: MessageReadEvent) { + val threadInfo = event.thread ?: return + stateLogic.markThreadAsReadByUser( + threadInfo = threadInfo, + user = event.user, + createdAt = event.createdAt, + ) + } + + /** + * Marks a given thread as unread by a user, if the [NotificationMarkUnreadEvent] is delivered for a thread. + * + * @param event The [NotificationMarkUnreadEvent] informing about the read state change. + */ + private fun markThreadAsUnread(event: NotificationMarkUnreadEvent) { + // At the moment, this event does not return the thread id, + // so this is the only way to identify that this event is related to a thread + val isUnreadThread = event.lastReadMessageId == null + if (isUnreadThread) { + stateLogic.markThreadAsUnreadByUser(event.firstUnreadMessageId, event.user, event.createdAt) + } + } + /** * Deletes all threads associated with the channel with [cid]. * Use when the channel was deleted. diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogic.kt new file mode 100644 index 00000000000..3293bf7852b --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogic.kt @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2014-2024 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-chat-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.chat.android.state.plugin.logic.querythreads.internal + +import io.getstream.chat.android.models.ChannelUserRead +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.ThreadInfo +import io.getstream.chat.android.models.ThreadParticipant +import io.getstream.chat.android.models.User +import io.getstream.chat.android.state.plugin.state.querythreads.internal.QueryThreadsMutableState +import java.util.Date + +/** + * Logic for managing the state of the threads list. + * + * @param mutableState Reference to the global [QueryThreadsMutableState]. + */ +@Suppress("TooManyFunctions") +internal class QueryThreadsStateLogic(private val mutableState: QueryThreadsMutableState) { + + /** + * Retrieves the current state of the 'loading' indicator from the [mutableState]. + */ + internal fun isLoading() = mutableState.loading.value + + /** + * Updates the loading state of the [mutableState]. + * + * @param loading The new loading state. + */ + internal fun setLoading(loading: Boolean) = + mutableState.setLoading(loading) + + /** + * Retrieves the current state of the 'loading more' indicator from the [mutableState]. + */ + internal fun isLoadingMore() = mutableState.loadingMore.value + + /** + * Updates the loading more state of the [mutableState]. + * + * @param loading The new loading more state. + */ + internal fun setLoadingMore(loading: Boolean) = + mutableState.setLoadingMore(loading) + + /** + * Retrieves the current state of the thread list from the [mutableState]. + */ + internal fun getThreads() = mutableState.threads.value + + /** + * Updates the thread state of the [mutableState]. + * + * @param threads The new threads state. + */ + internal fun setThreads(threads: List) = + mutableState.setThreads(threads) + + /** + * Upsert a list of threads in the [mutableState]. + * + * @param threads The threads to upsert. + */ + internal fun upsertThreads(threads: List) = + mutableState.upsertThreads(threads) + + /** + * Updates the identifier for the next page of threads in the [mutableState]. + * + * @param next The next page identifier. + */ + internal fun setNext(next: String?) = + mutableState.setNext(next) + + /** + * Adds a new thread to the set of unseen thread IDs in the [mutableState]. + * + * @param id The ID of the new [Thread]. + */ + internal fun addUnseenThreadId(id: String) = + mutableState.addUnseenThreadId(id) + + /** + * Clears the set of unseen thread IDs in the [mutableState]. + */ + internal fun clearUnseenThreadIds() = + mutableState.clearUnseenThreadIds() + + /** + * Retrieves a message from the [mutableState] if it exists. + */ + internal fun getMessage(messageId: String): Message? { + val threads = mutableState.threadMap + return threads[messageId]?.parentMessage + ?: threads.flatMap { it.value.latestReplies }.find { it.id == messageId } + } + + /** + * Deletes a message from a [Thread] in the [mutableState]. + * + * @param message The [Message] to delete. + */ + internal fun deleteMessage(message: Message) { + val threads = mutableState.threadMap + if (message.parentId == null && threads.containsKey(message.id)) { + // Message is a thread parent + mutableState.deleteThread(message.id) + } else if (message.parentId != null) { + // Message is a potential thread reply + mutableState.deleteMessageFromThread(message.parentId, message.id) + } + } + + /** + * Updates the parent message of a thread. + * + * @param parent The new state of the thread parent message. + * @return true if matching parent message was found and was updated, false otherwise. + */ + internal fun updateParent(parent: Message): Boolean { + val oldThreads = getThreads() + var threadFound = false + val newThreads = oldThreads.map { + if (it.parentMessageId == parent.id) { + threadFound = true + it.copy( + parentMessage = parent, + deletedAt = parent.deletedAt, + updatedAt = parent.updatedAt ?: it.updatedAt, + replyCount = parent.replyCount, + ) + } else { + it + } + } + mutableState.setThreads(newThreads) + return threadFound + } + + /** + * Inserts/updates the given reply into the appropriate thread. + * + * @param reply The reply to upsert. + */ + internal fun upsertReply(reply: Message) { + if (reply.parentId == null) return + val oldThreads = getThreads() + val newThreads = oldThreads.map { thread -> + if (thread.parentMessageId == reply.parentId) { + upsertReplyInThread(thread, reply) + } else { + thread + } + } + mutableState.setThreads(newThreads) + } + + /** + * Marks the given thread as read by the given user. + * + * @param threadInfo The [ThreadInfo] holding info about the [Thread] which should be marked as read. + * @param user The [User] for which the thread should be marked as read. + * @param createdAt The [Date] of the 'mark read' event. + */ + internal fun markThreadAsReadByUser(threadInfo: ThreadInfo, user: User, createdAt: Date) { + val updatedThreads = getThreads().map { thread -> + if (threadInfo.parentMessageId == thread.parentMessageId) { + val updatedRead = thread.read.map { read -> + if (read.user.id == user.id) { + read.copy( + user = user, + unreadMessages = 0, + lastReceivedEventDate = createdAt, + ) + } else { + read + } + } + thread.copy( + activeParticipantCount = threadInfo.activeParticipantCount, + deletedAt = threadInfo.deletedAt, + lastMessageAt = threadInfo.lastMessageAt ?: thread.lastMessageAt, + parentMessage = threadInfo.parentMessage ?: thread.parentMessage, + participantCount = threadInfo.participantCount, + replyCount = threadInfo.replyCount, + title = threadInfo.title, + updatedAt = threadInfo.updatedAt, + read = updatedRead, + ) + } else { + thread + } + } + setThreads(updatedThreads) + } + + /** + * Marks the given thread as read by the given user. + * + * @param threadId The ID of the message which was marked as unread. (to be found in the thread) + * @param user The [User] for which the thread should be marked as unread. + */ + internal fun markThreadAsUnreadByUser(threadId: String, user: User, createdAt: Date) { + val thread = mutableState.threadMap[threadId] ?: return + val updatedRead = thread.read.map { read -> + if (read.user.id == user.id) { + read.copy( + user = user, + // Update this value to what the backend returns (when implemented) + unreadMessages = read.unreadMessages + 1, + lastReceivedEventDate = createdAt, + ) + } else { + read + } + } + val updatedThread = thread.copy(read = updatedRead) + mutableState.upsertThreads(listOf(updatedThread)) + } + + private fun upsertReplyInThread(thread: Thread, reply: Message): Thread { + val newReplies = upsertMessageInList(reply, thread.latestReplies) + val isInsert = newReplies.size > thread.latestReplies.size + val sortedNewReplies = newReplies.sortedBy { + it.createdAt ?: it.createdLocallyAt + } + val replyCount = if (isInsert) { + thread.replyCount + 1 + } else { + thread.replyCount + } + val lastMessageAt = sortedNewReplies.lastOrNull()?.let { latestReply -> + latestReply.createdAt ?: latestReply.createdLocallyAt + } + // The new message could be from a new thread participant + val threadParticipants = if (isInsert) { + upsertThreadParticipantInList( + newParticipant = ThreadParticipant(user = reply.user), + participants = thread.threadParticipants, + ) + } else { + thread.threadParticipants + } + val participantCount = threadParticipants.size + // Update read counts (+1 for each non-sender of the message) + val read = if (isInsert) { + updateReadCounts(thread.read, reply) + } else { + thread.read + } + return thread.copy( + replyCount = replyCount, + lastMessageAt = lastMessageAt ?: thread.lastMessageAt, + updatedAt = lastMessageAt ?: thread.updatedAt, + participantCount = participantCount, + threadParticipants = threadParticipants, + latestReplies = sortedNewReplies, + read = read, + ) + } + + private fun upsertMessageInList(newMessage: Message, messages: List): List { + // Insert + if (messages.none { it.id == newMessage.id }) { + return messages + listOf(newMessage) + } + // Update + return messages.map { message -> + if (message.id == newMessage.id) { + newMessage + } else { + message + } + } + } + + private fun upsertThreadParticipantInList( + newParticipant: ThreadParticipant, + participants: List, + ): List { + // Insert + if (participants.none { it.getUserId() == newParticipant.getUserId() }) { + return participants + listOf(newParticipant) + } + // Update + return participants.map { participant -> + if (participant.getUserId() == newParticipant.getUserId()) { + newParticipant + } else { + participant + } + } + } + + private fun updateReadCounts(read: List, reply: Message): List { + return read.map { userRead -> + if (userRead.user.id != reply.user.id) { + userRead.copy(unreadMessages = userRead.unreadMessages + 1) + } else { + userRead + } + } + } +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableState.kt index 074ed186233..43c19b72a80 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableState.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.state.plugin.state.querythreads.internal +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Thread import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState import kotlinx.coroutines.flow.MutableStateFlow @@ -27,12 +28,20 @@ import kotlinx.coroutines.flow.update */ internal class QueryThreadsMutableState : QueryThreadsState { + private val _threadMap: LinkedHashMap = linkedMapOf() + private var _threads: MutableStateFlow>? = MutableStateFlow(emptyList()) private var _loading: MutableStateFlow? = MutableStateFlow(false) private var _loadingMore: MutableStateFlow? = MutableStateFlow(false) private var _next: MutableStateFlow? = MutableStateFlow(null) private var _unseenThreadIds: MutableStateFlow>? = MutableStateFlow(emptySet()) + /** + * Exposes a read-only map of the threads. + */ + val threadMap: Map + get() = _threadMap + // Note: The backing flow will always be initialized at this point override val threads: StateFlow> = _threads!! override val loading: StateFlow = _loading!! @@ -64,17 +73,74 @@ internal class QueryThreadsMutableState : QueryThreadsState { * @param threads The new threads state. */ internal fun setThreads(threads: List) { - _threads?.value = threads + _threadMap.clear() + upsertThreads(threads) + } + + /** + * Inserts all [Thread]s which are not already existing. Attempts to insert (overwrite) an existing thread will be + * ignored. + */ + internal fun insertThreadsIfAbsent(threads: List) { + threads.forEach { thread -> + if (!_threadMap.containsKey(thread.parentMessageId)) { + _threadMap[thread.parentMessageId] = thread + } + } + // Update the public threadList + _threads?.value = _threadMap.values.toList() + } + + /** + * Updates/Inserts the given [List] of [Thread]s. + * + * @param threads The new batch of threads. + */ + internal fun upsertThreads(threads: List) { + val entries = threads.associateBy(Thread::parentMessageId) + _threadMap.putAll(entries) + // Update the public threadList + _threads?.value = _threadMap.values.toList() + } + + /** + * Removes a thread from the state. + * + * @param threadId The Id of the [Thread] to delete. + */ + internal fun deleteThread(threadId: String) { + _threadMap.remove(threadId) + // Update the public threadList + _threads?.value = _threadMap.values.toList() } /** - * Append the new page of [threads] to the current list of threads. + * Deletes a [Message] from a [Thread] in the state. * - * @param threads The new page of threads. + * @param threadId Id of the [Thread] to delete the [Message] from. + * @param messageId The Id of the message to delete. + */ + internal fun deleteMessageFromThread(threadId: String?, messageId: String) { + if (threadId == null) return + val thread = _threadMap[threadId] ?: return + val index = thread.latestReplies.indexOfFirst { message -> message.id == messageId } + if (index > -1) { + val updatedMessageList = thread.latestReplies.toMutableList() + updatedMessageList.removeAt(index) + val updatedThread = thread.copy(latestReplies = updatedMessageList) + _threadMap[threadId] = updatedThread + // Update the public threadList + _threads?.value = _threadMap.values.toList() + } + } + + /** + * Clears all threads from the state. */ - internal fun appendThreads(threads: List) { - val currentThreads = _threads?.value.orEmpty() - _threads?.value = currentThreads + threads + internal fun clearThreads() { + _threadMap.clear() + // Update the public threadList + _threads?.value = _threadMap.values.toList() } /** @@ -110,6 +176,7 @@ internal class QueryThreadsMutableState : QueryThreadsState { * Clears all data from the state. */ internal fun destroy() { + _threadMap.clear() _threads = null _loading = null _loadingMore = null diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index 8d2ba62d7e4..b0dad20cabc 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -641,6 +641,7 @@ internal class SyncManager( logger.d { "[removeMessage] message.id: ${message.id}" } repos.deleteChannelMessage(message) logicRegistry.channelFromMessage(message)?.deleteMessage(message) + logicRegistry.threads().deleteMessage(message) logicRegistry.threadFromMessage(message)?.deleteMessage(message) logger.v { "[removeMessage] completed: ${message.id}" } Result.Success(Unit) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteMessageListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteMessageListenerStateTest.kt index 4d81435df05..ba1b66ab89d 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteMessageListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteMessageListenerStateTest.kt @@ -24,10 +24,10 @@ import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelStateLogic import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querythreads.internal.QueryThreadsLogic import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.result.Error import io.getstream.result.Result -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test @@ -38,13 +38,13 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -@OptIn(ExperimentalCoroutinesApi::class) internal class DeleteMessageListenerStateTest { private val channelStateLogic: ChannelStateLogic = mock() private val channelLogic: ChannelLogic = mock { on(it.stateLogic()) doReturn channelStateLogic } + private val threadsLogic: QueryThreadsLogic = mock() private val clientState: ClientState = mock { on(it.user) doReturn MutableStateFlow(randomUser()) @@ -54,6 +54,7 @@ internal class DeleteMessageListenerStateTest { on(it.channel(any(), any())) doReturn channelLogic on(it.channelFromMessageId(any())) doReturn channelLogic on(it.channelFromMessage(any())) doReturn channelLogic + on(it.threads()) doReturn threadsLogic } private val deleteMessageListenerState: DeleteMessageListenerState = @@ -68,6 +69,7 @@ internal class DeleteMessageListenerStateTest { whenever(clientState.isNetworkAvailable) doReturn true whenever(channelLogic.getMessage(any())) doReturn testMessage + whenever(threadsLogic.getMessage(any())) doReturn testMessage deleteMessageListenerState.onMessageDeleteRequest(testMessage.id) @@ -77,6 +79,12 @@ internal class DeleteMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.IN_PROGRESS }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + // The same ID, but not the status was correctly updated + message.id == testMessage.id && message.syncStatus == SyncStatus.IN_PROGRESS + }, + ) } @Test @@ -88,6 +96,7 @@ internal class DeleteMessageListenerStateTest { whenever(clientState.isNetworkAvailable) doReturn false whenever(channelLogic.getMessage(any())) doReturn testMessage + whenever(threadsLogic.getMessage(any())) doReturn testMessage deleteMessageListenerState.onMessageDeleteRequest(testMessage.id) @@ -97,6 +106,12 @@ internal class DeleteMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + // The same ID, but not the status was correctly updated + message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED + }, + ) } @Test @@ -108,6 +123,7 @@ internal class DeleteMessageListenerStateTest { whenever(clientState.isNetworkAvailable) doReturn true whenever(channelLogic.getMessage(any())) doReturn testMessage + whenever(threadsLogic.getMessage(any())) doReturn testMessage deleteMessageListenerState.onMessageDeleteResult(testMessage.id, Result.Success(testMessage)) @@ -117,6 +133,12 @@ internal class DeleteMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + // The same ID, but not the status was correctly updated + message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED + }, + ) } @Test @@ -128,6 +150,7 @@ internal class DeleteMessageListenerStateTest { whenever(clientState.isNetworkAvailable) doReturn true whenever(channelLogic.getMessage(any())) doReturn testMessage + whenever(threadsLogic.getMessage(any())) doReturn testMessage deleteMessageListenerState.onMessageDeleteResult(testMessage.id, Result.Failure(Error.GenericError(""))) @@ -137,5 +160,11 @@ internal class DeleteMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + // The same ID, but not the status was correctly updated + message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED + }, + ) } } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteReactionListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteReactionListenerStateTest.kt index 97a5015057a..cc396155252 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteReactionListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/DeleteReactionListenerStateTest.kt @@ -23,7 +23,7 @@ import io.getstream.chat.android.randomReaction import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry -import kotlinx.coroutines.ExperimentalCoroutinesApi +import io.getstream.chat.android.state.plugin.logic.querythreads.internal.QueryThreadsLogic import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.mockito.kotlin.any @@ -33,7 +33,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -@OptIn(ExperimentalCoroutinesApi::class) internal class DeleteReactionListenerStateTest { private val user = randomUser() private val defaultReaction = randomReaction( @@ -50,9 +49,13 @@ internal class DeleteReactionListenerStateTest { private val channelLogic = mock { on(it.getMessage(any())) doReturn defaultMessage } + private val threadsLogic = mock { + on(it.getMessage(any())) doReturn defaultMessage + } private val logicRegistry = mock { on(it.channelFromMessageId(any())) doReturn channelLogic on(it.channel(any(), any())) doReturn channelLogic + on(it.threads()) doReturn threadsLogic } private val deleteReactionListenerDatabase = DeleteReactionListenerState(logicRegistry, clientState) @@ -73,5 +76,10 @@ internal class DeleteReactionListenerStateTest { message.ownReactions.isEmpty() && message.latestReactions.isEmpty() }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.ownReactions.isEmpty() && message.latestReactions.isEmpty() + }, + ) } } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/EditMessageListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/EditMessageListenerStateTest.kt index b55b3a5370c..fed76085932 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/EditMessageListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/EditMessageListenerStateTest.kt @@ -24,9 +24,9 @@ import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelStat import io.getstream.chat.android.state.plugin.logic.channel.thread.internal.ThreadLogic import io.getstream.chat.android.state.plugin.logic.channel.thread.internal.ThreadStateLogic import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querythreads.internal.QueryThreadsLogic import io.getstream.result.Error import io.getstream.result.Result -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.mockito.kotlin.any @@ -36,7 +36,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -@OptIn(ExperimentalCoroutinesApi::class) internal class EditMessageListenerStateTest { private val logicRegistry: LogicRegistry = mock() @@ -51,12 +50,15 @@ internal class EditMessageListenerStateTest { on(it.stateLogic()) doReturn channelStateLogic } + val threadsLogic: QueryThreadsLogic = mock() + val threadStateLogic: ThreadStateLogic = mock() val threadLogic: ThreadLogic = mock { on(it.stateLogic()) doReturn threadStateLogic } whenever(logicRegistry.channelFromMessage(any())) doReturn channelLogic + whenever(logicRegistry.threads()) doReturn threadsLogic whenever(logicRegistry.threadFromMessage(any())) doReturn threadLogic val testMessage = randomMessage() @@ -67,6 +69,11 @@ internal class EditMessageListenerStateTest { message.id == testMessage.id }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.id == testMessage.id + }, + ) verify(threadStateLogic).upsertMessage( argThat { message -> message.id == testMessage.id @@ -81,12 +88,15 @@ internal class EditMessageListenerStateTest { on(it.stateLogic()) doReturn channelStateLogic } + val threadsLogic: QueryThreadsLogic = mock() + val threadStateLogic: ThreadStateLogic = mock() val threadLogic: ThreadLogic = mock { on(it.stateLogic()) doReturn threadStateLogic } whenever(logicRegistry.channelFromMessage(any())) doReturn channelLogic + whenever(logicRegistry.threads()) doReturn threadsLogic whenever(logicRegistry.threadFromMessage(any())) doReturn threadLogic whenever(clientState.isNetworkAvailable) doReturn true @@ -98,6 +108,11 @@ internal class EditMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.IN_PROGRESS }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.id == testMessage.id + }, + ) verify(threadStateLogic).upsertMessage( argThat { message -> message.id == testMessage.id && message.syncStatus == SyncStatus.IN_PROGRESS @@ -112,12 +127,15 @@ internal class EditMessageListenerStateTest { on(it.stateLogic()) doReturn channelStateLogic } + val threadsLogic: QueryThreadsLogic = mock() + val threadStateLogic: ThreadStateLogic = mock() val threadLogic: ThreadLogic = mock { on(it.stateLogic()) doReturn threadStateLogic } whenever(logicRegistry.channelFromMessage(any())) doReturn channelLogic + whenever(logicRegistry.threads()) doReturn threadsLogic whenever(logicRegistry.threadFromMessage(any())) doReturn threadLogic whenever(clientState.isNetworkAvailable) doReturn false @@ -129,6 +147,11 @@ internal class EditMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED + }, + ) verify(threadStateLogic).upsertMessage( argThat { message -> message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED @@ -143,12 +166,15 @@ internal class EditMessageListenerStateTest { on(it.stateLogic()) doReturn channelStateLogic } + val threadsLogic: QueryThreadsLogic = mock() + val threadStateLogic: ThreadStateLogic = mock() val threadLogic: ThreadLogic = mock { on(it.stateLogic()) doReturn threadStateLogic } whenever(logicRegistry.channelFromMessage(any())) doReturn channelLogic + whenever(logicRegistry.threads()) doReturn threadsLogic whenever(logicRegistry.threadFromMessage(any())) doReturn threadLogic val testMessage = randomMessage() @@ -160,6 +186,11 @@ internal class EditMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED + }, + ) verify(threadStateLogic).upsertMessage( argThat { message -> message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED @@ -174,12 +205,15 @@ internal class EditMessageListenerStateTest { on(it.stateLogic()) doReturn channelStateLogic } + val threadsLogic: QueryThreadsLogic = mock() + val threadStateLogic: ThreadStateLogic = mock() val threadLogic: ThreadLogic = mock { on(it.stateLogic()) doReturn threadStateLogic } whenever(logicRegistry.channelFromMessage(any())) doReturn channelLogic + whenever(logicRegistry.threads()) doReturn threadsLogic whenever(logicRegistry.threadFromMessage(any())) doReturn threadLogic val testMessage = randomMessage() @@ -191,6 +225,11 @@ internal class EditMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED + }, + ) verify(threadStateLogic).upsertMessage( argThat { message -> message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt index c4d831079d3..2e10fe6abe6 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/SendMessageListenerStateTest.kt @@ -22,9 +22,9 @@ import io.getstream.chat.android.randomString import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.state.plugin.logic.channel.thread.internal.ThreadLogic import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querythreads.internal.QueryThreadsLogic import io.getstream.result.Error import io.getstream.result.Result -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.mockito.kotlin.any @@ -35,14 +35,15 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -@OptIn(ExperimentalCoroutinesApi::class) internal class SendMessageListenerStateTest { private val channelLogic: ChannelLogic = mock() + private val threadsLogic: QueryThreadsLogic = mock() private val threadLogic: ThreadLogic = mock() private val logicRegistry: LogicRegistry = mock { on(it.channelFromMessage(any())) doReturn channelLogic on(it.threadFromMessage(any())) doReturn threadLogic + on(it.threads()) doReturn threadsLogic on(it.getMessageById(any())) doReturn null } @@ -64,6 +65,11 @@ internal class SendMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED + }, + ) verify(threadLogic).upsertMessage( argThat { message -> message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED @@ -87,6 +93,11 @@ internal class SendMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED + }, + ) verify(threadLogic).upsertMessage( argThat { message -> message.id == testMessage.id && message.syncStatus == SyncStatus.SYNC_NEEDED @@ -112,6 +123,11 @@ internal class SendMessageListenerStateTest { message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED }, ) + verify(threadsLogic, never()).upsertMessage( + argThat { message -> + message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED + }, + ) verify(threadLogic, never()).upsertMessage( argThat { message -> message.id == testMessage.id && message.syncStatus == SyncStatus.COMPLETED diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/ShuffleGiphyListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/ShuffleGiphyListenerStateTest.kt index 27848a0d152..9cfb4b87adc 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/ShuffleGiphyListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/ShuffleGiphyListenerStateTest.kt @@ -21,9 +21,9 @@ import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomMessage import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querythreads.internal.QueryThreadsLogic import io.getstream.result.Error import io.getstream.result.Result -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.mockito.kotlin.any @@ -32,12 +32,13 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify -@OptIn(ExperimentalCoroutinesApi::class) internal class ShuffleGiphyListenerStateTest { private val channelLogic: ChannelLogic = mock() + private val threadsLogic: QueryThreadsLogic = mock() private val logic: LogicRegistry = mock { on(it.channelFromMessage(any())) doReturn channelLogic + on(it.threads()) doReturn threadsLogic } private val shuffleGiphyListenerState = ShuffleGiphyListenerState(logic) @@ -48,6 +49,7 @@ internal class ShuffleGiphyListenerStateTest { shuffleGiphyListenerState.onShuffleGiphyResult(randomCID(), Result.Success(testMessage)) verify(channelLogic).upsertMessage(testMessage.copy(syncStatus = SyncStatus.COMPLETED)) + verify(threadsLogic).upsertMessage(testMessage.copy(syncStatus = SyncStatus.COMPLETED)) } @Test @@ -55,5 +57,6 @@ internal class ShuffleGiphyListenerStateTest { shuffleGiphyListenerState.onShuffleGiphyResult(randomCID(), Result.Failure(Error.GenericError(""))) verify(channelLogic, never()).upsertMessage(any()) + verify(threadsLogic, never()).upsertMessage(any()) } } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogicTest.kt new file mode 100644 index 00000000000..67d9d40e6e1 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogicTest.kt @@ -0,0 +1,751 @@ +/* + * Copyright (c) 2014-2024 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-chat-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.chat.android.state.plugin.logic.querythreads.internal + +import io.getstream.chat.android.client.api.models.QueryThreadsRequest +import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageReadEvent +import io.getstream.chat.android.client.events.MessageUpdatedEvent +import io.getstream.chat.android.client.events.NewMessageEvent +import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent +import io.getstream.chat.android.client.events.NotificationThreadMessageNewEvent +import io.getstream.chat.android.client.events.ReactionDeletedEvent +import io.getstream.chat.android.client.events.ReactionNewEvent +import io.getstream.chat.android.client.events.ReactionUpdateEvent +import io.getstream.chat.android.client.events.UnknownEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.QueryThreadsResult +import io.getstream.chat.android.models.Reaction +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.ThreadInfo +import io.getstream.chat.android.models.ThreadParticipant +import io.getstream.chat.android.models.User +import io.getstream.result.Error +import io.getstream.result.Result +import org.amshove.kluent.`should be equal to` +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import java.util.Date + +internal class QueryThreadsLogicTest { + + private val threadList = listOf( + Thread( + activeParticipantCount = 2, + cid = "messaging:123", + channel = null, + parentMessageId = "mId1", + parentMessage = Message( + id = "mId1", + cid = "messaging:123", + text = "Thread parent", + ), + createdByUserId = "usrId1", + createdBy = null, + replyCount = 1, + participantCount = 2, + threadParticipants = listOf( + ThreadParticipant(User(id = "usrId1")), + ThreadParticipant(User(id = "usrId2")), + ), + lastMessageAt = Date(), + createdAt = Date(), + updatedAt = Date(), + deletedAt = null, + title = "Thread 1", + latestReplies = listOf( + Message( + id = "mId2", + cid = "messaging:123", + text = "Thread reply", + parentId = "mId1", + ), + ), + read = emptyList(), + ), + ) + + @Test + fun `Given QueryThreadsLogic When checking request precondition and data is already loading Should return failure`() { + // given + val stateLogic = mock() + whenever(stateLogic.isLoading()) doReturn true + val logic = QueryThreadsLogic(stateLogic) + // when + val result = logic.onQueryThreadsPrecondition(QueryThreadsRequest()) + // then + result shouldBeInstanceOf Result.Failure::class + } + + @Test + fun `Given QueryThreadsLogic When checking request precondition for more data and more data is already loading Should return failure`() { + // given + val stateLogic = mock() + whenever(stateLogic.isLoadingMore()) doReturn true + val logic = QueryThreadsLogic(stateLogic) + // when + val result = logic.onQueryThreadsPrecondition(QueryThreadsRequest(next = "nextCursor")) + // then + result shouldBeInstanceOf Result.Failure::class + } + + @Test + fun `Given QueryThreadsLogic When checking request precondition for new data and more data is already loading Should return success`() { + // given + val stateLogic = mock() + whenever(stateLogic.isLoadingMore()) doReturn true + val logic = QueryThreadsLogic(stateLogic) + // when + val result = logic.onQueryThreadsPrecondition(QueryThreadsRequest()) + // then + result shouldBeInstanceOf Result.Success::class + } + + @Test + fun `Given QueryThreadsLogic When checking request precondition for new data and no data is loading Should return success`() { + // given + val stateLogic = mock() + whenever(stateLogic.isLoadingMore()) doReturn false + whenever(stateLogic.isLoading()) doReturn false + val logic = QueryThreadsLogic(stateLogic) + // when + val result = logic.onQueryThreadsPrecondition(QueryThreadsRequest()) + // then + result shouldBeInstanceOf Result.Success::class + } + + @Test + fun `Given QueryThreadsLogic When requesting new data Should update loading state`() { + // given + val stateLogic = mock() + val logic = QueryThreadsLogic(stateLogic) + // when + logic.onQueryThreadsRequest(QueryThreadsRequest()) + // then + verify(stateLogic, times(1)).setLoading(true) + verify(stateLogic, never()).setLoadingMore(any()) + } + + @Test + fun `Given QueryThreadsLogic When requesting new data Should update loadingMore state`() { + // given + val stateLogic = mock() + val logic = QueryThreadsLogic(stateLogic) + // when + logic.onQueryThreadsRequest(QueryThreadsRequest(next = "nextCursor")) + // then + verify(stateLogic, never()).setLoading(true) + verify(stateLogic, times(1)).setLoadingMore(any()) + } + + @Test + fun `Given QueryThreadsLogic When handling new data result Should set threads and clear unseenThreadIds`() { + // given + val stateLogic = mock() + val logic = QueryThreadsLogic(stateLogic) + // when + val request = QueryThreadsRequest() + val result = Result.Success( + value = QueryThreadsResult( + threads = emptyList(), + prev = null, + next = "nextCursor", + ), + ) + logic.onQueryThreadsResult(result, request) + // then + verify(stateLogic, times(1)).setLoading(false) + verify(stateLogic, times(1)).setLoadingMore(false) + verify(stateLogic, times(1)).setThreads(emptyList()) + verify(stateLogic, times(1)).clearUnseenThreadIds() + verify(stateLogic, times(1)).setNext("nextCursor") + verify(stateLogic, never()).upsertThreads(any()) + } + + @Test + fun `Given QueryThreadsLogic When handling more data result Should append threads`() { + // given + val stateLogic = mock() + val logic = QueryThreadsLogic(stateLogic) + // when + val request = QueryThreadsRequest(next = "page2Cursor") + val result = Result.Success( + value = QueryThreadsResult( + threads = emptyList(), + prev = "page1Cursor", + next = "page3Cursor", + ), + ) + logic.onQueryThreadsResult(result, request) + // then + verify(stateLogic, times(1)).setLoading(false) + verify(stateLogic, times(1)).setLoadingMore(false) + verify(stateLogic, times(1)).setNext("page3Cursor") + verify(stateLogic, times(1)).upsertThreads(emptyList()) + verify(stateLogic, never()).setThreads(any()) + verify(stateLogic, never()).clearUnseenThreadIds() + } + + @Test + fun `Given QueryThreadsLogic When handling error result Should update loading state`() { + // given + val stateLogic = mock() + val logic = QueryThreadsLogic(stateLogic) + // when + val request = QueryThreadsRequest() + val result = Result.Failure(Error.GenericError("error")) + logic.onQueryThreadsResult(result, request) + // then + verify(stateLogic, times(1)).setLoading(false) + verify(stateLogic, times(1)).setLoadingMore(false) + verify(stateLogic, never()).setNext(any()) + verify(stateLogic, never()).upsertThreads(any()) + verify(stateLogic, never()).setThreads(any()) + verify(stateLogic, never()).clearUnseenThreadIds() + } + + @Test + fun `Given QueryThreadsLogic When handling ChannelDeletedEvent Should update state by deleting affected threads`() { + // given + val event = NotificationChannelDeletedEvent( + type = "notification.channel_deleted", + createdAt = Date(), + rawCreatedAt = "", + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + channel = Channel(), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + val expectedUpdatedThreadList = emptyList() + verify(stateLogic, times(1)).setThreads(expectedUpdatedThreadList) + } + + @Test + fun `Given QueryThreadsLogic When handling ThreadMessageNew for existing thread Should do nothing`() { + // given + val event = NotificationThreadMessageNewEvent( + type = "notification.thread_message_new", + cid = "messaging:123", + channelId = "123", + channelType = "messaging", + message = Message(id = "mId3", parentId = "mId1", text = "Text"), + channel = Channel(), + createdAt = Date(), + rawCreatedAt = "", + unreadThreads = 1, + unreadThreadMessages = 2, + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, never()).addUnseenThreadId(any()) + } + + @Test + fun `Given QueryThreadsLogic When handling ThreadMessageNew for new thread Should update unseenThreadIds`() { + // given + val event = NotificationThreadMessageNewEvent( + type = "notification.thread_message_new", + cid = "messaging:123", + channelId = "123", + channelType = "messaging", + message = Message(id = "mId3", parentId = "mId4", text = "Text"), + channel = Channel(), + createdAt = Date(), + rawCreatedAt = "", + unreadThreads = 1, + unreadThreadMessages = 2, + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).addUnseenThreadId("mId4") + } + + @Test + fun `Given QueryThreadsLogic When handling NotificationMarkUnread for non thread Should do nothing`() { + // given + val event = NotificationMarkUnreadEvent( + type = "notification.mark_unread", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + lastReadMessageId = "mId1", + lastReadMessageAt = Date(), + firstUnreadMessageId = "mId2", + unreadMessages = 1, + ) + val stateLogic = mock() + doNothing().whenever(stateLogic).markThreadAsUnreadByUser(any(), any(), any()) + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, never()).markThreadAsUnreadByUser(any(), any(), any()) + } + + @Test + fun `Given QueryThreadsLogic When handling NotificationMarkUnread for thread Should mark unread via stateLogic`() { + // given + val event = NotificationMarkUnreadEvent( + type = "notification.mark_unread", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + lastReadMessageId = null, + lastReadMessageAt = Date(), + firstUnreadMessageId = "mId1", + unreadMessages = 1, + ) + val stateLogic = mock() + doNothing().whenever(stateLogic).markThreadAsUnreadByUser(any(), any(), any()) + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).markThreadAsUnreadByUser(event.firstUnreadMessageId, event.user, event.createdAt) + } + + @Test + fun `Given QueryThreadsLogic When handling MessageRead for thread Should mark read via stateLogic`() { + // given + val event = MessageReadEvent( + type = "notification.thread_message_new", + cid = "messaging:123", + channelId = "123", + channelType = "messaging", + createdAt = Date(), + rawCreatedAt = "", + thread = ThreadInfo( + activeParticipantCount = 2, + cid = "messaging:123", + createdAt = Date(), + createdBy = null, + createdByUserId = "usrId1", + deletedAt = null, + lastMessageAt = Date(), + parentMessage = null, + parentMessageId = "mId1", + participantCount = 2, + replyCount = 2, + title = "Thread 1", + updatedAt = Date(), + ), + user = User(id = "usrId2"), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).markThreadAsReadByUser(event.thread!!, event.user, event.createdAt) + } + + @Test + fun `Given QueryThreadsLogic When handling MessageRead without thread Should do nothing`() { + // given + val event = MessageReadEvent( + type = "notification.thread_message_new", + cid = "messaging:123", + channelId = "123", + channelType = "messaging", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId2"), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, never()).markThreadAsReadByUser(any(), any(), any()) + } + + @Test + fun `Given QueryThreadsLogic When handling MessageNew Should upsert reply`() { + // given + val event = NewMessageEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId4", parentId = "mId1", text = "New reply"), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling MessageUpdated for parent Should update parent`() { + // given + val event = MessageUpdatedEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId1", text = "Updated thread parent"), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn true + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(0)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling MessageUpdated for reply Should upsert reply`() { + // given + val event = MessageUpdatedEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId2", text = "Updated thread reply"), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn false + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(1)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling MessageDeleted for parent Should update parent`() { + // given + val event = MessageDeletedEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId1", text = "Deleted thread parent"), + hardDelete = false, + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn true + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(0)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling MessageDeleted for reply Should upsert reply`() { + // given + val event = MessageDeletedEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId2", text = "Deleted thread reply"), + hardDelete = false, + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn false + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(1)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling ReactionNew for parent Should update parent`() { + // given + val event = ReactionNewEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId1", text = "Thread parent"), + reaction = Reaction(), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn true + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(0)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling ReactionNew for reply Should upsert reply`() { + // given + val event = ReactionNewEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId2", text = "Thread reply"), + reaction = Reaction(), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn false + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(1)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling ReactionUpdate for parent Should update parent`() { + // given + val event = ReactionUpdateEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId1", text = "Thread parent"), + reaction = Reaction(), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn true + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(0)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling ReactionUpdate for reply Should upsert reply`() { + // given + val event = ReactionUpdateEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId2", text = "Thread reply"), + reaction = Reaction(), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn false + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(1)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling ReactionDeleted for parent Should update parent`() { + // given + val event = ReactionDeletedEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId1", text = "Thread parent"), + reaction = Reaction(), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn true + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(0)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling ReactionDeleted for reply Should upsert reply`() { + // given + val event = ReactionDeletedEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + cid = "messaging:123", + channelType = "messaging", + channelId = "123", + message = Message(id = "mId2", text = "Thread reply"), + reaction = Reaction(), + ) + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(event.message)) doReturn false + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verify(stateLogic, times(1)).updateParent(event.message) + verify(stateLogic, times(1)).upsertReply(event.message) + } + + @Test + fun `Given QueryThreadsLogic When handling unsupported event Should do nothing`() { + // given + val event = UnknownEvent( + type = "reply", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = "usrId1"), + rawData = emptyMap(), + ) + val stateLogic = mock() + val logic = QueryThreadsLogic(stateLogic) + // when + logic.handleEvents(listOf(event)) + // then + verifyNoInteractions(stateLogic) + } + + @Test + fun `Given QueryThreadsLogic When calling getMessage Should get message via stateLogic`() { + // given + val stateLogic = mock() + whenever(stateLogic.getMessage("mId1")) doReturn Message(id = "mId1") + val logic = QueryThreadsLogic(stateLogic) + // when + val message = logic.getMessage("mId1") + // then + message `should be equal to` Message(id = "mId1") + verify(stateLogic, times(1)).getMessage("mId1") + } + + @Test + fun `Given QueryThreadsLogic When calling upsertMessage for parent Should update parent`() { + // given + val messageToUpsert = Message(id = "mId1", text = "Updated thread parent") + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + whenever(stateLogic.updateParent(messageToUpsert)) doReturn true + val logic = QueryThreadsLogic(stateLogic) + // when + logic.upsertMessage(messageToUpsert) + // then + verify(stateLogic, times(1)).updateParent(messageToUpsert) + verify(stateLogic, never()).upsertReply(any()) + } + + @Test + fun `Given QueryThreadsLogic When calling upsertMessage for reply Should upsert reply`() { + // given + val messageToUpsert = Message(id = "mId4", parentId = "mId1", text = "New reply") + val stateLogic = mock() + whenever(stateLogic.getThreads()) doReturn threadList + val logic = QueryThreadsLogic(stateLogic) + // when + logic.upsertMessage(messageToUpsert) + // then + verify(stateLogic, times(1)).updateParent(messageToUpsert) + verify(stateLogic, times(1)).upsertReply(messageToUpsert) + } + + @Test + fun `Given QueryThreadsLogic When calling deleteMessage Should delete message via stateLogic`() { + // given + val stateLogic = mock() + doNothing().whenever(stateLogic).deleteMessage(any()) + val logic = QueryThreadsLogic(stateLogic) + // when + val messageToDelete = Message(id = "mId1") + logic.deleteMessage(messageToDelete) + // then + verify(stateLogic, times(1)).deleteMessage(messageToDelete) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogicTest.kt new file mode 100644 index 00000000000..b3facad1af1 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogicTest.kt @@ -0,0 +1,576 @@ +/* + * Copyright (c) 2014-2024 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-chat-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.chat.android.state.plugin.logic.querythreads.internal + +import io.getstream.chat.android.models.ChannelUserRead +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.ThreadInfo +import io.getstream.chat.android.models.ThreadParticipant +import io.getstream.chat.android.models.User +import io.getstream.chat.android.state.plugin.state.querythreads.internal.QueryThreadsMutableState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.`should be` +import org.amshove.kluent.`should be equal to` +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.Date + +internal class QueryThreadsStateLogicTest { + + private val threadList = listOf( + Thread( + activeParticipantCount = 2, + cid = "messaging:123", + channel = null, + parentMessageId = "mId1", + parentMessage = Message( + id = "mId1", + cid = "messaging:123", + text = "Thread parent", + ), + createdByUserId = "usrId1", + createdBy = null, + replyCount = 1, + participantCount = 2, + threadParticipants = listOf( + ThreadParticipant(User(id = "usrId1")), + ThreadParticipant(User(id = "usrId2")), + ), + lastMessageAt = Date(), + createdAt = Date(), + updatedAt = Date(), + deletedAt = null, + title = "Thread 1", + latestReplies = listOf( + Message( + id = "mId2", + cid = "messaging:123", + text = "Thread reply", + parentId = "mId1", + ), + ), + read = listOf( + ChannelUserRead( + User(id = "usrId1"), + lastReceivedEventDate = Date(), + unreadMessages = 0, + lastRead = Date(), + lastReadMessageId = "mId2", + ), + ChannelUserRead( + user = User(id = "usrId2"), + lastReceivedEventDate = Date(), + unreadMessages = 1, + lastRead = Date(), + lastReadMessageId = null, + ), + ), + ), + ) + + @Test + fun `Given QueryThreadsStateLogic When getting isLoading Should return isLoading from mutableState`() { + // given + val mutableState = mock() + whenever(mutableState.loading) doReturn MutableStateFlow(true) + val logic = QueryThreadsStateLogic(mutableState) + // when + val isLoading = logic.isLoading() + // then + isLoading `should be equal to` true + verify(mutableState, times(1)).loading + } + + @Test + fun `Given QueryThreadsStateLogic When calling setLoading Should update mutableState`() { + // given + val mutableState = mock() + doNothing().whenever(mutableState).setLoading(any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + logic.setLoading(true) + // then + verify(mutableState, times(1)).setLoading(true) + } + + @Test + fun `Given QueryThreadsStateLogic When getting isLoadingMore Should return isLoadingMore from mutableState`() { + // given + val mutableState = mock() + whenever(mutableState.loadingMore) doReturn MutableStateFlow(true) + val logic = QueryThreadsStateLogic(mutableState) + // when + val isLoading = logic.isLoadingMore() + // then + isLoading `should be equal to` true + verify(mutableState, times(1)).loadingMore + } + + @Test + fun `Given QueryThreadsStateLogic When calling setLoadingMore Should update mutableState`() { + // given + val mutableState = mock() + doNothing().whenever(mutableState).setLoadingMore(any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + logic.setLoadingMore(true) + // then + verify(mutableState, times(1)).setLoadingMore(true) + } + + @Test + fun `Given QueryThreadsStateLogic When getting threads Should return threads from mutableState`() { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(emptyList()) + val logic = QueryThreadsStateLogic(mutableState) + // when + val threads = logic.getThreads() + // then + threads `should be equal to` emptyList() + verify(mutableState, times(1)).threads + } + + @Test + fun `Given QueryThreadsStateLogic When calling setThreads Should update mutableState`() { + // given + val mutableState = mock() + doNothing().whenever(mutableState).setThreads(any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + logic.setThreads(emptyList()) + // then + verify(mutableState, times(1)).setThreads(emptyList()) + } + + @Test + fun `Given QueryThreadsStateLogic When calling upsertThreads Should update mutableState`() { + // given + val mutableState = mock() + doNothing().whenever(mutableState).upsertThreads(any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + logic.upsertThreads(emptyList()) + // then + verify(mutableState, times(1)).upsertThreads(emptyList()) + } + + @Test + fun `Given QueryThreadsStateLogic When calling setNext Should update mutableState`() { + // given + val mutableState = mock() + doNothing().whenever(mutableState).setNext(any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + logic.setNext("nextCursor") + // then + verify(mutableState, times(1)).setNext("nextCursor") + } + + @Test + fun `Given QueryThreadsStateLogic When calling addUnseenThreadId Should update mutableState`() { + // given + val mutableState = mock() + doNothing().whenever(mutableState).addUnseenThreadId(any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + logic.addUnseenThreadId("threadId") + // then + verify(mutableState, times(1)).addUnseenThreadId("threadId") + } + + @Test + fun `Given QueryThreadsStateLogic When calling clearUnseenThreadIds Should update mutableState`() { + // given + val mutableState = mock() + doNothing().whenever(mutableState).clearUnseenThreadIds() + val logic = QueryThreadsStateLogic(mutableState) + // when + logic.clearUnseenThreadIds() + // then + verify(mutableState, times(1)).clearUnseenThreadIds() + } + + @Test + fun `Given QueryTestStateLogic When calling getMessage for parent message Should return message`() { + // given + val mutableState = mock() + whenever(mutableState.threadMap) doReturn mapOf("mId1" to threadList[0]) + val logic = QueryThreadsStateLogic(mutableState) + // when + val message = logic.getMessage("mId1") + // then + message `should be equal to` threadList[0].parentMessage + } + + @Test + fun `Given QueryTestStateLogic When calling getMessage for reply message Should return message`() { + // given + val mutableState = mock() + whenever(mutableState.threadMap) doReturn mapOf("mId1" to threadList[0]) + val logic = QueryThreadsStateLogic(mutableState) + // when + val message = logic.getMessage("mId2") + // then + message `should be equal to` threadList[0].latestReplies[0] + } + + @Test + fun `Given QueryTestStateLogic When calling getMessage for unknown id Should return null`() { + // given + val mutableState = mock() + whenever(mutableState.threadMap) doReturn mapOf("mId1" to threadList[0]) + val logic = QueryThreadsStateLogic(mutableState) + // when + val message = logic.getMessage("mId3") + // then + message `should be` null + } + + @Test + fun `Given QueryThreadsStateLogic When calling deleteMessage for parent message Should delete thread`() { + // given + val mutableState = mock() + whenever(mutableState.threadMap) doReturn mapOf("mId1" to threadList[0]) + doNothing().whenever(mutableState).deleteThread(any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + val messageToDelete = threadList[0].parentMessage + logic.deleteMessage(messageToDelete) + // then + verify(mutableState).deleteThread("mId1") + } + + @Test + fun `Given QueryThreadsStateLogic When calling deleteMessage for reply message Should delete reply`() { + // given + val mutableState = mock() + whenever(mutableState.threadMap) doReturn mapOf("mId1" to threadList[0]) + doNothing().whenever(mutableState).deleteMessageFromThread(any(), any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + val messageToDelete = threadList[0].latestReplies[0] + logic.deleteMessage(messageToDelete) + // then + verify(mutableState).deleteMessageFromThread(threadId = "mId1", messageId = "mId2") + } + + @Test + fun `Given QueryThreadsStateLogic When calling deleteMessage for unknown message Should do nothing`() { + // given + val mutableState = mock() + whenever(mutableState.threadMap) doReturn mapOf("mId1" to threadList[0]) + doNothing().whenever(mutableState).deleteThread(any()) + doNothing().whenever(mutableState).deleteMessageFromThread(any(), any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + val messageToDelete = Message() + logic.deleteMessage(messageToDelete) + // then + verify(mutableState, never()).deleteThread(any()) + verify(mutableState, never()).deleteMessageFromThread(any(), any()) + } + + @Test + fun `Given QueryThreadsStateLogic When updating parent message which doesn't exist Should return false`() = + runTest { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(threadList) + val logic = QueryThreadsStateLogic(mutableState) + val parent = Message( + id = "mId3", + cid = "messaging:123", + text = "Text", + ) + // when + val updated = logic.updateParent(parent) + // then + updated `should be equal to` false + } + + @Test + fun `Given QueryThreadsStateLogic When updating parent message which exists Should return true`() { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(threadList) + val logic = QueryThreadsStateLogic(mutableState) + val parent = Message( + id = "mId1", + cid = "messaging:123", + text = "Text", + replyCount = 1, + ) + // when + val updated = logic.updateParent(parent) + // then + val expectedUpdatedThread = threadList[0].copy( + parentMessage = parent, + deletedAt = parent.deletedAt, + updatedAt = parent.updatedAt ?: threadList[0].updatedAt, + replyCount = parent.replyCount, + ) + val expectedUpdatedThreadList = listOf(expectedUpdatedThread) + updated `should be equal to` true + verify(mutableState, times(1)).setThreads(expectedUpdatedThreadList) + } + + @Test + fun `Given QueryThreadsStateLogic When upserting reply without parent Should do nothing`() { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(threadList) + val logic = QueryThreadsStateLogic(mutableState) + val reply = Message( + id = "mId3", + cid = "messaging:123", + text = "Text", + parentId = null, + ) + // when + logic.upsertReply(reply) + // then + verify(mutableState, never()).threads + verify(mutableState, never()).setThreads(any()) + } + + @Test + fun `Given QueryThreadsStateLogic When upserting reply in unknown thread Should do nothing`() { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(threadList) + val logic = QueryThreadsStateLogic(mutableState) + val reply = Message( + id = "mId3", + cid = "messaging:123", + text = "Text", + parentId = "mId10", + ) + // when + logic.upsertReply(reply) + // then + verify(mutableState, times(1)).threads + verify(mutableState, times(1)).setThreads(threadList) // verify no changes + } + + @Test + fun `Given QueryThreadsStateLogic When updating reply in existing thread Should update mutableState`() { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(threadList) + val logic = QueryThreadsStateLogic(mutableState) + val reply = Message( + id = "mId2", + cid = "messaging:123", + text = "Updated text", + parentId = "mId1", + ) + // when + logic.upsertReply(reply) + // then + val expectedUpdatedThread = threadList[0].copy(latestReplies = listOf(reply)) + val expectedUpdatedThreadList = listOf(expectedUpdatedThread) + verify(mutableState, times(1)).setThreads(expectedUpdatedThreadList) + } + + @Test + fun `Given QueryThreadsStateLogic When inserting reply in existing thread from new participant Should update mutableState`() { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(threadList) + val logic = QueryThreadsStateLogic(mutableState) + val reply = Message( + id = "mId3", + cid = "messaging:123", + text = "Updated text", + parentId = "mId1", + user = User(id = "usrId3"), + ) + // when + logic.upsertReply(reply) + // then + val expectedUpdatedThread = threadList[0].copy( + latestReplies = threadList[0].latestReplies + listOf(reply), + replyCount = 2, + participantCount = 3, + threadParticipants = threadList[0].threadParticipants + listOf(ThreadParticipant(User("usrId3"))), + read = threadList[0].read.map { read -> + read.copy(unreadMessages = read.unreadMessages + 1) + }, + ) + val expectedUpdatedThreadList = listOf(expectedUpdatedThread) + verify(mutableState, times(1)).setThreads(expectedUpdatedThreadList) + } + + @Test + fun `Given QueryThreadsStateLogic When inserting reply in existing thread from existing participant Should update mutableState`() { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(threadList) + val logic = QueryThreadsStateLogic(mutableState) + val reply = Message( + id = "mId3", + cid = "messaging:123", + text = "Updated text", + parentId = "mId1", + user = User(id = "usrId2"), + ) + // when + logic.upsertReply(reply) + // then + val expectedUpdatedThread = threadList[0].copy( + latestReplies = threadList[0].latestReplies + listOf(reply), + replyCount = 2, + read = threadList[0].read.map { read -> + if (read.user.id == "usrId2") { + read + } else { + read.copy(unreadMessages = read.unreadMessages + 1) + } + }, + ) + val expectedUpdatedThreadList = listOf(expectedUpdatedThread) + verify(mutableState, times(1)).setThreads(expectedUpdatedThreadList) + } + + @Test + fun `Given QueryThreadsStateLogic When marking unknown thread as read Should do nothing`() { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(threadList) + val logic = QueryThreadsStateLogic(mutableState) + // when + logic.markThreadAsReadByUser( + threadInfo = ThreadInfo( + activeParticipantCount = 2, + cid = "messaging:123", + createdAt = Date(), + createdBy = null, + createdByUserId = "usrId2", + deletedAt = null, + lastMessageAt = Date(), + parentMessage = null, + parentMessageId = "mId13", // not a loaded thread + participantCount = 2, + replyCount = 2, + title = "Unknown thread", + updatedAt = Date(), + ), + user = User(id = "userId1"), + createdAt = Date(), + ) + // then + verify(mutableState, times(1)).setThreads(threadList) + } + + @Test + fun `Given QueryThreadsStateLogic When marking thread as read Should update mutableState`() { + // given + val mutableState = mock() + whenever(mutableState.threads) doReturn MutableStateFlow(threadList) + val logic = QueryThreadsStateLogic(mutableState) + // when + val threadInfo = ThreadInfo( + activeParticipantCount = 2, + cid = "messaging:123", + createdAt = Date(), + createdBy = null, + createdByUserId = "usrId1", + deletedAt = null, + lastMessageAt = Date(), + parentMessage = null, + parentMessageId = "mId1", // loaded thread + participantCount = 2, + replyCount = 1, + title = "Thread 1", + updatedAt = Date(), + ) + val user = User(id = "usrId2") + val createdAt = Date() + logic.markThreadAsReadByUser(threadInfo, user, createdAt) + // then + val expectedUpdatedThread = threadList[0].copy( + activeParticipantCount = threadInfo.activeParticipantCount, + deletedAt = threadInfo.deletedAt, + lastMessageAt = threadInfo.lastMessageAt ?: threadList[0].lastMessageAt, + parentMessage = threadInfo.parentMessage ?: threadList[0].parentMessage, + participantCount = threadInfo.participantCount, + replyCount = threadInfo.replyCount, + title = threadInfo.title, + updatedAt = threadInfo.updatedAt, + read = threadList[0].read.map { read -> + if (read.user.id == user.id) { + read.copy(user = user, unreadMessages = 0, lastReceivedEventDate = createdAt) + } else { + read + } + }, + ) + val expectedUpdatedThreadList = listOf(expectedUpdatedThread) + verify(mutableState, times(1)).setThreads(expectedUpdatedThreadList) + } + + @Test + fun `Given QueryThreadsStateLogic When marking not existing thread as unread Should do nothing`() { + // given + val mutableState = mock() + whenever(mutableState.threadMap) doReturn threadList.associateBy(Thread::parentMessageId) + val logic = QueryThreadsStateLogic(mutableState) + // when + val threadId = "mId2" + val user = User(id = "usrId2") + val createdAt = Date() + logic.markThreadAsUnreadByUser(threadId, user, createdAt) + // then + verify(mutableState, never()).upsertThreads(any()) + } + + @Test + fun `Given QueryThreadsStateLogic When marking thread as unread Should update mutableState`() { + // given + val mutableState = mock() + whenever(mutableState.threadMap) doReturn threadList.associateBy(Thread::parentMessageId) + doNothing().whenever(mutableState).upsertThreads(any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + val threadId = "mId1" + val user = User(id = "usrId2") + val createdAt = Date() + logic.markThreadAsUnreadByUser(threadId, user, createdAt) + // then + val expectedUpdatedThread = threadList[0].copy( + read = threadList[0].read.map { read -> + if (read.user.id == user.id) { + read.copy(user = user, unreadMessages = read.unreadMessages + 1, lastReceivedEventDate = createdAt) + } else { + read + } + }, + ) + val expectedUpdatedThreadList = listOf(expectedUpdatedThread) + verify(mutableState, times(1)).upsertThreads(expectedUpdatedThreadList) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableStateTest.kt new file mode 100644 index 00000000000..26353b83046 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableStateTest.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2014-2024 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-chat-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.chat.android.state.plugin.state.querythreads.internal + +import app.cash.turbine.test +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.ThreadParticipant +import io.getstream.chat.android.models.User +import io.getstream.chat.android.test.TestCoroutineRule +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.`should be equal to` +import org.junit.Rule +import org.junit.jupiter.api.Test +import java.util.Date + +internal class QueryThreadsMutableStateTest { + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + private val threadList1 = listOf( + Thread( + activeParticipantCount = 2, + cid = "messaging:123", + channel = null, + parentMessageId = "pmId1", + parentMessage = Message(), + createdByUserId = "usrId1", + createdBy = null, + replyCount = 1, + participantCount = 2, + threadParticipants = listOf( + ThreadParticipant(User("usrId1")), + ThreadParticipant(User("usrId2")), + ), + lastMessageAt = Date(), + createdAt = Date(), + updatedAt = Date(), + deletedAt = null, + title = "Thread 1", + latestReplies = listOf(Message(id = "mId1")), + read = emptyList(), + ), + ) + + private val threadList2 = listOf( + Thread( + activeParticipantCount = 2, + cid = "messaging:124", + channel = null, + parentMessageId = "pmId2", + parentMessage = Message(), + createdByUserId = "usrId1", + createdBy = null, + replyCount = 1, + participantCount = 2, + threadParticipants = listOf( + ThreadParticipant(User("usrId1")), + ThreadParticipant(User("usrId2")), + ), + lastMessageAt = Date(), + createdAt = Date(), + updatedAt = Date(), + deletedAt = null, + title = "Thread 2", + latestReplies = listOf(Message()), + read = emptyList(), + ), + ) + + @Test + fun `Given QueryThreadsMutableState When calling setLoading Should update loading`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.loading.test { + val initialValue = awaitItem() + initialValue `should be equal to` false + // when + mutableState.setLoading(true) + val updatedValue = awaitItem() + // then + updatedValue `should be equal to` true + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling setLoadingMore Should update loadingMore`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.loadingMore.test { + val initialValue = awaitItem() + initialValue `should be equal to` false + // when + mutableState.setLoadingMore(true) + val updatedValue = awaitItem() + // then + updatedValue `should be equal to` true + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling setThreads Should update threads`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.threads.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptyList() + + // when + mutableState.setThreads(threadList1) + val updatedValue = awaitItem() + updatedValue `should be equal to` threadList1 + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling insertThreadsIfAbsent with new threads Should update threads`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.threads.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptyList() + + // when + mutableState.insertThreadsIfAbsent(threadList1) + val updatedValue = awaitItem() + updatedValue `should be equal to` threadList1 + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling insertThreadsIfAbsent with existing threads Should do nothing`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.threads.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptyList() + + mutableState.setThreads(threadList1) + val updatedValue = awaitItem() + updatedValue `should be equal to` threadList1 + + // when + mutableState.insertThreadsIfAbsent(threadList1) + expectNoEvents() // Verify state is not updated + } + } + + @Test + fun `Given QueryThreadsMutableState When calling upsertThreads with new threads Should insert threads`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.threads.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptyList() + + // when + mutableState.upsertThreads(threadList1) + val updatedValue1 = awaitItem() + updatedValue1 `should be equal to` threadList1 + + mutableState.upsertThreads(threadList2) + val updatedValue2 = awaitItem() + updatedValue2 `should be equal to` (threadList1 + threadList2) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling upsertThreads with existing threads Should update threads`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.threads.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptyList() + + // when + mutableState.upsertThreads(threadList1) + val updatedValue1 = awaitItem() + updatedValue1 `should be equal to` threadList1 + + val newThreads = listOf(threadList1[0].copy(title = "New thread title")) + mutableState.upsertThreads(newThreads) + val updatedValue2 = awaitItem() + updatedValue2 `should be equal to` newThreads + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling clearThreads Should update threads`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.threads.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptyList() + + // when + mutableState.upsertThreads(threadList1) + val updatedValue1 = awaitItem() + updatedValue1 `should be equal to` threadList1 + + mutableState.clearThreads() + val updatedValue2 = awaitItem() + updatedValue2 `should be equal to` emptyList() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling deleteThread Should update threads`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.threads.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptyList() + + // when + mutableState.upsertThreads(threadList1) + val updatedValue1 = awaitItem() + updatedValue1 `should be equal to` threadList1 + + mutableState.deleteThread("pmId1") + val updatedValue2 = awaitItem() + updatedValue2 `should be equal to` emptyList() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling deleteMessageFromThread Should update threads`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.threads.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptyList() + + // when + mutableState.upsertThreads(threadList1) + val updatedValue1 = awaitItem() + updatedValue1 `should be equal to` threadList1 + + mutableState.deleteMessageFromThread(threadId = "pmId1", messageId = "mId1") + val updatedValue2 = awaitItem() + val expectedThread = threadList1[0].copy(latestReplies = emptyList()) + val expectedThreadList = listOf(expectedThread) + updatedValue2 `should be equal to` expectedThreadList + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling setNext Should update next`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.next.test { + val initialValue = awaitItem() + initialValue `should be equal to` null + // when + mutableState.setNext("nextCursor") + val updatedValue = awaitItem() + // then + updatedValue `should be equal to` "nextCursor" + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling addUnseenThreadId Should update unseenThreadIds`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.unseenThreadIds.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptySet() + // when + mutableState.addUnseenThreadId("threadId1") + mutableState.addUnseenThreadId("threadId2") + val updatedValue1 = awaitItem() + val updatedValue2 = awaitItem() + // then + updatedValue1 `should be equal to` setOf("threadId1") + updatedValue2 `should be equal to` setOf("threadId1", "threadId2") + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given QueryThreadsMutableState When calling clearUnseenThreadIds Should update unseenThreadIds`() = runTest { + // given + val mutableState = QueryThreadsMutableState() + mutableState.unseenThreadIds.test { + val initialValue = awaitItem() + initialValue `should be equal to` emptySet() + // when + mutableState.addUnseenThreadId("threadId1") + mutableState.clearUnseenThreadIds() + val updatedValue = awaitItem() + val clearedSet = awaitItem() + // then + updatedValue `should be equal to` setOf("threadId1") + clearedSet `should be equal to` emptySet() + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/reactions/SendReactionListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/reactions/SendReactionListenerStateTest.kt index aad8683f982..310249e6278 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/reactions/SendReactionListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/reactions/SendReactionListenerStateTest.kt @@ -26,6 +26,7 @@ import io.getstream.chat.android.randomReaction import io.getstream.chat.android.state.plugin.listener.internal.SendReactionListenerState import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querythreads.internal.QueryThreadsLogic import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.result.Result import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -71,9 +72,13 @@ internal class SendReactionListenerStateTest { private val channelLogic: ChannelLogic = mock { on(it.getMessage(any())) doReturn defaultMessage } + private val threadsLogic: QueryThreadsLogic = mock { + on(it.getMessage(any())) doReturn defaultMessage + } private val logicRegistry: LogicRegistry = mock { on(it.channelFromMessageId(any())) doReturn channelLogic + on(it.threads()) doReturn threadsLogic } private val sendReactionListenerState = SendReactionListenerState(logicRegistry, clientState) @@ -95,6 +100,15 @@ internal class SendReactionListenerStateTest { } }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.latestReactions.any { reaction -> + reaction.messageId == testReaction.messageId && reaction.syncStatus == SyncStatus.IN_PROGRESS + } && message.ownReactions.any { reaction -> + reaction.messageId == testReaction.messageId && reaction.syncStatus == SyncStatus.IN_PROGRESS + } + }, + ) } @Test @@ -108,6 +122,7 @@ internal class SendReactionListenerStateTest { ) whenever(channelLogic.getMessage(any())) doReturn testMessage + whenever(threadsLogic.getMessage(any())) doReturn testMessage sendReactionListenerState.onSendReactionResult( randomCID(), @@ -126,5 +141,14 @@ internal class SendReactionListenerStateTest { } }, ) + verify(threadsLogic).upsertMessage( + argThat { message -> + message.latestReactions.any { reaction -> + reaction.messageId == testReaction.messageId && reaction.syncStatus == SyncStatus.COMPLETED + } && message.ownReactions.any { reaction -> + reaction.messageId == testReaction.messageId && reaction.syncStatus == SyncStatus.COMPLETED + } + }, + ) } } diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index 614fe043ff0..d043c6985c6 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -1641,6 +1641,24 @@ public final class io/getstream/chat/android/ui/common/state/pinned/PinnedMessag public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/ui/common/state/threads/ThreadListState { + public static final field $stable I + public fun (Ljava/util/List;ZZI)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Z + public final fun component3 ()Z + public final fun component4 ()I + public final fun copy (Ljava/util/List;ZZI)Lio/getstream/chat/android/ui/common/state/threads/ThreadListState; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/threads/ThreadListState;Ljava/util/List;ZZIILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/threads/ThreadListState; + public fun equals (Ljava/lang/Object;)Z + public final fun getThreads ()Ljava/util/List; + public final fun getUnseenThreadsCount ()I + public fun hashCode ()I + public final fun isLoading ()Z + public final fun isLoadingMore ()Z + public fun toString ()Ljava/lang/String; +} + public abstract interface class io/getstream/chat/android/ui/common/utils/ChannelNameFormatter { public static final field Companion Lio/getstream/chat/android/ui/common/utils/ChannelNameFormatter$Companion; public abstract fun formatChannelName (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)Ljava/lang/String; diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt index de0ad6575dc..74192532fc8 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt @@ -1621,24 +1621,38 @@ public class MessageListController( } this.lastSeenMessageId = messageId - cid.cidToTypeAndId().let { (channelType, channelId) -> - if (isInThread) { - // TODO sort out thread unreads when - // https://github.com/GetStream/stream-chat-android/pull/4122 has been merged in - // chatClient.markThreadRead(channelType, channelId, mode.parentMessage.id) - } else { - chatClient.markRead(channelType, channelId).enqueue( - onError = { error -> - logger.e { - "Could not mark cid: $channelId as read. Error message: ${error.message}. " + - "Cause: ${error.extractCause()}" - } - }, - ) - } + if (isInThread) { + markThreadAsRead() + } else { + markChannelAsRead() } } + private fun markChannelAsRead() { + val (channelType, channelId) = cid.cidToTypeAndId() + chatClient.markRead(channelType, channelId).enqueue( + onError = { error -> + logger.e { + "Could not mark cid: $channelId as read. Error message: ${error.message}. " + + "Cause: ${error.extractCause()}" + } + }, + ) + } + + private fun markThreadAsRead() { + val (channelType, channelId) = cid.cidToTypeAndId() + val threadId = (_mode.value as? MessageMode.MessageThread)?.parentMessage?.id ?: return + chatClient.markThreadRead(channelType, channelId, threadId).enqueue( + onError = { error -> + logger.e { + "Could not mark thread with id: $threadId as read. Error message: ${error.message}. " + + "Cause: ${error.extractCause()}" + } + }, + ) + } + /** * Flags the selected message. * @@ -1677,7 +1691,16 @@ public class MessageListController( */ public fun markUnread(message: Message, onResult: (Result) -> Unit = {}) { cid.cidToTypeAndId().let { (channelType, channelId) -> - chatClient.markUnread(channelType, channelId, message.id).enqueue { response -> + val call = when (val mode = mode.value) { + is MessageMode.Normal -> { + chatClient.markUnread(channelType, channelId, messageId = message.id) + } + + is MessageMode.MessageThread -> { + chatClient.markThreadUnread(channelType, channelId, mode.parentMessage.id, messageId = message.id) + } + } + call.enqueue { response -> onResult(response) if (response is Result.Failure) { onActionResult(response.value) { diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/threads/ThreadListState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/threads/ThreadListState.kt index 66b80cba3fb..3cd29cd9df9 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/threads/ThreadListState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/threads/ThreadListState.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.ui.common.state.threads -import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Thread /** @@ -27,7 +26,6 @@ import io.getstream.chat.android.models.Thread * @param isLoadingMore Indicator if there is loading of the next page of threads in progress. * @param unseenThreadsCount The number of threads that we know that exist, but are not (yet) loaded in the list. */ -@InternalStreamChatApi public data class ThreadListState( val threads: List, val isLoading: Boolean,