diff --git a/CHANGELOG.md b/CHANGELOG.md index 39053cb94d6..38645b6985c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ ### ⬆️ Improved ### ✅ Added +- Add `ChatClient::markThreadUnread` to mark a given thread as unread. [#5457](https://github.com/GetStream/stream-chat-android/pull/5457) +- Add `ChannelClient::markThreadUnread` to mark a given thread as unread. [#5457](https://github.com/GetStream/stream-chat-android/pull/5457) ### ⚠️ Changed @@ -71,6 +73,7 @@ ### ⬆️ Improved ### ✅ Added +- Added `ThreadList` component for showing the list of threads for the user. ### ⚠️ Changed diff --git a/docusaurus/docs/Android/assets/compose_default_thread_list_content.png b/docusaurus/docs/Android/assets/compose_default_thread_list_content.png new file mode 100644 index 00000000000..ebad9401cdd Binary files /dev/null and b/docusaurus/docs/Android/assets/compose_default_thread_list_content.png differ diff --git a/docusaurus/docs/Android/assets/compose_default_thread_list_empty.png b/docusaurus/docs/Android/assets/compose_default_thread_list_empty.png new file mode 100644 index 00000000000..e317f07e8fd Binary files /dev/null and b/docusaurus/docs/Android/assets/compose_default_thread_list_empty.png differ diff --git a/docusaurus/docs/Android/compose/thread-components/thread-list.mdx b/docusaurus/docs/Android/compose/thread-components/thread-list.mdx new file mode 100644 index 00000000000..afc2ddc1df8 --- /dev/null +++ b/docusaurus/docs/Android/compose/thread-components/thread-list.mdx @@ -0,0 +1,241 @@ +# Thread List + +`ThreadList` is a composable component which shows an overview of all threads of which the user is a member of. +It shows information about the channel, the thread parent message, the most recent reply in the thread, and the number of unread replies. + +The component is paginated by default, and only the most recently updated threads are loaded initially. Older threads are loaded only when the user scrolls to the end of the thread list. + +While this component is visible, and a new thread is created, or a thread which is not yet loaded is updated, the component will show a banner informing the user about the number of new threads, which the user can then click, to reload the thread list and load the newly updated threads. + +## Usage + +This component is backed by its `ThreadListViewModel`. To instantiate such `ViewModel`, you need to +first create an instance of `ThreadsViewModelFactory`: + +```kotlin +private val threadsViewModelFactory by lazy { + ThreadsViewModelFactory( + threadLimit = /* ... */, + threadReplyLimit = /* ... */, + threadParticipantLimit = /* ... */ + ) +} +``` +The `ThreadsViewModelFactory` accepts three configurable parameters: +* `threadLimit` - The maximum number of threads to be loaded per page (default: `25`). +* `threadReplyLimit` - The maximum number of (latest) replies to be loaded per thread (default: `10`). +* `threadParticipantLimit` - The maximum number of participants to be loaded per thread (default: `10`). + +After the `ThreadsViewModelFactory` is configured, you can instantiate the `ThreadListViewModel` which then can be used to invoke the `ThreadList` composable: + +```kotlin +private val viewModel: ThreadListViewModel by viewModels { threadsViewModelFactory } + +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ChatTheme { + ThreadList( + viewModel = viewModel, + modifier = Modifier.fillMaxSize(), + ) + } + } +} +``` + +This snippet will produce a fully working thread list, together with a loading state, and an empty state for the case without threads: + +| No threads | Thread list + new threads banner | +| --- | --- | +|![Empty](../../assets/compose_default_thread_list_empty.png)|![Loaded](../../assets/compose_default_thread_list_content.png)| + +Alternatively, you can use the stateless version of the `ThreadList` component, which is backed by a +state object `ThreadListState`. + +```kotlin +private val viewModel: ThreadListViewModel by viewModels { threadsViewModelFactory } + +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ChatTheme { + val state by viewModel.state.collectAsStateWithLifecycle() + ThreadList( + state = state, + onThreadClick = { + // Handle thread clicks + }, + onUnreadThreadsBannerClick = { + // Handle banner clicks + }, + onLoadMore = { + // Handle load more + } + ) + } + } +} +``` + +## Handling actions + +The `ThreadList` component exposes several action handlers: + +```kotlin +@Composable +public fun ThreadList( + viewModel: ThreadListViewModel, + onUnreadThreadsBannerClick: () -> Unit = { viewModel.load() }, + onThreadClick: (Thread) -> Unit = {}, + onLoadMore: () -> Unit = { viewModel.loadNextPage() }, +) +``` + +* `onUnreadThreadsBannerClick` - The action to be performed when a user taps on the unread threads banner. +By default, tapping on the banner results in reloading the thread list by calling `ThreadListViewModel.load`, in order to fetch the newly created threads. +You can override this method to provide a custom handling of banner clicks. +* `onThreadClick` - The action to be performed when a user taps on a thread item from the list. +By default, this handler is empty, and you can override it to provide a custom handling of the click. +The lambda provides you a `Thread` instance, which you can use to retrieve any data related the clicked thread. +* `onLoadMore` - The action to be performed when the user scrolls to the bottom of the currently loaded thread list, +and a new page of threads should be loaded. By default, this handler loads the next page of threads by calling `ThreadListViewModel.loadNextPage`, +but you can override it to provide custom behaviour. + +:::note +If you are using the stateless version of the `ThreadList`, you must handle the `onUnreadThreadsBannerClick` and +`onLoadMore` actions by yourself, as the component doesn't provide a default implementation. +::: + +## Customization + +The `ThreadList` component allows customization of the following UI components: + +```kotlin +public fun ThreadList( + modifier: Modifier = Modifier, + unreadThreadsBanner: @Composable (Int) -> Unit = { ... }, + itemContent: @Composable (Thread) -> Unit = { ... }, + emptyContent: @Composable () -> Unit = { ... }, + loadingContent: @Composable () -> Unit = { ... }, + loadingMoreContent: @Composable () -> Unit = { ... }, +) +``` + +* `modifier` - The modifier for the root component. Used for general customization such as size and padding. +* `unreadThreadsBanner` - Composable that represents the banner shown when a new thread is created. +By default, it shows the number of updated threads which are not loaded, and provides a refresh button +which reloads the thread list. The lambda provides an `Int` argument, which represents the number of new/updated threads which are not loaded. +You can use this argument to show the correct number of new/updated threads when overriding this component. +* `itemContent` - Composable that represents a single thread item in the list. This composable is used +to render each item in the list. Override this to provide a customized design of the thread item. +It provides a `Thread` argument, which represents the thread instance for which the item is rendered. +* `emptyContent` - Composable that represents the content shown when there are no threads. Override this to provide a custom empty content. +* `loadingContent` - Composable that represents the content shown during the initial loading of the threads. Override this to provide a custom loading content. +* `loadingMoreContent` - Composable that represents the section shown below the items during the loading of the next batch (page) of threads. Override to provide a custom loading more indicator. + +The most commonly customized component is the `itemContent`. Therefore, there are additional customization options for this component. +The base component for this is the `ThreadItem`: + +```kotlin +@Composable +public fun ThreadItem( + thread: Thread, + currentUser: User?, + onThreadClick: (Thread) -> Unit, + modifier: Modifier = Modifier, + titleContent: @Composable (Channel) -> Unit = { ... }, + replyToContent: @Composable RowScope.(parentMessage: Message) -> Unit = { ... }, + unreadCountContent: @Composable RowScope.(unreadCount: Int) -> Unit = { ... }, + latestReplyContent: @Composable (reply: Message) -> Unit = { ... }, +) +``` + +* `modifier` - The modifier for the root component. You can apply a background, elevation, padding... +* `titleContent` - Composable that represents the title of the thread item. By default, it shows a +thread icon and the channel name in which the thread exists. The lambda provides a `Channel` instance, +which you can utilize to create a custom thread title component. +* `replyToContent` - Composable that represents the parent message of the thread. By default, it shows +a preview of the parent message, with a `replied to:` prefix. The lambda provides a `Message` object, +which represents the parent message of the thread. Override this lambda to provide a custom component +showing the parent message of the thread. +* `unreadCountContent` - Composable representing the unread message count indicator. By default, it +shows a red circle with the number of unread thread messages. The lambda provides an `unreadCount` argument, +which you can use to create a custom unread count indicator. +* `latestReplyContent` - Composable representing the part of the item where the latest thread reply +is rendered. By default, it shows a preview of the latest message in the thread, including the name +and avatar of the user who wrote the message, and a timestamp when the message was created. The lambda +provides a `Message` object, which represents the latest reply in the thread. You can override this +lambda to provide a custom representation of the latest thread reply. + +For example, if you would like to override the title and the latest reply content of the thread items, +you can do: + +```kotlin +ThreadList( + viewModel = viewModel, + itemContent = { thread -> + ThreadItem( + thread = thread, + currentUser = currentUser + titleContent = { channel -> + // Custom thread title + }, + latestReplyContent = { reply -> + // Custom latest reply content + }, + onThreadClick = { thread -> + // Handle item click + } + ) + } +) +``` + +If you would like to keep the same layout as the default item, but you want to show a custom text for +the title, you can also utilize the global channel name formatter `ChannelNameFormatter`, which is +used to format the thread title as well (by default, the thread title shows the channel name). + +```kotlin +class CustomChannelNameFormatter: ChannelNameFormatter { + override fun formatChannelName(channel: Channel, currentUser: User?): String { + // custom channel name formatting + } +} + +// Usage +@Composable +public fun YourScreen() { + ChatTheme( + channelNameFormatter = CustomChannelNameFormatter() // override the default formatter + ) { + ThreadList(viewModel = viewModel) + } +} +``` + +Similarly, you can customize the message preview formatting for the parent message and the reply message, +by overriding the global message preview formatter `MessagePreviewFormatter`: + +```kotlin +class CustomMessagePreviewFormatter: MessagePreviewFormatter { + + // Other methods + + override fun formatMessagePreview(message: Message, currentUser: User?): AnnotatedString { + // Your implementation for customized message text + } +} + +// Usage +@Composable +public fun YourScreen() { + ChatTheme( + messagePreviewFormatter = CustomMessagePreviewFormatter() // override the default formatter + ) { + ThreadList(viewModel = viewModel) + } +} +``` \ No newline at end of file diff --git a/docusaurus/sidebars-android.js b/docusaurus/sidebars-android.js index ca5d5012e8d..97b99d7238b 100644 --- a/docusaurus/sidebars-android.js +++ b/docusaurus/sidebars-android.js @@ -87,6 +87,7 @@ module.exports = { "compose/general-customization/attachment-factory", ], }, + "compose/thread-components/thread-list", { "Utility Components": [ "compose/utility-components/user-avatar", 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 821644964e4..208dbf7227f 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -99,6 +99,7 @@ public final class io/getstream/chat/android/client/ChatClient { public final fun markMessageRead (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun markRead (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun markThreadRead (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; + public final fun markThreadUnread (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun markUnread (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun muteChannel (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun muteChannel (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)Lio/getstream/result/call/Call; @@ -132,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; @@ -476,24 +478,33 @@ public final class io/getstream/chat/android/client/api/models/QueryThreadsReque public fun (Z)V public fun (ZI)V public fun (ZII)V - public fun (ZIII)V - public fun (ZIIII)V - public fun (ZIIIILjava/lang/String;)V - public synthetic fun (ZIIIILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZIILjava/lang/String;)V + public fun (ZIILjava/lang/String;I)V + public fun (ZIILjava/lang/String;ILjava/lang/String;)V + public fun (ZIILjava/lang/String;ILjava/lang/String;I)V + public fun (ZIILjava/lang/String;ILjava/lang/String;ILio/getstream/chat/android/models/User;)V + public fun (ZIILjava/lang/String;ILjava/lang/String;ILio/getstream/chat/android/models/User;Ljava/lang/String;)V + public synthetic fun (ZIILjava/lang/String;ILjava/lang/String;ILio/getstream/chat/android/models/User;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component2 ()I public final fun component3 ()I - public final fun component4 ()I + public final fun component4 ()Ljava/lang/String; public final fun component5 ()I public final fun component6 ()Ljava/lang/String; - public final fun copy (ZIIIILjava/lang/String;)Lio/getstream/chat/android/client/api/models/QueryThreadsRequest; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;ZIIIILjava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/api/models/QueryThreadsRequest; + public final fun component7 ()I + public final fun component8 ()Lio/getstream/chat/android/models/User; + public final fun component9 ()Ljava/lang/String; + public final fun copy (ZIILjava/lang/String;ILjava/lang/String;ILio/getstream/chat/android/models/User;Ljava/lang/String;)Lio/getstream/chat/android/client/api/models/QueryThreadsRequest; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;ZIILjava/lang/String;ILjava/lang/String;ILio/getstream/chat/android/models/User;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/api/models/QueryThreadsRequest; public fun equals (Ljava/lang/Object;)Z public final fun getLimit ()I public final fun getMemberLimit ()I public final fun getNext ()Ljava/lang/String; public final fun getParticipantLimit ()I + public final fun getPrev ()Ljava/lang/String; public final fun getReplyLimit ()I + public final fun getUser ()Lio/getstream/chat/android/models/User; + public final fun getUserId ()Ljava/lang/String; public final fun getWatch ()Z public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -649,6 +660,7 @@ public final class io/getstream/chat/android/client/channel/ChannelClient { public final fun markMessageRead (Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun markRead ()Lio/getstream/result/call/Call; public final fun markThreadRead (Ljava/lang/String;)Lio/getstream/result/call/Call; + public final fun markThreadUnread (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun markUnread (Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun mute ()Lio/getstream/result/call/Call; public final fun mute (Ljava/lang/Integer;)Lio/getstream/result/call/Call; @@ -1301,6 +1313,11 @@ public abstract interface class io/getstream/chat/android/client/events/HasUnrea public abstract fun getUnreadChannels ()I } +public abstract interface class io/getstream/chat/android/client/events/HasUnreadThreadCounts { + public abstract fun getUnreadThreadMessages ()I + public abstract fun getUnreadThreads ()I +} + public abstract interface class io/getstream/chat/android/client/events/HasWatcherCount { public abstract fun getWatcherCount ()I } @@ -1447,7 +1464,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; @@ -1455,14 +1473,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 @@ -1709,9 +1729,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; @@ -1720,17 +1744,21 @@ 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; @@ -1846,6 +1874,35 @@ public final class io/getstream/chat/android/client/events/NotificationRemovedFr public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/client/events/NotificationThreadMessageNewEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadThreadCounts { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/Channel;Ljava/util/Date;Ljava/lang/String;II)V + public final fun component1 ()Ljava/lang/String; + public final fun component10 ()I + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Lio/getstream/chat/android/models/Message; + public final fun component6 ()Lio/getstream/chat/android/models/Channel; + public final fun component7 ()Ljava/util/Date; + public final fun component8 ()Ljava/lang/String; + public final fun component9 ()I + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/Channel;Ljava/util/Date;Ljava/lang/String;II)Lio/getstream/chat/android/client/events/NotificationThreadMessageNewEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationThreadMessageNewEvent;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/Channel;Ljava/util/Date;Ljava/lang/String;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationThreadMessageNewEvent; + public fun equals (Ljava/lang/Object;)Z + public fun getChannel ()Lio/getstream/chat/android/models/Channel; + 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 getMessage ()Lio/getstream/chat/android/models/Message; + public fun getRawCreatedAt ()Ljava/lang/String; + public fun getType ()Ljava/lang/String; + public fun getUnreadThreadMessages ()I + public fun getUnreadThreads ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/client/events/PollClosedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasPoll { public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Poll;)V public final fun component1 ()Ljava/lang/String; @@ -2511,7 +2568,7 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract fun createRepositoryFactory (Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/client/persistance/repository/factory/RepositoryFactory; } -public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener { +public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener { public abstract fun getErrorHandler ()Lio/getstream/chat/android/client/errorhandler/ErrorHandler; public abstract fun onAttachmentSendRequest (Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onChannelMarkReadPrecondition (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -2550,6 +2607,9 @@ public abstract interface class io/getstream/chat/android/client/plugin/Plugin : public abstract fun onQueryChannelsRequest (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onQueryChannelsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onQueryMembersResult (Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun onQueryThreadsPrecondition (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun onQueryThreadsRequest (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun onQueryThreadsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onSendReactionPrecondition (Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Reaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onSendReactionRequest (Ljava/lang/String;Lio/getstream/chat/android/models/Reaction;ZLio/getstream/chat/android/models/User;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onSendReactionResult (Ljava/lang/String;Lio/getstream/chat/android/models/Reaction;ZLio/getstream/chat/android/models/User;Lio/getstream/result/Result;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -2601,6 +2661,9 @@ public final class io/getstream/chat/android/client/plugin/Plugin$DefaultImpls { public static fun onQueryChannelsRequest (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun onQueryChannelsResult (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun onQueryMembersResult (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun onQueryThreadsPrecondition (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun onQueryThreadsRequest (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun onQueryThreadsResult (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun onSendReactionPrecondition (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Reaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun onSendReactionRequest (Lio/getstream/chat/android/client/plugin/Plugin;Ljava/lang/String;Lio/getstream/chat/android/models/Reaction;ZLio/getstream/chat/android/models/User;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun onSendReactionResult (Lio/getstream/chat/android/client/plugin/Plugin;Ljava/lang/String;Lio/getstream/chat/android/models/Reaction;ZLio/getstream/chat/android/models/User;Lio/getstream/result/Result;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -2681,6 +2744,12 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener public abstract fun onQueryMembersResult (Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener { + public abstract fun onQueryThreadsPrecondition (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun onQueryThreadsRequest (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun onQueryThreadsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener { public abstract fun onAttachmentSendRequest (Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } 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 0d506fc7f7f..e6a123e5c74 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 @@ -168,6 +168,7 @@ import io.getstream.chat.android.models.Option import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.PollConfig import io.getstream.chat.android.models.PushMessage +import io.getstream.chat.android.models.QueryThreadsResult import io.getstream.chat.android.models.Reaction import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.Thread @@ -2485,6 +2486,24 @@ internal constructor( } } + /** + * Marks a given thread starting from the given message as unread. + * + * @param channelType Type of the channel. + * @param channelId Id of the channel. + * @param threadId Id of the thread to mark as unread. + * @param messageId Id of the message from where the thread should be marked as unread. + */ + @CheckResult + public fun markThreadUnread( + channelType: String, + channelId: String, + threadId: String, + messageId: String, + ): Call { + return api.markThreadUnread(channelType, channelId, threadId = threadId, messageId = messageId) + } + @CheckResult public fun updateUsers(users: List): Call> { return api.updateUsers(users) @@ -3239,7 +3258,30 @@ internal constructor( public fun queryThreads( query: QueryThreadsRequest, ): Call> { + return queryThreadsResult(query).map { it.threads } + } + + /** + * Query threads matching [query] request. + * + * @param query [QueryThreadsRequest] with query parameters to get matching users. + */ + @CheckResult + public fun queryThreadsResult(query: QueryThreadsRequest): Call { return api.queryThreads(query) + .doOnStart(userScope) { + plugins.forEach { plugin -> + plugin.onQueryThreadsRequest(query) + } + } + .doOnResult(userScope) { result -> + plugins.forEach { plugin -> + plugin.onQueryThreadsResult(result, query) + } + } + .precondition(plugins) { + onQueryThreadsPrecondition(query) + } } /** diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt index 978759f6791..869e4c891c4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt @@ -42,6 +42,7 @@ import io.getstream.chat.android.models.Mute import io.getstream.chat.android.models.Option import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.PollConfig +import io.getstream.chat.android.models.QueryThreadsResult import io.getstream.chat.android.models.Reaction import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.Thread @@ -289,6 +290,14 @@ internal interface ChatApi { messageId: String, ): Call + @CheckResult + fun markThreadUnread( + channelType: String, + channelId: String, + threadId: String, + messageId: String, + ): Call + @CheckResult fun showChannel(channelType: String, channelId: String): Call @@ -466,7 +475,7 @@ internal interface ChatApi { * @param query [QueryThreadsRequest] with query parameters to get matching users. */ @CheckResult - fun queryThreads(query: QueryThreadsRequest): Call> + fun queryThreads(query: QueryThreadsRequest): Call /** * Get a thread by [messageId]. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryThreadsRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryThreadsRequest.kt index 436a82d2b23..dc6e44bb1d7 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryThreadsRequest.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryThreadsRequest.kt @@ -16,22 +16,30 @@ package io.getstream.chat.android.client.api.models +import io.getstream.chat.android.models.User + /** * Query threads request. * * @property watch If true, all the channels corresponding to threads returned in response will be watched. * Defaults to true. - * @property replyLimit The number of latest replies to fetch per thread. Defaults to 2. - * @property participantLimit The number of thread participants to request per thread. Defaults to 100. - * @property memberLimit The number of members to request per thread. Defaults to 100. - * @property limit The number of threads to return. Defaults to 10. * @property limit The number of threads to return. Defaults to 10. + * @property memberLimit The number of members to request per thread. Defaults to 100. + * @property next The next pagination token. This token can be used to fetch the next page of threads. + * @property participantLimit The number of thread participants to request per thread. Defaults to 100. + * @property prev The previous pagination token. This token can be used to fetch the previous page of threads. + * @property replyLimit The number of latest replies to fetch per thread. Defaults to 2. + * @property user The user for which the threads are queried. Defaults to null. + * @property userId The user ID for which the threads are queried. Defaults to null. */ public data class QueryThreadsRequest @JvmOverloads constructor( public val watch: Boolean = true, - public val replyLimit: Int = 2, - public val participantLimit: Int = 100, - public val memberLimit: Int = 100, public val limit: Int = 10, + public val memberLimit: Int = 100, public val next: String? = null, + public val participantLimit: Int = 100, + public val prev: String? = null, + public val replyLimit: Int = 2, + public val user: User? = null, + public val userId: String? = null, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt index dad1f94aa7a..e845f7905d5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt @@ -113,6 +113,7 @@ import io.getstream.chat.android.models.Mute import io.getstream.chat.android.models.Option import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.PollConfig +import io.getstream.chat.android.models.QueryThreadsResult import io.getstream.chat.android.models.Reaction import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.Thread @@ -721,6 +722,22 @@ constructor( ).toUnitCall() } + override fun markThreadUnread( + channelType: String, + channelId: String, + threadId: String, + messageId: String, + ): Call { + return channelApi.markUnread( + channelType = channelType, + channelId = channelId, + request = MarkUnreadRequest( + thread_id = threadId, + message_id = messageId, + ), + ).toUnitCall() + } + override fun markAllRead(): Call { return channelApi.markAllRead().toUnitCall() } @@ -1102,28 +1119,32 @@ constructor( /** * Queries a list of threads for the current user. * - * @param replyLimit The number of latest replies to fetch per thread. Defaults to 2. - * @param participantLimit The number of thread participants to request per thread. Defaults to 100. - * @param limit The number of threads to return. Defaults to 10. - * @param watch If true, all the channels corresponding to threads returned in response will be watched. - * Defaults to true. - * @param memberLimit The number of members to request per thread. Defaults to 100. + * @param query The [QueryThreadsRequest] model holding the data relevant for the `queryThreads` call. */ override fun queryThreads( query: QueryThreadsRequest, - ): Call> { + ): Call { val lazyQueryThreads = { threadsApi.queryThreads( connectionId, io.getstream.chat.android.client.api2.model.requests.QueryThreadsRequest( - reply_limit = query.replyLimit, - participant_limit = query.participantLimit, - limit = query.limit, watch = query.watch, + limit = query.limit, member_limit = query.memberLimit, next = query.next, + participant_limit = query.participantLimit, + prev = query.prev, + reply_limit = query.replyLimit, + user = query.user?.toDto(), + user_id = query.userId, ), - ).map { response -> response.threads.map { it.toDomain(currentUserIdProvider()) } } + ).map { response -> + QueryThreadsResult( + threads = response.threads.map { it.toDomain(currentUserIdProvider()) }, + prev = response.prev, + next = response.next, + ) + } } return if (connectionId.isBlank() && query.watch) { logger.i { "[queryThreads] postponing because an active connection is required" } 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 bef3b8fd996..0afbacef99a 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 @@ -56,6 +56,7 @@ import io.getstream.chat.android.client.api2.model.dto.NotificationMarkUnreadEve import io.getstream.chat.android.client.api2.model.dto.NotificationMessageNewEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationMutesUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationRemovedFromChannelEventDto +import io.getstream.chat.android.client.api2.model.dto.NotificationThreadMessageNewEventDto import io.getstream.chat.android.client.api2.model.dto.PollClosedEventDto import io.getstream.chat.android.client.api2.model.dto.PollDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.PollUpdatedEventDto @@ -112,6 +113,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 @@ -178,6 +180,7 @@ internal fun ChatEventDto.toDomain(currentUserId: UserId?): ChatEvent { is NotificationMarkReadEventDto -> toDomain(currentUserId) is NotificationMarkUnreadEventDto -> toDomain(currentUserId) is NotificationMessageNewEventDto -> toDomain(currentUserId) + is NotificationThreadMessageNewEventDto -> toDomain(currentUserId) is NotificationMutesUpdatedEventDto -> toDomain(currentUserId) is NotificationRemovedFromChannelEventDto -> toDomain(currentUserId) is ReactionDeletedEventDto -> toDomain(currentUserId) @@ -352,6 +355,7 @@ private fun MessageReadEventDto.toDomain(currentUserId: UserId?): MessageReadEve cid = cid, channelType = channel_type, channelId = channel_id, + thread = thread?.toDomain(currentUserId), ) } @@ -490,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, ) } @@ -537,6 +545,21 @@ private fun NotificationMessageNewEventDto.toDomain(currentUserId: UserId?): Not ) } +private fun NotificationThreadMessageNewEventDto.toDomain(currentUserId: UserId?): NotificationThreadMessageNewEvent { + return NotificationThreadMessageNewEvent( + type = type, + cid = cid, + channelId = channel_id, + channelType = channel_type, + message = message.toDomain(currentUserId), + channel = channel.toDomain(currentUserId), + createdAt = created_at.date, + rawCreatedAt = created_at.rawDate, + unreadThreads = unread_threads, + unreadThreadMessages = unread_thread_messages, + ) +} + private fun NotificationMutesUpdatedEventDto.toDomain(currentUserId: UserId?): NotificationMutesUpdatedEvent { return NotificationMutesUpdatedEvent( type = type, 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 e07217c01e9..5b513ab5558 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,28 +17,53 @@ 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 = Thread( + activeParticipantCount = active_participant_count ?: 0, cid = channel_cid, - channelInfo = channel.toDomain(), + channel = channel?.toDomain(currentUserId), parentMessageId = parent_message_id, parentMessage = parent_message.toDomain(currentUserId), createdByUserId = created_by_user_id, - createdBy = created_by.toDomain(currentUserId), + createdBy = created_by?.toDomain(currentUserId), replyCount = reply_count, participantCount = participant_count, - threadParticipants = thread_participants.map { it.toDomain(currentUserId) }, + threadParticipants = thread_participants.orEmpty().map { it.toDomain(currentUserId) }, lastMessageAt = last_message_at, createdAt = created_at, updatedAt = updated_at, + deletedAt = deleted_at, title = title, latestReplies = latest_replies.map { it.toDomain(currentUserId) }, - read = read.map { it.toDomain(currentUserId, last_message_at) }, + 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, + threadParticipants = thread_participants.orEmpty().map { it.toDomain(currentUserId) }, + title = title, + updatedAt = updated_at, + ) + +internal fun DownstreamThreadParticipantDto.toDomain(currentUserId: UserId?): ThreadParticipant = ThreadParticipant( + user = user?.toDomain(currentUserId), + userId = user_id, +) 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 a462bc0bc8b..02a4e1e114f 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) @@ -306,6 +311,19 @@ internal data class NotificationMessageNewEventDto( val unread_channels: Int = 0, ) : ChatEventDto() +@JsonClass(generateAdapter = true) +internal data class NotificationThreadMessageNewEventDto( + val type: String, + val cid: String, + val channel_id: String, + val channel_type: String, + val message: DownstreamMessageDto, + val channel: DownstreamChannelDto, + val created_at: ExactDate, + val unread_threads: Int, + val unread_thread_messages: Int, +) : ChatEventDto() + @JsonClass(generateAdapter = true) internal data class NotificationMutesUpdatedEventDto( val type: String, 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 fd2b9d674f1..92d60fef688 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,7 +21,9 @@ 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. * @param channel: The channel info. * @param parent_message_id: The parent message ID. @@ -34,38 +36,80 @@ import java.util.Date * @param last_message_at: The date of the last message in the thread. * @param created_at: The date when the thread was created. * @param updated_at: The date when the thread was updated. + * @param deleted_at: The date when the thread was deleted. * @param title: The title of the thread. * @param latest_replies: The latest replies in the thread. * @param read: The read states of the thread. */ @JsonClass(generateAdapter = true) internal data class DownstreamThreadDto( + val active_participant_count: Int?, val channel_cid: String, - val channel: ChannelInfoDto, + val channel: DownstreamChannelDto?, val parent_message_id: String, val parent_message: DownstreamMessageDto, val created_by_user_id: String, - val created_by: DownstreamUserDto, + val created_by: DownstreamUserDto?, val reply_count: Int, val participant_count: Int, - val thread_participants: List, + 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, - val read: List, + val read: List?, ) /** - * 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 thread_participants: List?, + 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: DownstreamUserDto?, val user_id: String, - val user: DownstreamUserDto, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkUnreadRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkUnreadRequest.kt index f9914c0d56e..c50ee27d505 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkUnreadRequest.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkUnreadRequest.kt @@ -21,4 +21,5 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) internal data class MarkUnreadRequest( val message_id: String, + val thread_id: String? = null, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryThreadsRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryThreadsRequest.kt index 12725dfb738..8cc1c8b8b04 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryThreadsRequest.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryThreadsRequest.kt @@ -18,25 +18,31 @@ package io.getstream.chat.android.client.api2.model.requests import com.squareup.moshi.JsonClass import io.getstream.chat.android.client.api2.endpoint.ThreadsApi +import io.getstream.chat.android.client.api2.model.dto.UpstreamUserDto /** * Used for querying threads. * @see [ThreadsApi.queryThreads] * * @param watch If true, all the channels corresponding to threads returned in response will be watched. - * Defaults to true. - * @param reply_limit The number of latest replies to fetch per thread. Defaults to 2. Max limit is 10. - * @param participant_limit The number of thread participants to request per thread. Defaults to 100. Max limit is 100. - * @param member_limit The number of members to request per thread. Defaults to 100. Max limit is 100. * @param limit The number of threads to return. Defaults to 10. Max limit is 25. + * @param member_limit The number of members to request per thread. Defaults to 100. Max limit is 100. * @param next The next pagination token. This token can be used to fetch the next page of threads. + * @param participant_limit The number of thread participants to request per thread. Defaults to 100. Max limit is 100. + * @param prev The previous pagination token. This token can be used to fetch the previous page of threads. + * @param reply_limit The number of latest replies to fetch per thread. Defaults to 2. Max limit is 10. + * @param user The user for which the threads are queried. Defaults to null. + * @param user_id The user ID for which the threads are queried. Defaults to null. */ @JsonClass(generateAdapter = true) internal data class QueryThreadsRequest( val watch: Boolean = true, - val reply_limit: Int = 2, - val participant_limit: Int = 100, - val member_limit: Int = 100, val limit: Int = 10, + val member_limit: Int = 100, val next: String? = null, + val participant_limit: Int = 100, + val prev: String? = null, + val reply_limit: Int = 2, + val user: UpstreamUserDto? = null, + val user_id: String? = null, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryThreadsResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryThreadsResponse.kt index f464d044aee..776b185e1f1 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryThreadsResponse.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryThreadsResponse.kt @@ -25,9 +25,13 @@ import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadDto * * @param threads: The list of threads. * @param duration: The duration of the request. + * @param prev: The identifier for the previous page of threads. + * @param next: The identifier for the next page of threads. */ @JsonClass(generateAdapter = true) internal data class QueryThreadsResponse( val threads: List, val duration: String, + val prev: String?, + val next: String?, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt index f0943e13b1d..17198caa168 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt @@ -62,6 +62,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 @@ -217,7 +218,7 @@ public class ChannelClient internal constructor( } } - @Suppress("ComplexMethod") + @Suppress("ComplexMethod", "LongMethod") private fun isRelevantForChannel(event: ChatEvent): Boolean { return when (event) { is ChannelDeletedEvent -> event.cid == cid @@ -242,6 +243,7 @@ public class ChannelClient internal constructor( is NotificationMarkReadEvent -> event.cid == cid is NotificationMarkUnreadEvent -> event.cid == cid is NotificationMessageNewEvent -> event.cid == cid + is NotificationThreadMessageNewEvent -> event.cid == cid is NotificationRemovedFromChannelEvent -> event.cid == cid is ReactionDeletedEvent -> event.cid == cid is ReactionNewEvent -> event.cid == cid @@ -420,6 +422,17 @@ public class ChannelClient internal constructor( return client.markUnread(channelType, channelId, messageId) } + /** + * Marks a given thread in the channel starting from the given message as unread. + * + * @param messageId Id of the message from where the thread should be marked as unread. + * @param threadId Id of the thread to mark as unread. + */ + @CheckResult + public fun markThreadUnread(threadId: String, messageId: String): Call { + return client.markThreadUnread(channelType, channelId, threadId = threadId, messageId = messageId) + } + @CheckResult public fun markRead(): Call { return client.markRead(channelType, channelId) 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 e171347e746..8f73a3a57c4 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 @@ -104,6 +105,17 @@ public sealed interface HasUnreadCounts { public val unreadChannels: Int } +/** + * Interface that marks a [ChatEvent] as having the information about unread thread counts. + * + * The list of events which contains unread counts: + * - notification.thread_message_new + */ +public sealed interface HasUnreadThreadCounts { + public val unreadThreads: Int + public val unreadThreadMessages: Int +} + /** * Triggered when a channel is deleted */ @@ -267,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 /** @@ -413,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 /** @@ -462,6 +479,22 @@ public data class NotificationMessageNewEvent( override val unreadChannels: Int = 0, ) : CidEvent(), HasChannel, HasMessage, HasUnreadCounts +/** + * Triggered when a message is added to a channel as a thread reply. + */ +public data class NotificationThreadMessageNewEvent( + override val type: String, + override val cid: String, + override val channelId: String, + override val channelType: String, + override val message: Message, + override val channel: Channel, + override val createdAt: Date, + override val rawCreatedAt: String?, + override val unreadThreads: Int, + override val unreadThreadMessages: Int, +) : CidEvent(), HasMessage, HasChannel, HasUnreadThreadCounts + /** * Triggered when the user mutes are updated */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt index 6fb43a5a2a4..4a097af2ed9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt @@ -58,6 +58,7 @@ import io.getstream.chat.android.client.api2.model.dto.NotificationMarkUnreadEve import io.getstream.chat.android.client.api2.model.dto.NotificationMessageNewEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationMutesUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationRemovedFromChannelEventDto +import io.getstream.chat.android.client.api2.model.dto.NotificationThreadMessageNewEventDto import io.getstream.chat.android.client.api2.model.dto.PollClosedEventDto import io.getstream.chat.android.client.api2.model.dto.PollDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.PollUpdatedEventDto @@ -123,6 +124,8 @@ internal class EventDtoAdapter( private val notificationMarkUnreadEventAdapter = moshi.adapter(NotificationMarkUnreadEventDto::class.java) private val markAllReadEventAdapter = moshi.adapter(MarkAllReadEventDto::class.java) private val notificationMessageNewEventAdapter = moshi.adapter(NotificationMessageNewEventDto::class.java) + private val notificationThreadMessageNewEventAdapter = + moshi.adapter(NotificationThreadMessageNewEventDto::class.java) private val notificationInvitedEventAdapter = moshi.adapter(NotificationInvitedEventDto::class.java) private val notificationInviteAcceptedEventAdapter = moshi.adapter(NotificationInviteAcceptedEventDto::class.java) private val notificationInviteRejectedEventAdapter = moshi.adapter(NotificationInviteRejectedEventDto::class.java) @@ -196,6 +199,7 @@ internal class EventDtoAdapter( } EventType.NOTIFICATION_MARK_UNREAD -> notificationMarkUnreadEventAdapter EventType.NOTIFICATION_MESSAGE_NEW -> notificationMessageNewEventAdapter + EventType.NOTIFICATION_THREAD_MESSAGE_NEW -> notificationThreadMessageNewEventAdapter EventType.NOTIFICATION_INVITED -> notificationInvitedEventAdapter EventType.NOTIFICATION_INVITE_ACCEPTED -> notificationInviteAcceptedEventAdapter EventType.NOTIFICATION_INVITE_REJECTED -> notificationInviteRejectedEventAdapter diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt index fed44a71668..4abf9c222f2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.plugin import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.errorhandler.ErrorHandler import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.plugin.listeners.ChannelMarkReadListener @@ -34,6 +35,7 @@ import io.getstream.chat.android.client.plugin.listeners.MarkAllReadListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener import io.getstream.chat.android.client.plugin.listeners.QueryMembersListener +import io.getstream.chat.android.client.plugin.listeners.QueryThreadsListener import io.getstream.chat.android.client.plugin.listeners.SendAttachmentListener import io.getstream.chat.android.client.plugin.listeners.SendGiphyListener import io.getstream.chat.android.client.plugin.listeners.SendMessageListener @@ -45,6 +47,7 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Member 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.User import io.getstream.chat.android.models.querysort.QuerySorter @@ -76,7 +79,8 @@ public interface Plugin : CreateChannelListener, DeleteChannelListener, GetMessageListener, - FetchCurrentUserListener { + FetchCurrentUserListener, + QueryThreadsListener { public fun getErrorHandler(): ErrorHandler? = null @@ -390,4 +394,14 @@ public interface Plugin : ) { /* No-Op */ } + + override suspend fun onQueryThreadsPrecondition(request: QueryThreadsRequest): Result = Result.Success(Unit) + + override suspend fun onQueryThreadsRequest(request: QueryThreadsRequest) { + /* No-Op */ + } + + override suspend fun onQueryThreadsResult(result: Result, request: QueryThreadsRequest) { + /* No-Op */ + } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener.kt new file mode 100644 index 00000000000..30006467806 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener.kt @@ -0,0 +1,53 @@ +/* + * 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.client.plugin.listeners + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryThreadsRequest +import io.getstream.chat.android.models.QueryThreadsResult +import io.getstream.result.Result + +/** + * Listener for [ChatClient.queryChannels] requests. + */ +public interface QueryThreadsListener { + + /** + * Run precondition for the request. If it returns [Result.Success] then the request is run otherwise it returns + * [Result.Failure] and no request is made. + * + * @param request [QueryThreadsRequest] which is going to be used for the request. + * + * @return [Result.Success] if precondition passes otherwise [Result.Failure] + */ + public suspend fun onQueryThreadsPrecondition(request: QueryThreadsRequest): Result + + /** + * Runs side effect before the request is launched. + * + * @param request [QueryThreadsRequest] which is going to be used for the request. + */ + public suspend fun onQueryThreadsRequest(request: QueryThreadsRequest) + + /** + * Runs side effect the request was completed. + * + * @param result The [Result] containing the successfully retrieved [QueryThreadsResult] or the error. + * @param request [QueryThreadsRequest] which is was used for the request. + */ + public suspend fun onQueryThreadsResult(result: Result, request: QueryThreadsRequest) +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/WhenMarkUnreadThread.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/WhenMarkUnreadThread.kt new file mode 100644 index 00000000000..89befb49a6c --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/WhenMarkUnreadThread.kt @@ -0,0 +1,62 @@ +/* + * 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.client.chatclient + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.test.TestCall +import io.getstream.chat.android.test.callFrom +import io.getstream.result.Error +import io.getstream.result.Result +import io.getstream.result.call.Call +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever + +internal class WhenMarkUnreadThread : BaseChatClientTest() { + + @Test + fun `Given markUnread api call successful ChatClient should return success result`() = runTest { + val apiResult = callFrom { } + val sut = Fixture().givenMarkUnreadApiResult(apiResult).get() + + val result = sut.markThreadUnread("channelType", "channelId", "threadId", "messageId").await() + + result shouldBeInstanceOf Result.Success::class + } + + @Test + fun `Given markUnread api call fails ChatClient should return error result`() = runTest { + val apiResult = TestCall(Result.Failure(Error.GenericError("Error"))) + val sut = Fixture().givenMarkUnreadApiResult(apiResult).get() + + val result = sut.markThreadUnread("channelType", "channelId", "threadId", "messageId").await() + + result shouldBeInstanceOf Result.Failure::class + } + + private inner class Fixture { + + fun givenMarkUnreadApiResult(result: Call) = apply { + whenever(api.markThreadUnread(any(), any(), any(), any())) doReturn result + } + + fun get(): ChatClient = chatClient + } +} 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 a57bc90308c..2cdfcf521bc 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -3065,6 +3065,74 @@ public final class io/getstream/chat/android/compose/ui/theme/messages/composer/ public final fun defaultTheme (ZLio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Landroidx/compose/runtime/Composer;II)Lio/getstream/chat/android/compose/ui/theme/messages/composer/attachments/AudioRecordingAttachmentPreviewTheme; } +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/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/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 { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListKt; + 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-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 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-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 class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$UnreadThreadsBannerKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$UnreadThreadsBannerKt; + 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 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 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; @@ -3505,3 +3573,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 new file mode 100644 index 00000000000..81c403d0be5 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadItem.kt @@ -0,0 +1,350 @@ +/* + * 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.ui.threads + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.getstream.chat.android.client.utils.message.isDeleted +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.Timestamp +import io.getstream.chat.android.compose.ui.components.avatar.UserAvatar +import io.getstream.chat.android.compose.ui.components.channels.UnreadCountIndicator +import io.getstream.chat.android.compose.ui.theme.ChatTheme +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 + +/** + * The basic Thread item, showing information about the Thread title, parent message, last reply and number of unread + * replies. + * + * @param thread The [Thread] object holding the data to be rendered. + * @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 +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 + .fillMaxWidth() + .combinedClickable( + onClick = { onThreadClick(thread) }, + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + ) + .padding(horizontal = 8.dp, vertical = 14.dp), + ) { + thread.channel?.let { channel -> + titleContent(channel) + } + val unreadCount = unreadCountForUser(thread, currentUser) + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + replyToContent(thread.parentMessage) + unreadCountContent(unreadCount) + } + thread.latestReplies.lastOrNull()?.let { 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 +internal fun DefaultThreadTitle( + channel: Channel, + currentUser: User?, +) { + val title = ChatTheme.channelNameFormatter.formatChannelName(channel, currentUser) + Row(modifier = Modifier.fillMaxWidth()) { + Icon( + painter = painterResource(id = R.drawable.stream_compose_ic_thread), + contentDescription = null, + tint = ChatTheme.colors.textHighEmphasis, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = title, + color = ChatTheme.colors.textHighEmphasis, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ChatTheme.typography.bodyBold, + ) + } +} + +/** + * Default representation of the parent message preview in a thread. + * + * @param parentMessage The parent message of the thread. + */ +@Composable +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, + ) + } +} + +/** + * Default representation of the latest reply content in a thread. + * + * @param reply The latest reply [Message] in the thread. + */ +@Composable +internal fun DefaultLatestReplyContent(reply: Message) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) { + UserAvatar( + modifier = Modifier.size(ChatTheme.dimens.channelAvatarSize), + user = reply.user, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = reply.user.name, + style = ChatTheme.typography.bodyBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ChatTheme.colors.textHighEmphasis, + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val text = formatMessage(reply) + Text( + modifier = Modifier.weight(1f), + text = text, + maxLines = 1, + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + style = ChatTheme.typography.body, + color = ChatTheme.colors.textLowEmphasis, + ) + Timestamp( + modifier = Modifier.padding(start = 8.dp), + date = reply.updatedAt, + ) + } + } + } +} + +private fun unreadCountForUser(thread: Thread, user: User?) = + thread.read + .find { it.user.id == user?.id } + ?.unreadMessages + ?: 0 + +@Composable +private fun formatMessage(message: Message) = + if (message.isDeleted()) { + buildAnnotatedString { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(stringResource(id = R.string.stream_ui_message_list_message_deleted)) + } + } + } else { + ChatTheme.messagePreviewFormatter.formatMessagePreview(message, null) + } + +@Composable +@Preview +private fun ThreadItemPreview() { + ChatTheme { + Surface { + val user1 = User(id = "uid1", name = "User 1") + val user2 = User(id = "uid2", name = "User 2") + val thread = Thread( + activeParticipantCount = 2, + cid = "cid", + channel = Channel(), + parentMessageId = "pmid1", + parentMessage = Message( + id = "pmid1", + text = "Hey everyone, who's up for a group ride this Saturday morning?", + ), + createdByUserId = "uid2", + createdBy = user2, + replyCount = 3, + participantCount = 2, + threadParticipants = listOf( + ThreadParticipant(user1, user1.id), + ThreadParticipant(user2, user2.id), + ), + lastMessageAt = Date(), + createdAt = Date(), + updatedAt = Date(), + deletedAt = null, + title = "Group ride preparation and discussion", + latestReplies = listOf( + Message(id = "mid1", text = "See you all there, stay safe on the roads!", user = user1), + ), + read = listOf( + ChannelUserRead( + user = user2, + lastReceivedEventDate = Date(), + unreadMessages = 3, + lastRead = Date(), + lastReadMessageId = null, + ), + ), + ) + ThreadItem( + thread = thread, + currentUser = user2, + onThreadClick = {}, + ) + } + } +} + +@Composable +@Preview +private fun DefaultThreadTitlePreview() { + ChatTheme { + Surface { + DefaultThreadTitle( + channel = Channel( + id = "messaging:123", + type = "messaging", + name = "Group ride preparation and discussion", + ), + currentUser = null, + ) + } + } +} + +@Composable +@Preview +private fun DefaultUnreadCountContentPreview() { + ChatTheme { + Row { + DefaultUnreadCountContent(unreadCount = 17) + } + } +} + +@Composable +@Preview +private fun ThreadParentMessageContentPreview() { + ChatTheme { + Row { + val parentMessage = Message( + id = "message1", + cid = "messaging:123", + text = "Hey everyone, who's up for a group ride this Saturday morning?", + ) + 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 new file mode 100644 index 00000000000..46f280b87ba --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadList.kt @@ -0,0 +1,372 @@ +/* + * 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.ui.threads + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.LoadingIndicator +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. + * Optionally, it renders a banner informing about new threads/thread messages outside of the loaded pages of threads. + * + * @param viewModel The [ThreadListViewModel] handling the loading of the threads. + * @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. By default, it + * calls [ThreadListViewModel.load] to force reload the list of threads, loading the newly created/updated threads. + * @param onThreadClick Action invoked when the usr clicks on a thread item in the list. No-op by default. + * @param onLoadMore Action invoked when the current thread page was scrolled to the end, and a next page should be + * loaded. By default, it calls [ThreadListViewModel.loadNextPage] to load the next page of threads. + * @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( + viewModel: ThreadListViewModel, + modifier: Modifier = Modifier, + currentUser: User? = ChatClient.instance().getCurrentUser(), + onUnreadThreadsBannerClick: () -> Unit = { viewModel.load() }, + onThreadClick: (Thread) -> Unit = {}, + onLoadMore: () -> Unit = { viewModel.loadNextPage() }, + 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() + }, +) { + 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) + }, + content = { padding -> + Box(modifier = modifier.padding(padding)) { + when { + state.isLoading -> loadingContent() + state.threads.isEmpty() -> emptyContent() + else -> Threads( + threads = state.threads, + isLoadingMore = state.isLoadingMore, + modifier = modifier, + onLoadMore = onLoadMore, + itemContent = itemContent, + loadingMoreContent = loadingMoreContent, + ) + } + } + }, + ) +} + +/** + * Composable representing a non-empty list of threads. + * + * @param threads The non-empty [List] of [Thread]s to show. + * @param isLoadingMore Indicator if there is loading of the next page of threads in progress. + * @param modifier [Modifier] instance for general styling. + * @param onLoadMore Action invoked when the current thread page was scrolled to the end, and a next page should be + * loaded. + * @param itemContent Composable rendering each [Thread] item in the list. + * @param loadingMoreContent Composable shown at the bottom of the list during the loading of more threads (pagination). + */ +@Suppress("LongParameterList") +@Composable +private fun Threads( + threads: List, + isLoadingMore: Boolean, + modifier: Modifier, + onLoadMore: () -> Unit, + itemContent: @Composable (Thread) -> Unit, + loadingMoreContent: @Composable () -> Unit, +) { + val listState = rememberLazyListState() + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() + val totalItemsCount = listState.layoutInfo.totalItemsCount + lastVisibleItem != null && + totalItemsCount - LoadMoreThreshold > 0 && + lastVisibleItem.index >= totalItemsCount - LoadMoreThreshold + } + } + Box(modifier = modifier) { + LazyColumn(state = listState) { + items(threads) { thread -> + itemContent(thread) + } + if (isLoadingMore) { + item { + loadingMoreContent() + } + } + } + } + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadMore() + } + } +} + +/** + * The default item rendering the unread threads banner. + * + * @param unreadThreads The number of unread threads. + * @param onClick The action invoked when the user clicks on the banner. + */ +@Composable +internal fun DefaultUnreadThreadsBanner( + unreadThreads: Int, + onClick: () -> Unit, +) { + UnreadThreadsBanner( + unreadThreads = unreadThreads, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + onClick = onClick, + ) +} + +/** + * The default item rendering a [Thread]. + * + * @param thread The [Thread] to render. + * @param currentUser The currently logged in [User]. + * @param onThreadClick The action invoked when the user clicks on a thread item. + */ +@Composable +internal fun DefaultThreadItem( + thread: Thread, + currentUser: User?, + onThreadClick: (Thread) -> Unit, +) { + ThreadItem( + thread = thread, + currentUser = currentUser, + onThreadClick = onThreadClick, + ) +} + +/** + * The default empty placeholder that is displayed when there are no threads. + * + * @param modifier Modifier for styling. + */ +@Composable +internal fun DefaultThreadListEmptyContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + modifier = Modifier.size(112.dp), + painter = painterResource(R.drawable.stream_compose_ic_threads_empty), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.stream_compose_thread_list_empty_title), + textAlign = TextAlign.Center, + color = ChatTheme.colors.textLowEmphasis, + fontSize = 20.sp, + lineHeight = 25.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The default loading content that is displayed during the initial loading of the threads. + * + * @param modifier Modifier for styling. + */ +@Composable +internal fun DefaultThreadListLoadingContent(modifier: Modifier = Modifier) { + Box(modifier = modifier.background(ChatTheme.colors.appBackground)) { + LoadingIndicator(modifier) + } +} + +/** + * The default content shown on the bottom of the list during the loading of more threads. + */ +@Composable +internal fun DefaultThreadListLoadingMoreContent() { + Box( + modifier = Modifier + .fillMaxWidth() + .background(ChatTheme.colors.appBackground) + .padding(top = 8.dp, start = 8.dp, end = 8.dp, bottom = 40.dp), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator(modifier = Modifier.size(16.dp)) + } +} + +/** + * Default load more threshold - Trigger the loading of the next page of items, if the user scrolls to the N-th element + * from the end of the list. + */ +private const val LoadMoreThreshold = 10 + +@Preview +@Composable +private fun DefaultThreadListEmptyContentPreview() { + ChatTheme { + Surface { + DefaultThreadListEmptyContent() + } + } +} + +@Preview +@Composable +private fun DefaultThreadListLoadingContentPreview() { + ChatTheme { + Surface { + DefaultThreadListLoadingContent() + } + } +} + +@Preview +@Composable +private fun DefaultThreadListLoadingMoreContentPreview() { + ChatTheme { + Surface { + DefaultThreadListLoadingMoreContent() + } + } +} 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 new file mode 100644 index 00000000000..af6d0b2b8fc --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/UnreadThreadsBanner.kt @@ -0,0 +1,118 @@ +/* + * 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.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 +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatTheme + +/** + * Composable a banner showing the number of unread threads. + * It will not be shown if [unreadThreads] is zero. + * + * @param unreadThreads The number of unread threads. + * @param modifier [Modifier] instance for general styling. + * @param onClick Action invoked when the user clicks on the banner. + */ +@Composable +public fun UnreadThreadsBanner( + unreadThreads: Int, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + if (unreadThreads <= 0) { + // Don't show if there are no new threads + return + } + val clickableModifier = if (onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + } + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .background(ChatTheme.colors.textHighEmphasis, RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) + .then(clickableModifier) + .padding(start = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val text = pluralStringResource(R.plurals.stream_compose_thread_list_new_threads, unreadThreads, unreadThreads) + Text( + modifier = Modifier.weight(1f), + text = text, + fontSize = 16.sp, + color = ChatTheme.colors.barsBackground, + lineHeight = 20.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + IconButton( + onClick = {}, + ) { + Icon( + painter = painterResource(id = R.drawable.stream_compose_ic_union), + contentDescription = "Reload threads", + tint = ChatTheme.colors.barsBackground, + ) + } + } +} + +@Composable +@Preview +private fun UnreadThreadsBannerPreview() { + ChatTheme { + Surface { + 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 new file mode 100644 index 00000000000..dd1eadbe1d3 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt @@ -0,0 +1,53 @@ +/* + * 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.viewmodel.threads + +import androidx.lifecycle.ViewModel +import io.getstream.chat.android.ui.common.feature.threads.ThreadListController +import io.getstream.chat.android.ui.common.state.threads.ThreadListState +import kotlinx.coroutines.flow.StateFlow + +/** + * ViewModel responsible for managing the state of a threads list. + * + * @param controller The [ThreadListController] handling the business logic and the state management for the threads + * list. + */ +public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel() { + + /** + * The current thread list state. + */ + public val state: StateFlow = controller.state + + /** + * Loads the initial data when requested. + * Overrides all previously retrieved data. + */ + public fun load() { + controller.load() + } + + /** + * Loads more data when requested. + * + * Does nothing if the end of the list has already been reached or loading is already in progress. + */ + 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 new file mode 100644 index 00000000000..3b866c1ffac --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadsViewModelFactory.kt @@ -0,0 +1,51 @@ +/* + * 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.viewmodel.threads + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.getstream.chat.android.ui.common.feature.threads.ThreadListController + +/** + * A ViewModel factory for creating a [ThreadListViewModel]. + * + * @see ThreadListViewModel + * + * @param threadLimit The number of threads to load per page. + * @param threadReplyLimit The number of replies per thread to load. + * @param threadParticipantLimit The number of participants per thread to load. + */ +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, +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + require(modelClass == ThreadListViewModel::class.java) { + "ThreadsViewModelFactory can only create instances of ThreadListViewModel" + } + @Suppress("UNCHECKED_CAST") + return ThreadListViewModel( + controller = ThreadListController( + threadLimit = threadLimit, + threadReplyLimit = threadReplyLimit, + threadParticipantLimit = threadParticipantLimit, + ), + ) as T + } +} diff --git a/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_threads_empty.xml b/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_threads_empty.xml new file mode 100644 index 00000000000..602bdbf230e --- /dev/null +++ b/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_threads_empty.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_union.xml b/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_union.xml new file mode 100644 index 00000000000..f202b52456e --- /dev/null +++ b/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_union.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/stream-chat-android-compose/src/main/res/values/strings.xml b/stream-chat-android-compose/src/main/res/values/strings.xml index b6f5b3640d4..0337193015b 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -163,6 +163,7 @@ You Also sent to the channel Replied to a thread + replied to:  Image options @@ -234,4 +235,11 @@ Confirm Dismiss View Comments + + + No threads here yet... + + %d new thread + %d new threads + 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 9545c83ac09..b5baa246774 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -817,6 +817,7 @@ public final class io/getstream/chat/android/models/EventType { public static final field NOTIFICATION_MESSAGE_NEW Ljava/lang/String; public static final field NOTIFICATION_MUTES_UPDATED Ljava/lang/String; public static final field NOTIFICATION_REMOVED_FROM_CHANNEL Ljava/lang/String; + public static final field NOTIFICATION_THREAD_MESSAGE_NEW Ljava/lang/String; public static final field POLL_CLOSED Ljava/lang/String; public static final field POLL_DELETED Ljava/lang/String; public static final field POLL_UPDATED Ljava/lang/String; @@ -1491,6 +1492,21 @@ public final class io/getstream/chat/android/models/PushProvider$Companion { public final fun fromKey (Ljava/lang/String;)Lio/getstream/chat/android/models/PushProvider; } +public final class io/getstream/chat/android/models/QueryThreadsResult { + public fun (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/models/QueryThreadsResult; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/QueryThreadsResult;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/QueryThreadsResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getNext ()Ljava/lang/String; + public final fun getPrev ()Ljava/lang/String; + public final fun getThreads ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/models/Reaction : io/getstream/chat/android/models/CustomObject { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;ILio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/SyncStatus;Ljava/util/Map;Z)V @@ -1673,30 +1689,34 @@ public final class io/getstream/chat/android/models/SyncStatus$Companion { } public final class io/getstream/chat/android/models/Thread { - public fun (Ljava/lang/String;Lio/getstream/chat/android/models/ChannelInfo;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)V - public final fun component1 ()Ljava/lang/String; - public final fun component10 ()Ljava/util/Date; + public fun (ILjava/lang/String;Lio/getstream/chat/android/models/Channel;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)V + public final fun component1 ()I + public final fun component10 ()Ljava/util/List; public final fun component11 ()Ljava/util/Date; public final fun component12 ()Ljava/util/Date; - public final fun component13 ()Ljava/lang/String; - public final fun component14 ()Ljava/util/List; - public final fun component15 ()Ljava/util/List; - public final fun component2 ()Lio/getstream/chat/android/models/ChannelInfo; - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Lio/getstream/chat/android/models/Message; - public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Lio/getstream/chat/android/models/User; - public final fun component7 ()I + public final fun component13 ()Ljava/util/Date; + public final fun component14 ()Ljava/util/Date; + public final fun component15 ()Ljava/lang/String; + public final fun component16 ()Ljava/util/List; + public final fun component17 ()Ljava/util/List; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lio/getstream/chat/android/models/Channel; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Lio/getstream/chat/android/models/Message; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Lio/getstream/chat/android/models/User; public final fun component8 ()I - public final fun component9 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Lio/getstream/chat/android/models/ChannelInfo;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lio/getstream/chat/android/models/Thread; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/Thread;Ljava/lang/String;Lio/getstream/chat/android/models/ChannelInfo;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/Thread; + public final fun component9 ()I + public final fun copy (ILjava/lang/String;Lio/getstream/chat/android/models/Channel;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lio/getstream/chat/android/models/Thread; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/Thread;ILjava/lang/String;Lio/getstream/chat/android/models/Channel;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/Thread; public fun equals (Ljava/lang/Object;)Z - public final fun getChannelInfo ()Lio/getstream/chat/android/models/ChannelInfo; + public final fun getActiveParticipantCount ()I + public final fun getChannel ()Lio/getstream/chat/android/models/Channel; 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 getLatestReplies ()Ljava/util/List; public final fun getParentMessage ()Lio/getstream/chat/android/models/Message; @@ -1711,6 +1731,56 @@ 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/util/List;Ljava/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/util/List; + public final fun component13 ()Ljava/lang/String; + public final fun component14 ()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/util/List;Ljava/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/util/List;Ljava/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 getThreadParticipants ()Ljava/util/List; + 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 { + public fun (Lio/getstream/chat/android/models/User;Ljava/lang/String;)V + public final fun component1 ()Lio/getstream/chat/android/models/User; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lio/getstream/chat/android/models/User;Ljava/lang/String;)Lio/getstream/chat/android/models/ThreadParticipant; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/ThreadParticipant;Lio/getstream/chat/android/models/User;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/ThreadParticipant; + public fun equals (Ljava/lang/Object;)Z + public final fun getUser ()Lio/getstream/chat/android/models/User; + public final 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/EventType.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt index 5acea5a768f..5a5b0505728 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt @@ -50,6 +50,7 @@ public object EventType { public const val CHANNEL_TRUNCATED: String = "channel.truncated" public const val HEALTH_CHECK: String = "health.check" public const val NOTIFICATION_MESSAGE_NEW: String = "notification.message_new" + public const val NOTIFICATION_THREAD_MESSAGE_NEW: String = "notification.thread_message_new" public const val NOTIFICATION_CHANNEL_TRUNCATED: String = "notification.channel_truncated" public const val NOTIFICATION_CHANNEL_DELETED: String = "notification.channel_deleted" public const val NOTIFICATION_MARK_READ: String = "notification.mark_read" diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/QueryThreadsResult.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/QueryThreadsResult.kt new file mode 100644 index 00000000000..281040caf04 --- /dev/null +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/QueryThreadsResult.kt @@ -0,0 +1,33 @@ +/* + * 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 representing the result of a 'Query Threads' operation. + * + * @param threads: The list of threads. + * @param prev: The identifier for the previous page of threads. + * @param next: The identifier for the next page of threads. + */ +@Immutable +public data class QueryThreadsResult( + val threads: List, + val prev: String?, + val next: String?, +) 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 d1072655ed7..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,20 +19,45 @@ 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, val cid: String, - val channelInfo: ChannelInfo, + val channel: Channel?, val parentMessageId: String, val parentMessage: Message, val createdByUserId: String, - val createdBy: User, + 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, val read: 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..42cebb45dc6 --- /dev/null +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadInfo.kt @@ -0,0 +1,58 @@ +/* + * 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 threadParticipants: List, + 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..8cab2fc1166 --- /dev/null +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ThreadParticipant.kt @@ -0,0 +1,31 @@ +/* + * 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). + * @param userId The Id of the thread participant. + */ +@Immutable +public data class ThreadParticipant( + val user: User?, + val userId: String, +) 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 ddbbb2553d7..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; @@ -125,6 +127,7 @@ public final class io/getstream/chat/android/state/plugin/state/StateRegistry { public final fun channel (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/client/channel/state/ChannelState; public final fun clear ()V public final fun queryChannels (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState; + public final fun queryThreads ()Lio/getstream/chat/android/state/plugin/state/querythreads/QueryThreadsState; public final fun thread (Ljava/lang/String;)Lio/getstream/chat/android/state/plugin/state/channel/thread/ThreadState; } @@ -192,6 +195,14 @@ public abstract interface class io/getstream/chat/android/state/plugin/state/que public abstract fun setChatEventHandlerFactory (Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;)V } +public abstract interface class io/getstream/chat/android/state/plugin/state/querythreads/QueryThreadsState { + public abstract fun getLoading ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getLoadingMore ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getNext ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getThreads ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getUnseenThreadIds ()Lkotlinx/coroutines/flow/StateFlow; +} + public class io/getstream/chat/android/state/utils/Event { public fun (Ljava/lang/Object;)V public final fun getContentIfNotHandled ()Ljava/lang/Object; 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 18319f7d0ca..65838be463d 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 @@ -267,6 +268,7 @@ internal class EventHandlerSequential( updateGlobalState(event) updateChannelsState(event) updateOfflineStorage(event) + updateQueryThreadsState(event) updateThreadState(event) logger.v { "[handleBatchEvent] <<< id: ${event.id}" } } catch (e: Throwable) { @@ -295,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) @@ -323,9 +329,15 @@ 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) { @@ -423,6 +435,11 @@ internal class EventHandlerSequential( logger.v { "[updateChannelsState] completed batchId: ${batchEvent.id}" } } + private fun updateQueryThreadsState(batchEvent: BatchEvent) { + logger.v { "[updateQueryThreadsState] batchEvent.size: ${batchEvent.size}" } + logicRegistry.threads().handleEvents(batchEvent.sortedEvents) + } + private fun updateThreadState(batchEvent: BatchEvent) { logger.v { "[updateThreadState] batchEvent.size: ${batchEvent.size}" } val sortedEvents: List = batchEvent.sortedEvents 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 00383f7c39e..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 @@ -25,6 +25,7 @@ import android.os.Environment import androidx.annotation.CheckResult import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.client.utils.internal.validateCidWithResult @@ -47,6 +48,7 @@ import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.channel.thread.ThreadState import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import io.getstream.result.Error @@ -60,7 +62,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import java.text.SimpleDateFormat import java.util.Date @@ -145,6 +146,23 @@ public fun ChatClient.watchChannelAsState( } } +/** + * Performs [ChatClient.queryThreadsResult] under the hood and returns [QueryThreadsState]. + * The [QueryThreadsState] cannot be created before connecting the user therefore, the method returns a StateFlow + * that emits a null when the user has not been connected yet and the new value every time the user changes. + * + * @param request The [QueryThreadsRequest] used to perform the query threads operation. + * @return A [StateFlow] emitting changes in the [QueryThreadsState]. + */ +public fun ChatClient.queryThreadsAsState( + request: QueryThreadsRequest, + coroutineScope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), +): StateFlow { + return getStateOrNull(coroutineScope) { + requestsAsState(coroutineScope).queryThreads(request) + } +} + /** * Same class of ChatClient.getReplies, but provides the result as [ThreadState] * @@ -177,7 +195,6 @@ public suspend fun ChatClient.getRepliesAsState( * @param messageId The ID of the original message the replies were made to. * @param messageLimit The number of messages that will be initially loaded. * @param olderToNewer The flag that determines the order of the messages. - * @param coroutineScope The [CoroutineScope] used for executing the request. * * @return [ThreadState] wrapped inside a [Call]. */ @@ -353,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/internal/StatePlugin.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt index be354a69764..0434f530ab0 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt @@ -30,6 +30,7 @@ import io.getstream.chat.android.client.plugin.listeners.HideChannelListener import io.getstream.chat.android.client.plugin.listeners.MarkAllReadListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener +import io.getstream.chat.android.client.plugin.listeners.QueryThreadsListener import io.getstream.chat.android.client.plugin.listeners.SendAttachmentListener import io.getstream.chat.android.client.plugin.listeners.SendGiphyListener import io.getstream.chat.android.client.plugin.listeners.SendMessageListener @@ -52,6 +53,7 @@ import io.getstream.chat.android.state.plugin.listener.internal.HideChannelListe import io.getstream.chat.android.state.plugin.listener.internal.MarkAllReadListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryChannelListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryChannelsListenerState +import io.getstream.chat.android.state.plugin.listener.internal.QueryThreadsListenerState import io.getstream.chat.android.state.plugin.listener.internal.SendAttachmentListenerState import io.getstream.chat.android.state.plugin.listener.internal.SendGiphyListenerState import io.getstream.chat.android.state.plugin.listener.internal.SendMessageListenerState @@ -110,7 +112,8 @@ public class StatePlugin internal constructor( SendMessageListener by SendMessageListenerState(logic), TypingEventListener by TypingEventListenerState(stateRegistry), SendAttachmentListener by SendAttachmentListenerState(logic), - FetchCurrentUserListener by FetchCurrentUserListenerState(clientState, globalState) { + FetchCurrentUserListener by FetchCurrentUserListenerState(clientState, globalState), + QueryThreadsListener by QueryThreadsListenerState(logic) { private val lazyErrorHandler: ErrorHandler by lazy { errorHandlerFactory.create() } override fun getErrorHandler(): ErrorHandler = lazyErrorHandler 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 new file mode 100644 index 00000000000..3ad96b2101b --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryThreadsListenerState.kt @@ -0,0 +1,44 @@ +/* + * 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.listener.internal + +import io.getstream.chat.android.client.api.models.QueryThreadsRequest +import io.getstream.chat.android.client.plugin.listeners.QueryThreadsListener +import io.getstream.chat.android.models.QueryThreadsResult +import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.result.Result + +/** + * [QueryThreadsListener] implementation for the [StatePlugin]. + * 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.threads().onQueryThreadsPrecondition(request) + } + + override suspend fun onQueryThreadsRequest(request: QueryThreadsRequest) { + logic.threads().onQueryThreadsRequest(request) + } + + override suspend fun onQueryThreadsResult(result: Result, request: QueryThreadsRequest) { + 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/internal/ChannelLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt index 65a2afc11b5..c73bef8cc4f 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt @@ -59,6 +59,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 @@ -549,6 +550,9 @@ internal class ChannelLogic( channelStateLogic.updateCurrentUserRead(event.createdAt, event.message) channelStateLogic.toggleHidden(false) } + is NotificationThreadMessageNewEvent -> { + upsertEventMessage(event.message) + } is ReactionNewEvent -> { upsertEventMessage(event.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 b1ad388b796..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 @@ -34,6 +34,8 @@ import io.getstream.chat.android.state.plugin.logic.channel.thread.internal.Thre 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 @@ -46,7 +48,8 @@ import java.util.concurrent.ConcurrentHashMap * Registry-container for logic objects related to: * 1. Query channels * 2. Query channel - * 3. Query thread + * 3. Query threads + * 4. Query thread */ @Suppress("LongParameterList") internal class LogicRegistry internal constructor( @@ -64,6 +67,14 @@ internal class LogicRegistry internal constructor( private val queryChannels: ConcurrentHashMap>, QueryChannelsLogic> = ConcurrentHashMap() private val channels: ConcurrentHashMap, ChannelLogic> = ConcurrentHashMap() + + // Note: At the moment, there is no need for multiple instances of QueryThreadsLogic, as we always load all threads, + // without the option for filtering. Update this is we decide to support different queries. + private val queryThreads: QueryThreadsLogic = QueryThreadsLogic( + stateLogic = QueryThreadsStateLogic( + mutableState = stateRegistry.mutableQueryThreads(), + ), + ) private val threads: ConcurrentHashMap = ConcurrentHashMap() internal fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsLogic { @@ -200,6 +211,11 @@ internal class LogicRegistry internal constructor( return channel(channelType, channelId).stateLogic() } + /** + * Provides the [QueryThreadsLogic] handling the business logic and state management related to thread queries. + */ + fun threads(): QueryThreadsLogic = queryThreads + /** Returns [ThreadLogic] of thread replies with parent message that has id equal to [messageId]. */ fun thread(messageId: String): ThreadLogic { return threads.getOrPut(messageId) { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt new file mode 100644 index 00000000000..1bcf8d2df5e --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt @@ -0,0 +1,200 @@ +/* + * 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.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.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.models.Message +import io.getstream.chat.android.models.QueryThreadsResult +import io.getstream.log.taggedLogger +import io.getstream.result.Error +import io.getstream.result.Result + +/** + * Logic class for "Query Threads" operations. + * + * @param stateLogic The [QueryThreadsStateLogic] managing the global state of the threads list. + */ +internal class QueryThreadsLogic(private val stateLogic: QueryThreadsStateLogic) { + + private val logger by taggedLogger("Chat:QueryThreadsLogic") + + /** + * Run precondition for the request. If it returns [Result.Success] then the request is run otherwise it returns + * [Result.Failure] and no request is made. + * + * @param request [QueryThreadsRequest] which is going to be used for the request. + * + * @return [Result.Success] if precondition passes otherwise [Result.Failure] + */ + internal fun onQueryThreadsPrecondition(request: QueryThreadsRequest): Result { + if (stateLogic.isLoading()) { + val errorMsg = "Already loading the threads, ignoring all other load requests." + logger.d { errorMsg } + return Result.Failure(Error.GenericError(errorMsg)) + } + return if (stateLogic.isLoadingMore() && request.isNextPageRequest()) { + val errorMsg = "Already loading the next page of threads, ignoring all other next page requests." + logger.d { errorMsg } + Result.Failure(Error.GenericError(errorMsg)) + } else { + Result.Success(Unit) + } + } + + /** + * Handles the actions that are needed to update the threads state before the attempt to load the threads + * from the network. + * + * @param request The [QueryThreadsRequest] used to fetch the threads. + */ + internal fun onQueryThreadsRequest(request: QueryThreadsRequest) { + if (request.isNextPageRequest()) { + stateLogic.setLoadingMore(true) + } else { + stateLogic.setLoading(true) + } + } + + /** + * Handles the actions that are needed to update the threads state after the loading of the threads from network was + * completed. + * + * @param result The [Result] holding the [QueryThreadsResult] if the operation was successful, or an [Error] if the + * operation failed. + * @param request The [QueryThreadsRequest] used to fetch the threads. + */ + internal fun onQueryThreadsResult(result: Result, request: QueryThreadsRequest) { + stateLogic.setLoadingMore(false) + stateLogic.setLoading(false) + when (result) { + is Result.Success -> { + if (request.isNextPageRequest()) { + stateLogic.appendThreads(result.value.threads) + } else { + stateLogic.setThreads(result.value.threads) + stateLogic.clearUnseenThreadIds() + } + stateLogic.setNext(result.value.next) + } + + is Result.Failure -> { + logger.i { "[queryThreadsResult] with request: $request failed." } + } + } + } + + /** + * Handles the given [List] of [ChatEvent]s by updating the threads state. + * + * @param events The [List] of [ChatEvent]s to handle. + */ + 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) { + // Destructive operation - remove the threads completely from the list + is NotificationChannelDeletedEvent -> deleteThreadsFromChannel(event.cid) + // Informs about a new thread (loaded, not loaded, or newly created thread) + is NotificationThreadMessageNewEvent -> onNewThreadMessageNotification(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) + else -> Unit + } + } + + private fun QueryThreadsRequest.isNextPageRequest() = this.next != null + + 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() + if (threads.none { it.parentMessageId == newMessageThreadId }) { + stateLogic.addUnseenThreadId(newMessageThreadId) + } + // If the thread is loaded, it will be updated by message.new + message.updated events + } + + /** + * Updates the thread in which the message has been updated. + * + * @param message The updated [Message]. + */ + private fun updateParentOrReply(message: Message) { + val parentUpdated = stateLogic.updateParent(parent = message) + if (!parentUpdated) { + stateLogic.upsertReply(reply = message) + } + } + + /** + * 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, + ) + } + + /** + * Deletes all threads associated with the channel with [cid]. + * Use when the channel was deleted. + */ + private fun deleteThreadsFromChannel(cid: String) { + val threads = stateLogic.getThreads() + val filteredThreads = threads.filterNot { it.cid == cid } + stateLogic.setThreads(filteredThreads) + } +} 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..98424f6e816 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogic.kt @@ -0,0 +1,296 @@ +/* + * 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]. + */ +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.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, + threadParticipants = threadInfo.threadParticipants, + title = threadInfo.title, + updatedAt = threadInfo.updatedAt, + read = updatedRead, + ) + } else { + thread + } + } + setThreads(updatedThreads) + } + + 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, userId = reply.user.id), + 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.userId == newParticipant.userId }) { + return participants + listOf(newParticipant) + } + // Update + return participants.map { participant -> + if (participant.userId == newParticipant.userId) { + 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/StateRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt index 98f6c765d57..f5cce7b0093 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt @@ -29,6 +29,8 @@ import io.getstream.chat.android.state.plugin.state.channel.thread.ThreadState import io.getstream.chat.android.state.plugin.state.channel.thread.internal.ThreadMutableState import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querychannels.internal.QueryChannelsMutableState +import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState +import io.getstream.chat.android.state.plugin.state.querythreads.internal.QueryThreadsMutableState import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -57,6 +59,10 @@ public class StateRegistry constructor( private val queryChannels: ConcurrentHashMap>, QueryChannelsMutableState> = ConcurrentHashMap() private val channels: ConcurrentHashMap, ChannelMutableState> = ConcurrentHashMap() + + // Note: At the moment, there is no need for multiple instances of QueryThreadsMutableState, as we always load all + // threads, without the option for filtering. Update this is we decide to support different queries. + private val queryThreads: QueryThreadsMutableState = QueryThreadsMutableState() private val threads: ConcurrentHashMap = ConcurrentHashMap() /** @@ -113,6 +119,16 @@ public class StateRegistry constructor( return channels.containsKey(channelType to channelId) } + /** + * Returns a [QueryThreadsState] holding the current state of the threads data. + */ + public fun queryThreads(): QueryThreadsState = queryThreads + + /** + * Returns a [QueryThreadsState] holding the current state of the threads data. + */ + internal fun mutableQueryThreads(): QueryThreadsMutableState = queryThreads + /** * Returns [ThreadState] of thread replies with parent message that has id equal to [messageId]. * @@ -146,6 +162,7 @@ public class StateRegistry constructor( queryChannels.clear() channels.forEach { it.value.destroy() } channels.clear() + queryThreads.destroy() threads.forEach { it.value.destroy() } threads.clear() } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt index 3892925405c..3fa4bf37a14 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.state.plugin.state.internal import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.models.Message @@ -28,6 +29,7 @@ import io.getstream.chat.android.state.model.querychannels.pagination.internal.Q import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.channel.thread.ThreadState import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState import io.getstream.log.taggedLogger import io.getstream.result.call.Call import io.getstream.result.call.launch @@ -94,6 +96,19 @@ internal class ChatClientStateCalls( return queryChannel(channelType, channelId, request) } + /** + * Runs the [ChatClient.queryThreadsResult] operation with the provided [QueryThreadsRequest], and returns the + * [QueryThreadsState]. + * + * @param request The [QueryThreadsRequest] used to perform the query threads operation. + */ + internal suspend fun queryThreads(request: QueryThreadsRequest): QueryThreadsState { + chatClient.queryThreadsResult(request).launch(scope) + return deferredState + .await() + .queryThreads() + } + /** Reference request of the get thread replies query. */ internal suspend fun getReplies( messageId: String, diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querythreads/QueryThreadsState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querythreads/QueryThreadsState.kt new file mode 100644 index 00000000000..a22489165e4 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querythreads/QueryThreadsState.kt @@ -0,0 +1,44 @@ +/* + * 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 + +import io.getstream.chat.android.models.Thread +import kotlinx.coroutines.flow.StateFlow + +/** + * Contains the state related to threads queries. + */ +public interface QueryThreadsState { + + /** Sorted list of [Thread]s. */ + public val threads: StateFlow> + + /** Indicator if the current state is being loaded. */ + public val loading: StateFlow + + /** Indicator if the current state is loading more threads (a next page is being loaded). */ + public val loadingMore: StateFlow + + /** + * The identifier for the next page of threads. + * null-value represents that the last page is loaded and there are no more threads to load. + */ + public val next: StateFlow + + /** The IDs of the threads which exist, but are not (yet) loaded in the paginated list of threads. */ + public val unseenThreadIds: StateFlow> +} 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 new file mode 100644 index 00000000000..43c19b72a80 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableState.kt @@ -0,0 +1,186 @@ +/* + * 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 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 +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** + * Mutable state holder of [QueryThreadsState] type. + */ +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!! + override val loadingMore: StateFlow = _loadingMore!! + override val next: StateFlow = _next!! + override val unseenThreadIds: StateFlow> = _unseenThreadIds!! + + /** + * Updates the loading state. Will be true only during the initial load, or during a full reload. + * + * @param loading The new loading state. + */ + internal fun setLoading(loading: Boolean) { + _loading?.value = loading + } + + /** + * Updates the loading more state. Will be true only during the loading of pages after the first one. + * + * @param loading The new loading more state. + */ + internal fun setLoadingMore(loading: Boolean) { + _loadingMore?.value = loading + } + + /** + * Updates the threads state with the new [threads] list. Overwrites the current threads list. + * + * @param threads The new threads state. + */ + internal fun setThreads(threads: List) { + _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() + } + + /** + * Deletes a [Message] from a [Thread] in the state. + * + * @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 clearThreads() { + _threadMap.clear() + // Update the public threadList + _threads?.value = _threadMap.values.toList() + } + + /** + * Updates the identifier for the next page of threads. + * + * @param next The next page identifier. + */ + internal fun setNext(next: String?) { + _next?.value = next + } + + /** + * Adds a new thread to the set of unseen thread IDs. + * + * @param id The ID of the new [Thread]. + */ + internal fun addUnseenThreadId(id: String) { + _unseenThreadIds?.update { set -> + val mutableUnseenThreadIds = set.toMutableSet() + mutableUnseenThreadIds.add(id) + mutableUnseenThreadIds + } + } + + /** + * Clears the set of unseen thread IDs. + */ + internal fun clearUnseenThreadIds() { + _unseenThreadIds?.value = emptySet() + } + + /** + * Clears all data from the state. + */ + internal fun destroy() { + _threadMap.clear() + _threads = null + _loading = null + _loadingMore = null + _next = null + _unseenThreadIds = 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..a81419087f3 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsLogicTest.kt @@ -0,0 +1,701 @@ +/* + * 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.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"), "usrId1"), + ThreadParticipant(User(id = "usrId2"), "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()).appendThreads(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)).appendThreads(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()).appendThreads(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 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, + threadParticipants = emptyList(), + 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..d8282fa82a2 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querythreads/internal/QueryThreadsStateLogicTest.kt @@ -0,0 +1,541 @@ +/* + * 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"), "usrId1"), + ThreadParticipant(User(id = "usrId2"), "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 appendThreads Should update mutableState`() { + // given + val mutableState = mock() + doNothing().whenever(mutableState).upsertThreads(any()) + val logic = QueryThreadsStateLogic(mutableState) + // when + logic.appendThreads(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"), "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, + threadParticipants = emptyList(), + 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, + threadParticipants = listOf( + ThreadParticipant(User(id = "usrId1"), "usrId1"), + ThreadParticipant(User(id = "usrId2"), "usrId2"), + ), + 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, + threadParticipants = threadInfo.threadParticipants, + 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) + } +} 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..fd69f8a54ac --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querythreads/internal/QueryThreadsMutableStateTest.kt @@ -0,0 +1,335 @@ +/* + * 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.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(null, "usrId1"), + ThreadParticipant(null, "usrId1"), + ), + 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(null, "usrId1"), + ThreadParticipant(null, "usrId1"), + ), + 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 1e7e941bce6..00c480006d5 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 @@ -390,6 +390,9 @@ public abstract interface class io/getstream/chat/android/ui/common/feature/mess public final class io/getstream/chat/android/ui/common/feature/messages/list/MessagePositionHandler$Companion { } +public final class io/getstream/chat/android/ui/common/feature/threads/ThreadListController$Companion { +} + public abstract interface class io/getstream/chat/android/ui/common/helper/ClipboardHandler { public abstract fun copyMessage (Lio/getstream/chat/android/models/Message;)V } @@ -1613,6 +1616,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 b275d2e8892..a1a85078a80 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 @@ -1619,24 +1619,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. * diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt new file mode 100644 index 00000000000..4b10cb264be --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt @@ -0,0 +1,143 @@ +/* + * 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.ui.common.feature.threads + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryThreadsRequest +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider +import io.getstream.chat.android.state.extensions.queryThreadsAsState +import io.getstream.chat.android.ui.common.state.threads.ThreadListState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +/** + * Controller responsible for managing the Threads list state. It serves as a central place for the state management and + * business logic related to the Threads list. + * + * @param threadLimit The number of threads to load per page. + * @param threadReplyLimit The number of replies per thread to load. + * @param threadParticipantLimit The number of participants per thread to load. + * @param chatClient The [ChatClient] instance for retrieving the Threads related data. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@InternalStreamChatApi +public class ThreadListController( + private val threadLimit: Int, + private val threadReplyLimit: Int, + private val threadParticipantLimit: Int, + private val chatClient: ChatClient = ChatClient.instance(), +) { + + /** + * Exposes the current thread list state. + */ + private val _state: MutableStateFlow = MutableStateFlow(INITIAL_STATE) + public val state: StateFlow + get() = _state + + private val scope = chatClient.inheritScope { DispatcherProvider.IO } + private val query = QueryThreadsRequest( + limit = threadLimit, + replyLimit = threadReplyLimit, + participantLimit = threadParticipantLimit, + watch = true, + ) + private val queryThreadsState = chatClient.queryThreadsAsState( + request = query, + coroutineScope = scope, + ) + + init { + scope.launch { + queryThreadsState + .filterNotNull() + .flatMapLatest { + combine( + it.threads, + it.loading, + it.loadingMore, + it.unseenThreadIds, + ) { threads, loading, loadingMore, unseenThreadIds -> + ThreadListState(threads, loading, loadingMore, unseenThreadIds.size) + } + } + .collectLatest { + _state.value = it + } + } + } + + /** + * Force loads the first page of threads. + */ + public fun load() { + chatClient.queryThreadsResult(query = query).enqueue() + } + + /** + * Loads the next page of threads (if possible). + */ + public fun loadNextPage() { + if (!shouldLoadNextPage()) return + val next = queryThreadsState.value?.next?.value + val nextPageQuery = query.copy(next = next) + chatClient.queryThreadsResult(query = nextPageQuery).enqueue() + } + + private fun shouldLoadNextPage(): Boolean { + val currentState = _state.value + if (currentState.isLoading || currentState.isLoadingMore) { + return false + } + // Load next page only if the 'next' param exists + return queryThreadsState.value?.next?.value != null + } + + public companion object { + /** + * Default value for the thread limit. + */ + @InternalStreamChatApi + public const val DEFAULT_THREAD_LIMIT: Int = 25 + + /** + * Default value for the thread reply limit. + */ + @InternalStreamChatApi + public const val DEFAULT_THREAD_REPLY_LIMIT: Int = 10 + + /** + * Default value for the thread participant limit. + */ + @InternalStreamChatApi + public const val DEFAULT_THREAD_PARTICIPANT_LIMIT: Int = 10 + + private val INITIAL_STATE = ThreadListState( + threads = emptyList(), + isLoading = true, + isLoadingMore = false, + unseenThreadsCount = 0, + ) + } +} 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 new file mode 100644 index 00000000000..3cd29cd9df9 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/threads/ThreadListState.kt @@ -0,0 +1,34 @@ +/* + * 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.ui.common.state.threads + +import io.getstream.chat.android.models.Thread + +/** + * Represents the Threads list state, used to render the Threads list UI. + * + * @param threads The list of loaded [Thread]s. + * @param isLoading Indicator if the threads are loading. + * @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. + */ +public data class ThreadListState( + val threads: List, + val isLoading: Boolean, + val isLoadingMore: Boolean, + val unseenThreadsCount: Int, +)