From c324f6fed451b9291db189ee0745127a7fc2cf02 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Mon, 25 Nov 2024 15:27:00 +0100 Subject: [PATCH 01/22] [AND-3] Implement ThreadListView for XML SDK. --- .../src/main/res/values/strings.xml | 2 +- .../api/stream-chat-android-ui-components.api | 118 ++ .../ui/feature/threads/list/ThreadListView.kt | 239 ++++ .../threads/list/ThreadListViewStyle.kt | 373 ++++++ .../list/internal/ThreadListAdapter.kt | 240 ++++ .../threads/list/internal/ThreadListItem.kt | 51 + .../chat/android/ui/helper/TransformStyle.kt | 4 + .../threads/ThreadListViewBinding.kt | 39 + .../viewmodel/threads/ThreadListViewModel.kt | 54 + .../threads/ThreadsViewModelFactory.kt | 51 + .../main/res/drawable/stream_ui_ic_thread.xml | 27 + .../drawable/stream_ui_ic_threads_empty.xml | 27 + .../main/res/drawable/stream_ui_ic_union.xml | 27 + .../stream_ui_shape_unread_threads_banner.xml | 20 + .../res/layout/stream_ui_item_thread_list.xml | 97 ++ ...tream_ui_item_thread_list_loading_more.xml | 39 + .../res/layout/stream_ui_thread_list_view.xml | 101 ++ .../src/main/res/values/attrs.xml | 1 + .../res/values/attrs_thread_list_view.xml | 122 ++ .../main/res/values/strings_thread_list.xml | 24 + .../src/main/res/values/styles.xml | 1062 +++++++++++++---- 21 files changed, 2461 insertions(+), 257 deletions(-) create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory.kt create mode 100644 stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_thread.xml create mode 100644 stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_threads_empty.xml create mode 100644 stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_union.xml create mode 100644 stream-chat-android-ui-components/src/main/res/drawable/stream_ui_shape_unread_threads_banner.xml create mode 100644 stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list.xml create mode 100644 stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list_loading_more.xml create mode 100644 stream-chat-android-ui-components/src/main/res/layout/stream_ui_thread_list_view.xml create mode 100644 stream-chat-android-ui-components/src/main/res/values/attrs_thread_list_view.xml create mode 100644 stream-chat-android-ui-components/src/main/res/values/strings_thread_list.xml 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 0337193015b..0549fc3e955 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -237,7 +237,7 @@ View Comments - No threads here yet... + No threads here yet… %d new thread %d new threads diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index bb7b5dc55d3..ef56376df8c 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -3742,6 +3742,104 @@ public final class io/getstream/chat/android/ui/feature/search/list/SearchResult public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/ui/feature/threads/list/ThreadListView : androidx/constraintlayout/widget/ConstraintLayout { + public fun (Landroid/content/Context;)V + public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V + public final fun setLoadMoreListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$LoadMoreListener;)V + public final fun setThreadClickListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$ThreadClickListener;)V + public final fun setUnreadThreadsBannerClickListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$UnreadThreadsBannerClickListener;)V + public final fun showLoading ()V + public final fun showThreads (Ljava/util/List;Z)V + public final fun showUnreadThreadsBanner (I)V +} + +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/ThreadListView$LoadMoreListener { + public abstract fun onLoadMore ()V +} + +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/ThreadListView$ThreadClickListener { + public abstract fun onThreadClick (Lio/getstream/chat/android/models/Thread;)V +} + +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/ThreadListView$UnreadThreadsBannerClickListener { + public abstract fun onUnreadThreadsBannerClick ()V +} + +public final class io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle : io/getstream/chat/android/ui/helper/ViewStyle { + public fun (ILandroid/graphics/drawable/Drawable;Ljava/lang/String;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIIIIIII)V + public final fun component1 ()I + public final fun component10 ()Landroid/graphics/drawable/Drawable; + public final fun component11 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component12 ()Landroid/graphics/drawable/Drawable; + public final fun component13 ()Landroid/graphics/drawable/Drawable; + public final fun component14 ()I + public final fun component15 ()I + public final fun component16 ()I + public final fun component17 ()I + public final fun component18 ()I + public final fun component19 ()I + public final fun component2 ()Landroid/graphics/drawable/Drawable; + public final fun component20 ()I + public final fun component21 ()I + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component5 ()Landroid/graphics/drawable/Drawable; + public final fun component6 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component7 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component8 ()Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle; + public final fun component9 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun copy (ILandroid/graphics/drawable/Drawable;Ljava/lang/String;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIIIIIII)Lio/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle;ILandroid/graphics/drawable/Drawable;Ljava/lang/String;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIIIIIIIILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle; + public fun equals (Ljava/lang/Object;)Z + public final fun getBackgroundColor ()I + public final fun getBannerBackground ()Landroid/graphics/drawable/Drawable; + public final fun getBannerIcon ()Landroid/graphics/drawable/Drawable; + public final fun getBannerMarginBottom ()I + public final fun getBannerMarginLeft ()I + public final fun getBannerMarginRight ()I + public final fun getBannerMarginTop ()I + public final fun getBannerPaddingBottom ()I + public final fun getBannerPaddingLeft ()I + public final fun getBannerPaddingRight ()I + public final fun getBannerPaddingTop ()I + public final fun getBannerTextStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun getEmptyStateDrawable ()Landroid/graphics/drawable/Drawable; + public final fun getEmptyStateText ()Ljava/lang/String; + public final fun getEmptyStateTextStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun getLatestReplyStyle ()Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle; + public final fun getThreadIconDrawable ()Landroid/graphics/drawable/Drawable; + public final fun getThreadReplyToStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun getThreadTitleStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun getUnreadCountBadgeBackground ()Landroid/graphics/drawable/Drawable; + public final fun getUnreadCountBadgeTextStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { + public abstract fun getStableId ()J +} + +public final class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$LoadingMoreItem : io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { + public static final field INSTANCE Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$LoadingMoreItem; + public fun equals (Ljava/lang/Object;)Z + public fun getStableId ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem : io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { + public fun (Lio/getstream/chat/android/models/Thread;)V + public final fun component1 ()Lio/getstream/chat/android/models/Thread; + public final fun copy (Lio/getstream/chat/android/models/Thread;)Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem;Lio/getstream/chat/android/models/Thread;ILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem; + public fun equals (Ljava/lang/Object;)Z + public fun getStableId ()J + public final fun getThread ()Lio/getstream/chat/android/models/Thread; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class io/getstream/chat/android/ui/font/ChatFonts { public abstract fun getFont (Lio/getstream/chat/android/ui/font/TextStyle;)Landroid/graphics/Typeface; public abstract fun setFont (Lio/getstream/chat/android/ui/font/TextStyle;Landroid/widget/TextView;)V @@ -3885,6 +3983,7 @@ public final class io/getstream/chat/android/ui/helper/TransformStyle { public static final fun getSearchInputViewStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getSearchResultListViewStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getSingleReactionViewStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; + public static final fun getThreadListViewStyle ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getTypingIndicatorViewStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getUnreadLabelButtonStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getUnsupportedAttachmentStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; @@ -3914,6 +4013,7 @@ public final class io/getstream/chat/android/ui/helper/TransformStyle { public static final fun setSearchInputViewStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setSearchResultListViewStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setSingleReactionViewStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V + public static final fun setThreadListViewStyle (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setTypingIndicatorViewStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setUnreadLabelButtonStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setUnsupportedAttachmentStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V @@ -4904,6 +5004,24 @@ public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel public static final fun bind (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel;Lio/getstream/chat/android/ui/feature/search/list/SearchResultListView;Landroidx/lifecycle/LifecycleOwner;)V } +public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBindingKt { + public static final fun bindView (Lio/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel;Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView;Landroidx/lifecycle/LifecycleOwner;)V +} + +public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel : androidx/lifecycle/ViewModel { + public fun (Lio/getstream/chat/android/ui/common/feature/threads/ThreadListController;)V + public final fun getState ()Landroidx/lifecycle/LiveData; + public final fun load ()V + public final fun loadNextPage ()V +} + +public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { + public fun ()V + public fun (III)V + public synthetic fun (IIIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; +} + public final class io/getstream/chat/android/ui/viewmodel/typing/TypingIndicatorViewModel : androidx/lifecycle/ViewModel { public fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;)V public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt new file mode 100644 index 00000000000..3803f93cb93 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt @@ -0,0 +1,239 @@ +/* + * 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.feature.threads.list + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.content.res.AppCompatResources +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.ui.R +import io.getstream.chat.android.ui.databinding.StreamUiThreadListViewBinding +import io.getstream.chat.android.ui.feature.threads.list.internal.ThreadListAdapter +import io.getstream.chat.android.ui.feature.threads.list.internal.ThreadListItem +import io.getstream.chat.android.ui.font.setTextStyle +import io.getstream.chat.android.ui.utils.extensions.createStreamThemeWrapper +import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater +import io.getstream.chat.android.ui.widgets.EndlessScrollListener + +/** + * View rendering a paginated list of threads. + * Optionally, it renders a banner informing about new threads/thread messages outside of the loaded pages of threads. + */ +public class ThreadListView : ConstraintLayout { + + private val binding = StreamUiThreadListViewBinding.inflate(streamThemeInflater, this) + private lateinit var style: ThreadListViewStyle + private lateinit var adapter: ThreadListAdapter + private val scrollListener = EndlessScrollListener(LOAD_MORE_THRESHOLD) { + loadMoreListener?.onLoadMore() + } + + /** + * Creates a [ThreadListView] from the given [Context]. + */ + public constructor(context: Context) : super(context.createStreamThemeWrapper()) { + init(null) + } + + /** + * Creates a [ThreadListView] from the given [Context] and [AttributeSet]. + */ + public constructor(context: Context, attrs: AttributeSet?) : super(context.createStreamThemeWrapper(), attrs) { + init(attrs) + } + + private var loadMoreListener: LoadMoreListener? = null + + private fun init(attrs: AttributeSet?) { + style = ThreadListViewStyle(context, attrs) + adapter = ThreadListAdapter(style) + + binding.threadListRecyclerView.setHasFixedSize(true) + binding.threadListRecyclerView.adapter = adapter + binding.threadListRecyclerView.addOnScrollListener(scrollListener) + + binding.threadListRecyclerView.addItemDecoration( + DividerItemDecoration( + context, + LinearLayoutManager.VERTICAL, + ).apply { + setDrawable(AppCompatResources.getDrawable(context, R.drawable.stream_ui_divider)!!) + }, + ) + + setBackgroundColor(style.backgroundColor) + applyEmptyStateStyle(style) + applyBannerStyle(style) + } + + /** + * Shows a list of threads. + * + * @param threads The list of [Thread]s to show. + * @param isLoadingMore Indicator if the loading more view should be shown. + */ + public fun showThreads(threads: List, isLoadingMore: Boolean) { + val isCurrentlyEmpty = adapter.itemCount == 0 + val hasThreads = threads.isNotEmpty() + + binding.threadListRecyclerView.isVisible = hasThreads + binding.emptyContainer.isVisible = !hasThreads + binding.progressBar.isVisible = false + + val threadItems = threads.map(ThreadListItem::ThreadItem) + val loadingMoreItems = if (isLoadingMore) listOf(ThreadListItem.LoadingMoreItem) else emptyList() + adapter.submitList(threadItems + loadingMoreItems) + + scrollListener.enablePagination() + + if (isCurrentlyEmpty && hasThreads) { + // Data is fully reloaded, ensure list is scrolled to the top + binding.threadListRecyclerView.scrollToPosition(0) + } + } + + /** + * Shows the loading state of the thread list. + */ + public fun showLoading() { + adapter.submitList(emptyList()) // clear current list + binding.threadListRecyclerView.isVisible = false + binding.emptyContainer.isVisible = false + binding.progressBar.isVisible = true + scrollListener.disablePagination() + } + + /** + * Show the 'unread threads' banner. + * Hides the banner if [unreadThreadsCount] == 0. + * + * @param unreadThreadsCount The number of unread threads. + */ + public fun showUnreadThreadsBanner(unreadThreadsCount: Int) { + val bannerText = context.resources.getQuantityString( + R.plurals.stream_ui_thread_list_new_threads, + unreadThreadsCount, + unreadThreadsCount, + ) + binding.unreadThreadsBannerTextView.isVisible = unreadThreadsCount > 0 + binding.unreadThreadsBannerTextView.text = bannerText + } + + /** + * Sets the listener for clicks on the unread threads banner. + * + * @param listener The [UnreadThreadsBannerClickListener] to be invoked when the user clicks on the unread threads + * banner. + */ + public fun setUnreadThreadsBannerClickListener(listener: UnreadThreadsBannerClickListener) { + binding.unreadThreadsBannerTextView.setOnClickListener { + listener.onUnreadThreadsBannerClick() + } + } + + /** + * Sets the listener for clicks on threads. + * + * @param listener The [ThreadClickListener] to be invoked when the user clicks on a thread. + */ + public fun setThreadClickListener(listener: ThreadClickListener) { + adapter.setThreadClickListener(listener) + } + + /** + * Sets the listener requesting loading of more threads. + * + * @param listener The [LoadMoreListener] to be invoked when the end of the thread list is reached. + */ + public fun setLoadMoreListener(listener: LoadMoreListener) { + this.loadMoreListener = listener + } + + private fun applyEmptyStateStyle(style: ThreadListViewStyle) { + binding.emptyImage.setImageDrawable(style.emptyStateDrawable) + binding.emptyTextView.text = style.emptyStateText + binding.emptyTextView.setTextStyle(style.emptyStateTextStyle) + } + + private fun applyBannerStyle(style: ThreadListViewStyle) { + binding.unreadThreadsBannerTextView.setTextStyle(style.bannerTextStyle) + binding.unreadThreadsBannerTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( + null, + null, + style.bannerIcon, + null, + ) + binding.unreadThreadsBannerTextView.background = style.bannerBackground + binding.unreadThreadsBannerTextView.updatePadding( + left = style.bannerPaddingLeft, + top = style.bannerPaddingTop, + right = style.bannerPaddingRight, + bottom = style.bannerPaddingBottom, + ) + binding.unreadThreadsBannerTextView.updateLayoutParams { + leftMargin = style.bannerMarginLeft + topMargin = style.bannerMarginTop + rightMargin = style.bannerMarginRight + bottomMargin = style.bannerMarginBottom + } + } + + private companion object { + private const val LOAD_MORE_THRESHOLD = 10 + } + + /** + * Listener for clicks on the "unread threads" banner. + */ + public fun interface UnreadThreadsBannerClickListener { + + /** + * Called when the user clicks on the unread threads banner. + */ + public fun onUnreadThreadsBannerClick() + } + + /** + * Listener for clicks on the thread list. + */ + public fun interface ThreadClickListener { + + /** + * Called when the user clicks on a thread in the list. + * + * @param thread The clicked [Thread]. + */ + public fun onThreadClick(thread: Thread) + } + + /** + * Listener invoked when the end of the thread list is reached, and a new page of threads should be loaded. + */ + public fun interface LoadMoreListener { + + /** + * Called when a new page of threads should be loaded. + */ + public fun onLoadMore() + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt new file mode 100644 index 00000000000..969f6d42aa4 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt @@ -0,0 +1,373 @@ +/* + * 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.feature.threads.list + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.annotation.ColorInt +import androidx.annotation.Px +import io.getstream.chat.android.ui.R +import io.getstream.chat.android.ui.feature.messages.preview.MessagePreviewStyle +import io.getstream.chat.android.ui.font.TextStyle +import io.getstream.chat.android.ui.helper.TransformStyle +import io.getstream.chat.android.ui.helper.ViewStyle +import io.getstream.chat.android.ui.utils.extensions.getColorCompat +import io.getstream.chat.android.ui.utils.extensions.getDimension +import io.getstream.chat.android.ui.utils.extensions.getDrawableCompat + +/** + * Class holding the customizable styling parameters for [ThreadListView]. + * + * @param backgroundColor The background color of the thread list. + * @param emptyStateDrawable Drawable shown when there are no threads. + * @param emptyStateText Text shown when there are no threads. + * @param emptyStateTextStyle Style for the empty state text. + * @param threadIconDrawable Drawable for the thread icon. + * @param threadTitleStyle Style for the thread title. + * @param threadReplyToStyle Style for the thread reply to text. + * @param latestReplyStyle Style for the latest reply message preview. + * @param unreadCountBadgeTextStyle Style for the unread count badge. + * @param unreadCountBadgeBackground Background for the unread count badge. + * @param bannerTextStyle Style for the unread threads banner text. + * @param bannerIcon Icon for the unread threads banner. + * @param bannerBackground Background for the unread threads banner. + * @param bannerPaddingLeft Left padding for the unread threads banner. + * @param bannerPaddingTop Top padding for the unread threads banner. + * @param bannerPaddingRight Right padding for the unread threads banner. + * @param bannerPaddingBottom Bottom padding for the unread threads banner. + * @param bannerMarginLeft Left margin for the unread threads banner. + * @param bannerMarginTop Top margin for the unread threads banner. + * @param bannerMarginRight Right margin for the unread threads banner. + * @param bannerMarginBottom Bottom margin for the unread threads banner. + */ +public data class ThreadListViewStyle( + // General + @ColorInt public val backgroundColor: Int, + // Empty state + public val emptyStateDrawable: Drawable, + public val emptyStateText: String, + public val emptyStateTextStyle: TextStyle, + // Results + public val threadIconDrawable: Drawable, + public val threadTitleStyle: TextStyle, + public val threadReplyToStyle: TextStyle, + public val latestReplyStyle: MessagePreviewStyle, + public val unreadCountBadgeTextStyle: TextStyle, + public val unreadCountBadgeBackground: Drawable, + // Unread threads banner + public val bannerTextStyle: TextStyle, + public val bannerIcon: Drawable, + public val bannerBackground: Drawable, + @Px public val bannerPaddingLeft: Int, + @Px public val bannerPaddingTop: Int, + @Px public val bannerPaddingRight: Int, + @Px public val bannerPaddingBottom: Int, + @Px public val bannerMarginLeft: Int, + @Px public val bannerMarginTop: Int, + @Px public val bannerMarginRight: Int, + @Px public val bannerMarginBottom: Int, +) : ViewStyle { + + @Suppress("TooManyFunctions") + internal companion object { + + /** + * Creates a [ThreadListViewStyle] from the declared XML properties. + */ + operator fun invoke(context: Context, attrs: AttributeSet?): ThreadListViewStyle { + context.obtainStyledAttributes( + attrs, + R.styleable.ThreadListView, + R.attr.streamUiThreadListStyle, + R.style.StreamUi_ThreadList, + ).use { typedArray -> + // General + val backgroundColor = backgroundColor(context, typedArray) + // Empty state + val emptyStateDrawable = emptyStateDrawable(context, typedArray) + val emptyStateText = emptyStateText(context, typedArray) + val emptyStateTextStyle = emptyStateTextStyle(context, typedArray) + // Results + val threadIconDrawable = threadIconDrawable(context, typedArray) + val threadTitleStyle = threadTitleStyle(context, typedArray) + val threadReplyToStyle = threadReplyToStyle(context, typedArray) + val latestReplySenderStyle = threadLatestReplySenderStyle(context, typedArray) + val latestReplyMessageStyle = threadLatestReplyMessageStyle(context, typedArray) + val latestReplyTimeStyle = threadLatestReplyTimeStyle(context, typedArray) + val unreadCountBadgeTextStyle = unreadCountBadgeTextStyle(context, typedArray) + val unreadCountBadgeBackground = unreadCountBadgeBackground(context, typedArray) + // Unread threads banner + val bannerTextStyle = bannerTextStyle(context, typedArray) + val bannerIcon = bannerIcon(context, typedArray) + val bannerBackground = bannerBackground(context, typedArray) + val bannerPaddingLeft = bannerPaddingLeft(context, typedArray) + val bannerPaddingTop = bannerPaddingTop(context, typedArray) + val bannerPaddingRight = bannerPaddingRight(context, typedArray) + val bannerPaddingBottom = bannerPaddingBottom(context, typedArray) + val bannerMarginLeft = bannerMarginLeft(context, typedArray) + val bannerMarginTop = bannerMarginTop(context, typedArray) + val bannerMarginRight = bannerMarginRight(context, typedArray) + val bannerMarginBottom = bannerMarginBottom(context, typedArray) + return ThreadListViewStyle( + backgroundColor = backgroundColor, + emptyStateDrawable = emptyStateDrawable, + emptyStateText = emptyStateText, + emptyStateTextStyle = emptyStateTextStyle, + threadIconDrawable = threadIconDrawable, + threadTitleStyle = threadTitleStyle, + threadReplyToStyle = threadReplyToStyle, + latestReplyStyle = MessagePreviewStyle( + messageSenderTextStyle = latestReplySenderStyle, + messageTextStyle = latestReplyMessageStyle, + messageTimeTextStyle = latestReplyTimeStyle, + ), + unreadCountBadgeTextStyle = unreadCountBadgeTextStyle, + unreadCountBadgeBackground = unreadCountBadgeBackground, + bannerTextStyle = bannerTextStyle, + bannerIcon = bannerIcon, + bannerBackground = bannerBackground, + bannerPaddingLeft = bannerPaddingLeft, + bannerPaddingTop = bannerPaddingTop, + bannerPaddingRight = bannerPaddingRight, + bannerPaddingBottom = bannerPaddingBottom, + bannerMarginLeft = bannerMarginLeft, + bannerMarginTop = bannerMarginTop, + bannerMarginRight = bannerMarginRight, + bannerMarginBottom = bannerMarginBottom, + ).let(TransformStyle.threadListViewStyle::transform) + } + } + + private fun backgroundColor(context: Context, typedArray: TypedArray) = typedArray.getColor( + R.styleable.ThreadListView_streamUiThreadListBackground, + context.getColorCompat(R.color.stream_ui_white_snow), + ) + + private fun emptyStateDrawable(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListEmptyStateDrawable) + ?: context.getDrawableCompat(R.drawable.stream_ui_ic_threads_empty)!! + + private fun emptyStateText(context: Context, typedArray: TypedArray) = + typedArray.getString(R.styleable.ThreadListView_streamUiThreadListEmptyStateText) + ?: context.getString(R.string.stream_ui_thread_list_empty_title) + + private fun emptyStateTextStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListEmptyStateTextSize, + context.getDimension(R.dimen.stream_ui_text_large), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListEmptyStateTextColor, + context.getColorCompat(R.color.stream_ui_text_color_secondary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListEmptyStateTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListEmptyStateTextFont, + ) + .style(R.styleable.ThreadListView_streamUiThreadListEmptyStateTextStyle, Typeface.NORMAL) + .build() + + private fun threadIconDrawable(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListThreadIconDrawable) + ?: context.getDrawableCompat(R.drawable.stream_ui_ic_thread)!! + + private fun threadTitleStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadTitleTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadTitleTextColor, + context.getColorCompat(R.color.stream_ui_text_color_primary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadTitleTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadTitleTextFont, + ) + .style(R.styleable.ThreadListView_streamUiThreadListThreadTitleTextStyle, Typeface.BOLD) + .build() + + private fun threadReplyToStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextColor, + context.getColorCompat(R.color.stream_ui_text_color_secondary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextFont, + ) + .style(R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextStyle, Typeface.NORMAL) + .build() + + private fun threadLatestReplySenderStyle(context: Context, typedArray: TypedArray) = + TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextColor, + context.getColorCompat(R.color.stream_ui_text_color_primary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextStyle, + Typeface.BOLD, + ) + .build() + + private fun threadLatestReplyMessageStyle(context: Context, typedArray: TypedArray) = + TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextColor, + context.getColorCompat(R.color.stream_ui_text_color_secondary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextStyle, + Typeface.NORMAL, + ) + .build() + + private fun threadLatestReplyTimeStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextColor, + context.getColorCompat(R.color.stream_ui_text_color_secondary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextStyle, + Typeface.NORMAL, + ) + .build() + + private fun unreadCountBadgeTextStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextSize, + context.getDimension(R.dimen.stream_ui_text_small), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextColor, + context.getColorCompat(R.color.stream_ui_white), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextStyle, + Typeface.NORMAL, + ) + .build() + + private fun unreadCountBadgeBackground(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeBackground) + ?: context.getDrawableCompat(R.drawable.stream_ui_shape_badge_background)!! + + private fun bannerTextStyle(context: Context, typedArray: TypedArray) = + TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextSize, + context.getDimension(R.dimen.stream_ui_text_large), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextColor, + context.getColorCompat(R.color.stream_ui_white), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextStyle, + Typeface.NORMAL, + ) + .build() + + private fun bannerIcon(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerIcon) + ?: context.getDrawableCompat(R.drawable.stream_ui_ic_union)!! + + private fun bannerBackground(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerBackground) + ?: context.getDrawableCompat(R.drawable.stream_ui_shape_unread_threads_banner)!! + + private fun bannerPaddingLeft(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerPaddingLeft, + context.getDimension(R.dimen.stream_ui_spacing_medium), + ) + + private fun bannerPaddingTop(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerPaddingTop, + context.getDimension(R.dimen.stream_ui_spacing_medium), + ) + + private fun bannerPaddingRight(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerPaddingRight, + context.getDimension(R.dimen.stream_ui_spacing_medium), + ) + + private fun bannerPaddingBottom(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerPaddingBottom, + context.getDimension(R.dimen.stream_ui_spacing_medium), + ) + + private fun bannerMarginLeft(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerMarginLeft, + context.getDimension(R.dimen.stream_ui_spacing_small), + ) + + private fun bannerMarginTop(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerMarginTop, + context.getDimension(R.dimen.stream_ui_spacing_small), + ) + + private fun bannerMarginRight(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerMarginRight, + context.getDimension(R.dimen.stream_ui_spacing_small), + ) + + private fun bannerMarginBottom(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerMarginBottom, + context.getDimension(R.dimen.stream_ui_spacing_small), + ) + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt new file mode 100644 index 00000000000..708e2db596f --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt @@ -0,0 +1,240 @@ +/* + * 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.feature.threads.list.internal + +import android.text.SpannableStringBuilder +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.User +import io.getstream.chat.android.ui.ChatUI +import io.getstream.chat.android.ui.R +import io.getstream.chat.android.ui.common.extensions.internal.context +import io.getstream.chat.android.ui.common.extensions.internal.singletonList +import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListBinding +import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListLoadingMoreBinding +import io.getstream.chat.android.ui.feature.threads.list.ThreadListView +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle +import io.getstream.chat.android.ui.font.setTextStyle +import io.getstream.chat.android.ui.utils.extensions.asMention +import io.getstream.chat.android.ui.utils.extensions.bold +import io.getstream.chat.android.ui.utils.extensions.getAttachmentsText +import io.getstream.chat.android.ui.utils.extensions.getTranslatedText +import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater + +/** + * RecyclerView adapter implementation for displaying a list of threads. + * + * @param style The [ThreadListViewStyle] for item customization. + */ +internal class ThreadListAdapter(private val style: ThreadListViewStyle) : + ListAdapter(ThreadListItemDiffCallback) { + + private var clickListener: ThreadListView.ThreadClickListener? = null + + init { + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long { + return getItem(position).stableId + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return when (item) { + is ThreadListItem.ThreadItem -> ITEM_THREAD + is ThreadListItem.LoadingMoreItem -> ITEM_LOADING_MORE + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + ITEM_THREAD -> { + StreamUiItemThreadListBinding + .inflate(parent.streamThemeInflater, parent, false) + .let(::ThreadItemViewHolder) + .also { it.applyStyle(style) } + } + + else -> { + StreamUiItemThreadListLoadingMoreBinding + .inflate(parent.streamThemeInflater, parent, false) + .let(::LoadingMoreViewHolder) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = getItem(position) + if (item is ThreadListItem.ThreadItem && holder is ThreadItemViewHolder) { + holder.bind(item.thread) + } + } + + /** + * Sets the listener for clicks on thread items. + */ + fun setThreadClickListener(clickListener: ThreadListView.ThreadClickListener) { + this.clickListener = clickListener + } + + companion object { + private const val ITEM_THREAD = 0 + private const val ITEM_LOADING_MORE = 1 + + private const val MAX_UNREAD_COUNT = 99 + } + + /** + * ViewHolder for thread items. + */ + inner class ThreadItemViewHolder( + private val binding: StreamUiItemThreadListBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + private lateinit var thread: Thread + + init { + binding.root.setOnClickListener { + clickListener?.onThreadClick(thread) + } + } + + /** + * Applies the [ThreadListViewStyle] to the [ThreadItemViewHolder]. + * + * @param style The style to be applied. + */ + fun applyStyle(style: ThreadListViewStyle) { + binding.threadImage.setImageDrawable(style.threadIconDrawable) + binding.threadTitleTextView.setTextStyle(style.threadTitleStyle) + binding.replyToTextView.setTextStyle(style.threadReplyToStyle) + // Remove ripple from latestReplyMessageView + binding.latestReplyMessageView.binding.root.background = null + binding.latestReplyMessageView.styleView(style.latestReplyStyle) + binding.unreadCountBadge.setTextStyle(style.unreadCountBadgeTextStyle) + binding.unreadCountBadge.background = style.unreadCountBadgeBackground + } + + /** + * Binds the given [Thread] to the view. + */ + fun bind(thread: Thread) { + this.thread = thread + val currentUser = ChatUI.currentUserProvider.getCurrentUser() + bindThreadTitle(thread, currentUser) + bindReplyTo(thread, currentUser) + bindUnreadCountBadge(thread, currentUser) + bindLatestReply(thread, currentUser) + } + + private fun bindThreadTitle(thread: Thread, currentUser: User?) { + val channel = thread.channel + val title = channel + ?.let { ChatUI.channelNameFormatter.formatChannelName(channel = it, currentUser = currentUser) } + ?: thread.title + binding.threadTitleTextView.text = title + } + + private fun bindReplyTo(thread: Thread, currentUser: User?) { + val prefix = binding.root.context.getString(R.string.stream_ui_thread_list_replied_to) + val parentMessageText = formatMessage(thread.parentMessage, currentUser?.asMention(context)) + val replyToText = "$prefix$parentMessageText" + binding.replyToTextView.text = replyToText + } + + private fun bindUnreadCountBadge(thread: Thread, currentUser: User?) { + val unreadCount = thread.read + .find { it.user.id == currentUser?.id } + ?.unreadMessages + ?: 0 + if (unreadCount > 0) { + binding.unreadCountBadge.text = + if (unreadCount > MAX_UNREAD_COUNT) "${MAX_UNREAD_COUNT}+" else unreadCount.toString() + binding.unreadCountBadge.isVisible = true + } else { + binding.unreadCountBadge.isVisible = false + } + } + + private fun bindLatestReply(thread: Thread, currentUser: User?) { + val latestReply = thread.latestReplies.lastOrNull() + if (latestReply != null) { + // User avatar + binding.latestReplyMessageView.binding.userAvatarView.setUser(latestReply.user) + // Sender name + binding.latestReplyMessageView.binding.senderNameLabel.text = latestReply.user.name.bold() + // Reply text + binding.latestReplyMessageView.binding.messageLabel.text = + formatMessage(latestReply, currentUser?.asMention(context)) + // Timestamp + binding.latestReplyMessageView.binding.messageTimeLabel.text = + ChatUI.dateFormatter.formatDate(latestReply.createdAt ?: latestReply.createdLocallyAt) + binding.latestReplyMessageView.isVisible = true + } else { + // Note: should never happen + binding.latestReplyMessageView.isVisible = false + } + } + + private fun formatMessage(message: Message, currentUserMention: String?): CharSequence { + val attachmentsText = message.getAttachmentsText() + val displayedText = message.getTranslatedText() + val previewText = displayedText.trim().let { + if (currentUserMention != null) { + // bold mentions of the current user + it.bold(currentUserMention.singletonList(), ignoreCase = true) + } else { + it + } + } + + return listOf(previewText, attachmentsText) + .filterNot { it.isNullOrEmpty() } + .joinTo(SpannableStringBuilder(), " ") + } + } + + /** + * ViewHolder for the loading more item. + */ + inner class LoadingMoreViewHolder( + binding: StreamUiItemThreadListLoadingMoreBinding, + ) : RecyclerView.ViewHolder(binding.root) + + /** + * [DiffUtil.ItemCallback] for calculating differences between [ThreadListItem]s. + */ + private object ThreadListItemDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { + return oldItem.stableId == newItem.stableId + } + + override fun areContentsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { + return if (oldItem is ThreadListItem.ThreadItem && newItem is ThreadListItem.ThreadItem) { + oldItem.thread == newItem.thread // [Thread] is a data class, equality check is enough + } else { + false + } + } + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt new file mode 100644 index 00000000000..d5b90ef539e --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.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.ui.feature.threads.list.internal + +import io.getstream.chat.android.models.Thread + +/** + * Class representing the different types of items that can be rendered in the + * by the [ThreadListAdapter]. + */ +public sealed interface ThreadListItem { + + /** + * The item stable ID. + */ + public val stableId: Long + + /** + * Represents a thread item. + */ + public data class ThreadItem(val thread: Thread) : ThreadListItem { + override val stableId: Long + get() = thread.parentMessage.identifierHash() + } + + /** + * Represents a loading more item. + */ + public data object LoadingMoreItem : ThreadListItem { + override val stableId: Long + get() = LOADING_MORE_ITEM_STABLE_ID + } + + private companion object { + private const val LOADING_MORE_ITEM_STABLE_ID = 1L + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/helper/TransformStyle.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/helper/TransformStyle.kt index 3650261e729..e8352769629 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/helper/TransformStyle.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/helper/TransformStyle.kt @@ -43,6 +43,7 @@ import io.getstream.chat.android.ui.feature.messages.list.reactions.view.ViewRea import io.getstream.chat.android.ui.feature.pinned.list.PinnedMessageListViewStyle import io.getstream.chat.android.ui.feature.search.SearchInputViewStyle import io.getstream.chat.android.ui.feature.search.list.SearchResultListViewStyle +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle import io.getstream.chat.android.ui.widgets.avatar.AvatarStyle import io.getstream.chat.android.ui.widgets.typing.TypingIndicatorViewStyle @@ -138,6 +139,9 @@ public object TransformStyle { @JvmStatic public var audioRecordPlayerViewStyle: StyleTransformer = noopTransformer() + @JvmStatic + public var threadListViewStyle: StyleTransformer = noopTransformer() + private fun noopTransformer() = StyleTransformer { it } } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt new file mode 100644 index 00000000000..957d326ccb8 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt @@ -0,0 +1,39 @@ +/* + * 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.viewmodel.threads + +import androidx.lifecycle.LifecycleOwner +import io.getstream.chat.android.ui.feature.threads.list.ThreadListView + +/** + * Binds [ThreadListView] to a [ThreadListViewModel], updating the view's state based on + * data provided by the ViewModel and propagating view events to the ViewModel as needed. + * + * This function sets listeners on the view and ViewModel. Call this method + * before setting any additional listeners on these objects yourself. + */ +public fun ThreadListViewModel.bindView(view: ThreadListView, lifecycleOwner: LifecycleOwner) { + state.observe(lifecycleOwner) { state -> + when { + state.threads.isEmpty() && state.isLoading -> view.showLoading() + else -> view.showThreads(state.threads, state.isLoadingMore) + } + view.showUnreadThreadsBanner(state.unseenThreadsCount) + } + view.setUnreadThreadsBannerClickListener(::load) + view.setLoadMoreListener(::loadNextPage) +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt new file mode 100644 index 00000000000..345ea0a0d1c --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt @@ -0,0 +1,54 @@ +/* + * 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.viewmodel.threads + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import io.getstream.chat.android.ui.common.feature.threads.ThreadListController +import io.getstream.chat.android.ui.common.state.threads.ThreadListState + +/** + * 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: LiveData = controller.state.asLiveData() + + /** + * 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-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory.kt new file mode 100644 index 00000000000..8b0f5cc8e15 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/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.ui.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-ui-components/src/main/res/drawable/stream_ui_ic_thread.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_thread.xml new file mode 100644 index 00000000000..2a2936a530c --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_thread.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_threads_empty.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_threads_empty.xml new file mode 100644 index 00000000000..602bdbf230e --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_threads_empty.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_union.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_union.xml new file mode 100644 index 00000000000..0826e2b4597 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_union.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_shape_unread_threads_banner.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_shape_unread_threads_banner.xml new file mode 100644 index 00000000000..5973a46f13f --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_shape_unread_threads_banner.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list.xml b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list.xml new file mode 100644 index 00000000000..8568f280ead --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list_loading_more.xml b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list_loading_more.xml new file mode 100644 index 00000000000..ed811e5a58a --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list_loading_more.xml @@ -0,0 +1,39 @@ + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/layout/stream_ui_thread_list_view.xml b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_thread_list_view.xml new file mode 100644 index 00000000000..5cf301eccea --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_thread_list_view.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/values/attrs.xml b/stream-chat-android-ui-components/src/main/res/values/attrs.xml index b0d7479410e..32daed8e574 100644 --- a/stream-chat-android-ui-components/src/main/res/values/attrs.xml +++ b/stream-chat-android-ui-components/src/main/res/values/attrs.xml @@ -56,5 +56,6 @@ + diff --git a/stream-chat-android-ui-components/src/main/res/values/attrs_thread_list_view.xml b/stream-chat-android-ui-components/src/main/res/values/attrs_thread_list_view.xml new file mode 100644 index 00000000000..e3a2256ab2c --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/values/attrs_thread_list_view.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/values/strings_thread_list.xml b/stream-chat-android-ui-components/src/main/res/values/strings_thread_list.xml new file mode 100644 index 00000000000..3976600e83a --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/values/strings_thread_list.xml @@ -0,0 +1,24 @@ + + + + No threads here yet… + replied to:  + + %d new thread + %d new threads + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/values/styles.xml b/stream-chat-android-ui-components/src/main/res/values/styles.xml index affe5a819f2..8254c3d666d 100644 --- a/stream-chat-android-ui-components/src/main/res/values/styles.xml +++ b/stream-chat-android-ui-components/src/main/res/values/styles.xml @@ -224,40 +224,80 @@ @@ -273,7 +313,9 @@ true topRight 0dp - ?attr/streamUiGlobalAvatarOnlineIndicatorColor + + ?attr/streamUiGlobalAvatarOnlineIndicatorColor + @color/stream_ui_white circle @@ -290,7 +332,9 @@ 6dp @drawable/stream_ui_ic_down 3dp - @dimen/stream_ui_scroll_button_unread_badge_text_size + + @dimen/stream_ui_scroll_button_unread_badge_text_size + @color/stream_ui_literal_white normal @@ -317,7 +361,8 @@ @color/stream_ui_text_color_primary normal @dimen/stream_ui_text_small - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + normal @dimen/stream_ui_text_small @color/stream_ui_text_color_secondary @@ -355,17 +400,20 @@ @dimen/stream_ui_text_small - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + normal @dimen/stream_ui_text_small - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + normal @color/stream_ui_bars_background - @color/stream_ui_grey_gainsboro + @color/stream_ui_grey_gainsboro + @dimen/stream_ui_text_medium @@ -381,7 +429,8 @@ @color/stream_ui_accent_blue @color/stream_ui_accent_blue @color/stream_ui_blue_alice - @color/stream_ui_blue_alice + @color/stream_ui_blue_alice + @color/stream_ui_literal_transparent @@ -398,8 +447,10 @@ @color/stream_ui_grey_whisper - @color/stream_ui_grey_gainsboro - @color/stream_ui_grey_whisper + @color/stream_ui_grey_gainsboro + + @color/stream_ui_grey_whisper + @color/stream_ui_white @@ -416,7 +467,8 @@ @color/stream_ui_grey_whisper @dimen/stream_ui_text_medium - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + italic @@ -490,7 +542,8 @@ normal @dimen/stream_ui_text_medium - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + normal @dimen/stream_ui_text_medium @@ -506,14 +559,17 @@ bottom - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + @dimen/stream_ui_text_small normal @drawable/stream_ui_ic_pin @color/stream_ui_highlight - @dimen/stream_ui_message_viewholder_avatar_missing_margin + + @dimen/stream_ui_message_viewholder_avatar_missing_margin + 0dp @@ -521,14 +577,24 @@ 75% - @style/StreamUi.AudioRecordPlayerView - @style/StreamUi.AudioRecordPlayerView + + @style/StreamUi.AudioRecordPlayerView + + + @style/StreamUi.AudioRecordPlayerView + @@ -582,7 +662,9 @@ @dimen/stream_ui_text_medium - @color/stream_ui_text_color_primary + + @color/stream_ui_text_color_primary + normal @@ -615,8 +697,11 @@ @color/stream_ui_accent_blue true @drawable/stream_ui_ic_pen - @color/stream_ui_icon_button_background_selector - @drawable/stream_ui_divider + @color/stream_ui_icon_button_background_selector + + + @drawable/stream_ui_divider + @@ -652,11 +738,18 @@ @color/stream_ui_white_smoke - @dimen/stream_ui_channel_list_item_margin_start + @dimen/stream_ui_channel_list_item_margin_start + @dimen/stream_ui_channel_list_item_margin_end - @dimen/stream_ui_channel_list_item_title_margin_start - @dimen/stream_ui_channel_list_item_vertical_spacer_height - @dimen/stream_ui_channel_list_item_vertical_spacer_position + + @dimen/stream_ui_channel_list_item_title_margin_start + + + @dimen/stream_ui_channel_list_item_vertical_spacer_height + + + @dimen/stream_ui_channel_list_item_vertical_spacer_position + @dimen/stream_ui_channel_item_title @color/stream_ui_text_color_primary bold @@ -665,7 +758,8 @@ @color/stream_ui_text_color_secondary normal - @dimen/stream_ui_channel_item_message_date + @dimen/stream_ui_channel_item_message_date + @color/stream_ui_text_color_secondary normal @@ -763,12 +857,16 @@ @@ -891,29 +1062,41 @@ @@ -1038,83 +1238,177 @@ + + From 50b12497a3e19640b3096eaa59643549f7e78ccc Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Tue, 26 Nov 2024 12:43:32 +0100 Subject: [PATCH 02/22] Remove thread list dividers. --- .../ui/feature/threads/list/ThreadListView.kt | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt index 3803f93cb93..4134e003bfa 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt @@ -18,13 +18,10 @@ package io.getstream.chat.android.ui.feature.threads.list import android.content.Context import android.util.AttributeSet -import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager import io.getstream.chat.android.models.Thread import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.databinding.StreamUiThreadListViewBinding @@ -72,15 +69,6 @@ public class ThreadListView : ConstraintLayout { binding.threadListRecyclerView.adapter = adapter binding.threadListRecyclerView.addOnScrollListener(scrollListener) - binding.threadListRecyclerView.addItemDecoration( - DividerItemDecoration( - context, - LinearLayoutManager.VERTICAL, - ).apply { - setDrawable(AppCompatResources.getDrawable(context, R.drawable.stream_ui_divider)!!) - }, - ) - setBackgroundColor(style.backgroundColor) applyEmptyStateStyle(style) applyBannerStyle(style) From 10ba32914403e608859d09840af91416ac1b0910 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Tue, 26 Nov 2024 17:31:21 +0100 Subject: [PATCH 03/22] [AND-3] Add Threads tab to sample app. --- .../channel/list/ChannelListFragment.kt | 2 +- .../ui/sample/feature/home/HomeFragment.kt | 5 ++ .../ui/sample/feature/home/HomeViewModel.kt | 5 ++ .../feature/mentions/MentionsFragment.kt | 2 +- .../sample/feature/threads/ThreadsFragment.kt | 78 +++++++++++++++++++ .../src/main/res/drawable/ic_threads.xml | 27 +++++++ .../src/main/res/layout/fragment_threads.xml | 33 ++++++++ .../main/res/menu/menu_bottom_navigation.xml | 5 ++ .../main/res/navigation/home_nav_graph.xml | 8 ++ .../src/main/res/values/strings.xml | 1 + 10 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt create mode 100644 stream-chat-android-ui-components-sample/src/main/res/drawable/ic_threads.xml create mode 100644 stream-chat-android-ui-components-sample/src/main/res/layout/fragment_threads.xml diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt index 4dfdcf00cb3..7214e55edae 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt @@ -147,7 +147,7 @@ class ChannelListFragment : Fragment() { binding.searchResultListView.setSearchResultSelectedListener { message -> requireActivity().findNavController(R.id.hostFragmentContainer) - .navigateSafely(HomeFragmentDirections.actionOpenChat(message.cid, message.id)) + .navigateSafely(HomeFragmentDirections.actionOpenChat(message.cid, message.id, message.parentId)) } } diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeFragment.kt index 420218d8874..fd085325cd6 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeFragment.kt @@ -186,6 +186,10 @@ class HomeFragment : Fragment() { backgroundColor = ContextCompat.getColor(requireContext(), R.color.stream_ui_accent_red) badgeTextColor = ContextCompat.getColor(requireContext(), R.color.stream_ui_literal_white) } + getOrCreateBadge(R.id.threads_fragment).apply { + backgroundColor = ContextCompat.getColor(requireContext(), R.color.stream_ui_accent_red) + badgeTextColor = ContextCompat.getColor(requireContext(), R.color.stream_ui_literal_white) + } } } @@ -229,6 +233,7 @@ class HomeFragment : Fragment() { binding.bottomNavigationView.apply { setBadgeNumber(R.id.channels_fragment, state.totalUnreadCount) setBadgeNumber(R.id.mentions_fragment, state.mentionsUnreadCount) + setBadgeNumber(R.id.threads_fragment, state.unreadThreadsCount) } nameTextView.text = state.user.name diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt index 3f8bb061a62..d3bc2fabdde 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt @@ -78,6 +78,9 @@ class HomeViewModel( _state.addSource(globalState.totalUnreadCount.asLiveData()) { count -> setState { copy(totalUnreadCount = count) } } + _state.addSource(globalState.unreadThreadsCount.asLiveData()) { count -> + setState { copy(unreadThreadsCount = count) } + } _state.addSource(clientState.user.asLiveData()) { user -> setState { copy(user = user ?: User()) } } @@ -119,11 +122,13 @@ class HomeViewModel( * @param user The currently logged in user. * @param totalUnreadCount The total unread messages count for the current user. * @param mentionsUnreadCount The number of unread mentions by the current user. + * @param unreadThreadsCount The number of unread threads by the current user. */ data class UiState( val user: User = User(), val totalUnreadCount: Int = 0, val mentionsUnreadCount: Int = 0, + val unreadThreadsCount: Int = 0, ) /** diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/mentions/MentionsFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/mentions/MentionsFragment.kt index 24367ffe159..abe6d195a3d 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/mentions/MentionsFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/mentions/MentionsFragment.kt @@ -56,7 +56,7 @@ class MentionsFragment : Fragment() { viewModel.bindView(binding.mentionsListView, viewLifecycleOwner) binding.mentionsListView.setMentionSelectedListener { message -> requireActivity().findNavController(R.id.hostFragmentContainer) - .navigateSafely(HomeFragmentDirections.actionOpenChat(message.cid, message.id)) + .navigateSafely(HomeFragmentDirections.actionOpenChat(message.cid, message.id, message.parentId)) } setupOnClickListeners() } diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt new file mode 100644 index 00000000000..eadbfdf0938 --- /dev/null +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt @@ -0,0 +1,78 @@ +/* + * 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.ui.sample.feature.threads + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.findNavController +import io.getstream.chat.android.ui.viewmodel.threads.ThreadListViewModel +import io.getstream.chat.android.ui.viewmodel.threads.ThreadsViewModelFactory +import io.getstream.chat.android.ui.viewmodel.threads.bindView +import io.getstream.chat.ui.sample.R +import io.getstream.chat.ui.sample.common.navigateSafely +import io.getstream.chat.ui.sample.databinding.FragmentThreadsBinding +import io.getstream.chat.ui.sample.feature.home.HomeFragmentDirections + +/** + * Fragment displaying the list of threads for the currently logged in user. + */ +class ThreadsFragment : Fragment() { + + private var _binding: FragmentThreadsBinding? = null + private val binding: FragmentThreadsBinding + get() = _binding!! + + private val viewModel: ThreadListViewModel by viewModels { ThreadsViewModelFactory() } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + _binding = FragmentThreadsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.bindView(binding.threadListView, viewLifecycleOwner) + binding.threadListView.setThreadClickListener { thread -> + requireActivity().findNavController(R.id.hostFragmentContainer) + .navigateSafely( + HomeFragmentDirections.actionOpenChat( + cid = thread.parentMessage.cid, + parentMessageId = thread.parentMessageId, + ), + ) + } + setupBackHandler() + } + + private fun setupBackHandler() { + activity?.apply { + onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + finish() + } + } + } +} diff --git a/stream-chat-android-ui-components-sample/src/main/res/drawable/ic_threads.xml b/stream-chat-android-ui-components-sample/src/main/res/drawable/ic_threads.xml new file mode 100644 index 00000000000..0a6c48ebee4 --- /dev/null +++ b/stream-chat-android-ui-components-sample/src/main/res/drawable/ic_threads.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_threads.xml b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_threads.xml new file mode 100644 index 00000000000..a3b35a8c4b9 --- /dev/null +++ b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_threads.xml @@ -0,0 +1,33 @@ + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components-sample/src/main/res/menu/menu_bottom_navigation.xml b/stream-chat-android-ui-components-sample/src/main/res/menu/menu_bottom_navigation.xml index da419a96a13..24cedaf6352 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/menu/menu_bottom_navigation.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/menu/menu_bottom_navigation.xml @@ -25,4 +25,9 @@ android:icon="@drawable/ic_mentions" android:title="@string/home_bottom_nav_mentions" /> + diff --git a/stream-chat-android-ui-components-sample/src/main/res/navigation/home_nav_graph.xml b/stream-chat-android-ui-components-sample/src/main/res/navigation/home_nav_graph.xml index 77a9c75665d..d94a877f12b 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/navigation/home_nav_graph.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/navigation/home_nav_graph.xml @@ -35,4 +35,12 @@ android:label="MentionsFragment" tools:layout="@layout/fragment_mentions" /> + + + diff --git a/stream-chat-android-ui-components-sample/src/main/res/values/strings.xml b/stream-chat-android-ui-components-sample/src/main/res/values/strings.xml index c944804eda2..b064b677711 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ Sign Out Chats Mentions + Threads Let\'s start chatting! From 1f11594708b6199560f2bcf297a50ed737fe03f1 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Wed, 27 Nov 2024 14:06:03 +0100 Subject: [PATCH 04/22] Add ThreadListView to CHANGELOG.md. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 462e384ad91..5f6604b2bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ ### ⬆️ Improved ### ✅ Added +- Add `ThreadListView` component for showing the list of threads for the user. [#5491](https://github.com/GetStream/stream-chat-android/pull/5491) ### ⚠️ Changed From 94c346da18951cc70162f95272306736af19da31 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Wed, 27 Nov 2024 14:22:01 +0100 Subject: [PATCH 05/22] Fix sample app thread navigation. --- .../getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt index eadbfdf0938..96c0f2618b7 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt @@ -61,6 +61,7 @@ class ThreadsFragment : Fragment() { .navigateSafely( HomeFragmentDirections.actionOpenChat( cid = thread.parentMessage.cid, + messageId = thread.parentMessageId, parentMessageId = thread.parentMessageId, ), ) From 5f34bf811088033d5108538c47bb1e8f47884a35 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Thu, 28 Nov 2024 13:52:02 +0100 Subject: [PATCH 06/22] [AND-3] Create ThreadListItemViewHolderFactory for ViewHolder customization. --- .../sample/feature/threads/ThreadsFragment.kt | 1 - .../api/stream-chat-android-ui-components.api | 35 ++- .../ui/feature/threads/list/ThreadListView.kt | 55 +++- .../{internal => adapter}/ThreadListItem.kt | 4 +- .../ThreadListItemViewHolderFactory.kt | 128 ++++++++++ .../list/adapter/ThreadListItemViewType.kt | 33 +++ .../adapter/internal/ThreadListAdapter.kt | 88 +++++++ .../BaseThreadListItemViewHolder.kt | 53 ++++ .../internal/ThreadItemViewHolder.kt | 158 ++++++++++++ .../ThreadListLoadingMoreViewHolder.kt | 35 +++ .../list/internal/ThreadListAdapter.kt | 240 ------------------ 11 files changed, 572 insertions(+), 258 deletions(-) rename stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/{internal => adapter}/ThreadListItem.kt (89%) create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/internal/ThreadListAdapter.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadListLoadingMoreViewHolder.kt delete mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt index 96c0f2618b7..eadbfdf0938 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt @@ -61,7 +61,6 @@ class ThreadsFragment : Fragment() { .navigateSafely( HomeFragmentDirections.actionOpenChat( cid = thread.parentMessage.cid, - messageId = thread.parentMessageId, parentMessageId = thread.parentMessageId, ), ) diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 1599185fdcf..95ba2cdf6f0 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -3773,6 +3773,7 @@ public final class io/getstream/chat/android/ui/feature/threads/list/ThreadListV public final fun setLoadMoreListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$LoadMoreListener;)V public final fun setThreadClickListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$ThreadClickListener;)V public final fun setUnreadThreadsBannerClickListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$UnreadThreadsBannerClickListener;)V + public final fun setViewHolderFactory (Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory;)V public final fun showLoading ()V public final fun showThreads (Ljava/util/List;Z)V public final fun showUnreadThreadsBanner (I)V @@ -3841,23 +3842,23 @@ public final class io/getstream/chat/android/ui/feature/threads/list/ThreadListV public fun toString ()Ljava/lang/String; } -public abstract interface class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem { public abstract fun getStableId ()J } -public final class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$LoadingMoreItem : io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { - public static final field INSTANCE Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$LoadingMoreItem; +public final class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$LoadingMoreItem : io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem { + public static final field INSTANCE Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$LoadingMoreItem; public fun equals (Ljava/lang/Object;)Z public fun getStableId ()J public fun hashCode ()I public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem : io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { +public final class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$ThreadItem : io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem { public fun (Lio/getstream/chat/android/models/Thread;)V public final fun component1 ()Lio/getstream/chat/android/models/Thread; - public final fun copy (Lio/getstream/chat/android/models/Thread;)Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem;Lio/getstream/chat/android/models/Thread;ILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem; + public final fun copy (Lio/getstream/chat/android/models/Thread;)Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$ThreadItem; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$ThreadItem;Lio/getstream/chat/android/models/Thread;ILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$ThreadItem; public fun equals (Ljava/lang/Object;)Z public fun getStableId ()J public final fun getThread ()Lio/getstream/chat/android/models/Thread; @@ -3865,6 +3866,28 @@ public final class io/getstream/chat/android/ui/feature/threads/list/internal/Th public fun toString ()Ljava/lang/String; } +public class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory { + public fun ()V + protected fun createLoadingMoreViewHolder (Landroid/view/ViewGroup;)Lio/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder; + protected fun createThreadItemViewHolder (Landroid/view/ViewGroup;)Lio/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder; + public fun createViewHolder (Landroid/view/ViewGroup;I)Lio/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder; + protected final fun getClickListener ()Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$ThreadClickListener; + public fun getItemViewType (Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem;)I + public fun getItemViewType (Lio/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder;)I + protected final fun getStyle ()Lio/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle; +} + +public final class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType { + public static final field INSTANCE Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType; + public static final field ITEM_LOADING_MORE I + public static final field ITEM_THREAD I +} + +public abstract class io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder { + public fun (Landroid/view/View;)V + public abstract fun bind (Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem;)V +} + public abstract interface class io/getstream/chat/android/ui/font/ChatFonts { public abstract fun getFont (Lio/getstream/chat/android/ui/font/TextStyle;)Landroid/graphics/Typeface; public abstract fun setFont (Lio/getstream/chat/android/ui/font/TextStyle;Landroid/widget/TextView;)V diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt index 4134e003bfa..2583df6470f 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt @@ -25,8 +25,9 @@ import androidx.core.view.updatePadding import io.getstream.chat.android.models.Thread import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.databinding.StreamUiThreadListViewBinding -import io.getstream.chat.android.ui.feature.threads.list.internal.ThreadListAdapter -import io.getstream.chat.android.ui.feature.threads.list.internal.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItemViewHolderFactory +import io.getstream.chat.android.ui.feature.threads.list.adapter.internal.ThreadListAdapter import io.getstream.chat.android.ui.font.setTextStyle import io.getstream.chat.android.ui.utils.extensions.createStreamThemeWrapper import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater @@ -40,7 +41,9 @@ public class ThreadListView : ConstraintLayout { private val binding = StreamUiThreadListViewBinding.inflate(streamThemeInflater, this) private lateinit var style: ThreadListViewStyle + private lateinit var viewHolderFactory: ThreadListItemViewHolderFactory private lateinit var adapter: ThreadListAdapter + private var clickListener: ThreadClickListener? = null private val scrollListener = EndlessScrollListener(LOAD_MORE_THRESHOLD) { loadMoreListener?.onLoadMore() } @@ -63,10 +66,7 @@ public class ThreadListView : ConstraintLayout { private fun init(attrs: AttributeSet?) { style = ThreadListViewStyle(context, attrs) - adapter = ThreadListAdapter(style) - binding.threadListRecyclerView.setHasFixedSize(true) - binding.threadListRecyclerView.adapter = adapter binding.threadListRecyclerView.addOnScrollListener(scrollListener) setBackgroundColor(style.backgroundColor) @@ -81,7 +81,7 @@ public class ThreadListView : ConstraintLayout { * @param isLoadingMore Indicator if the loading more view should be shown. */ public fun showThreads(threads: List, isLoadingMore: Boolean) { - val isCurrentlyEmpty = adapter.itemCount == 0 + val isCurrentlyEmpty = requireAdapter().itemCount == 0 val hasThreads = threads.isNotEmpty() binding.threadListRecyclerView.isVisible = hasThreads @@ -90,7 +90,7 @@ public class ThreadListView : ConstraintLayout { val threadItems = threads.map(ThreadListItem::ThreadItem) val loadingMoreItems = if (isLoadingMore) listOf(ThreadListItem.LoadingMoreItem) else emptyList() - adapter.submitList(threadItems + loadingMoreItems) + requireAdapter().submitList(threadItems + loadingMoreItems) scrollListener.enablePagination() @@ -104,7 +104,7 @@ public class ThreadListView : ConstraintLayout { * Shows the loading state of the thread list. */ public fun showLoading() { - adapter.submitList(emptyList()) // clear current list + requireAdapter().submitList(emptyList()) // clear current list binding.threadListRecyclerView.isVisible = false binding.emptyContainer.isVisible = false binding.progressBar.isVisible = true @@ -127,6 +127,21 @@ public class ThreadListView : ConstraintLayout { binding.unreadThreadsBannerTextView.text = bannerText } + /** + * Sets the [ThreadListItemViewHolderFactory] used to create the thread list view holders. + * Use if you want completely custom views for the thread list items. + * Make sure to call this before setting/updating the data in the thread list view. + * + * @param factory The [ThreadListItemViewHolderFactory] to be used for creating the item view holders. + * @throws IllegalStateException if called when a [factory] was already set. + */ + public fun setViewHolderFactory(factory: ThreadListItemViewHolderFactory) { + check(::adapter.isInitialized.not()) { + "Adapter was already initialized, please set ChannelListItemViewHolderFactory first" + } + viewHolderFactory = factory + } + /** * Sets the listener for clicks on the unread threads banner. * @@ -145,7 +160,7 @@ public class ThreadListView : ConstraintLayout { * @param listener The [ThreadClickListener] to be invoked when the user clicks on a thread. */ public fun setThreadClickListener(listener: ThreadClickListener) { - adapter.setThreadClickListener(listener) + this.clickListener = listener } /** @@ -157,6 +172,28 @@ public class ThreadListView : ConstraintLayout { this.loadMoreListener = listener } + /** + * Ensures the [adapter] is initialized before accessing it. + * Useful for cases where a custom [viewHolderFactory] is provided. + */ + private fun requireAdapter(): ThreadListAdapter { + if (::adapter.isInitialized.not()) { + initAdapter() + } + return adapter + } + + private fun initAdapter() { + // Ensure the viewHolderFactory is initialized + if (::viewHolderFactory.isInitialized.not()) { + viewHolderFactory = ThreadListItemViewHolderFactory() + } + viewHolderFactory.setStyle(style) + viewHolderFactory.setThreadClickListener(clickListener) + adapter = ThreadListAdapter(style, viewHolderFactory) + binding.threadListRecyclerView.adapter = adapter + } + private fun applyEmptyStateStyle(style: ThreadListViewStyle) { binding.emptyImage.setImageDrawable(style.emptyStateDrawable) binding.emptyTextView.text = style.emptyStateText diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem.kt similarity index 89% rename from stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt rename to stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem.kt index d5b90ef539e..080930f1428 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package io.getstream.chat.android.ui.feature.threads.list.internal +package io.getstream.chat.android.ui.feature.threads.list.adapter import io.getstream.chat.android.models.Thread /** * Class representing the different types of items that can be rendered in the - * by the [ThreadListAdapter]. + * by the [io.getstream.chat.android.ui.feature.threads.list.adapter.internal.ThreadListAdapter]. */ public sealed interface ThreadListItem { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory.kt new file mode 100644 index 00000000000..5d2a74b94a2 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory.kt @@ -0,0 +1,128 @@ +/* + * 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.feature.threads.list.adapter + +import android.view.ViewGroup +import io.getstream.chat.android.ui.feature.threads.list.ThreadListView +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.BaseThreadListItemViewHolder +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.internal.ThreadItemViewHolder +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.internal.ThreadListLoadingMoreViewHolder + +/** + * Factory responsible for creating ViewHolder instances for the RecyclerView used in the + * [io.getstream.chat.android.ui.feature.threads.list.ThreadListView]. + */ +public open class ThreadListItemViewHolderFactory { + + /** + * The [ThreadListViewStyle] for styling the viewHolders. + */ + protected lateinit var style: ThreadListViewStyle + private set + + /** + * The listener for clicks on the thread items. + */ + protected var clickListener: ThreadListView.ThreadClickListener? = null + private set + + /** + * Returns a view type value based on the type of the given [item]. + * The view type returned here will be used as a parameter in [createViewHolder]. + * + * For built-in view types, see [ThreadListItemViewType] and its constants. + * + * @param item The [ThreadListItem] to check the type of. + */ + public open fun getItemViewType(item: ThreadListItem): Int { + return when (item) { + is ThreadListItem.ThreadItem -> ThreadListItemViewType.ITEM_THREAD + is ThreadListItem.LoadingMoreItem -> ThreadListItemViewType.ITEM_LOADING_MORE + } + } + + /** + * Returns a view type value based on the type of the given [viewHolder]. + * + * For built-in view types, see [ThreadListItemViewType] and its constants. + * + * @param viewHolder The [BaseThreadListItemViewHolder] to check the type of. + */ + public open fun getItemViewType(viewHolder: BaseThreadListItemViewHolder): Int { + return when (viewHolder) { + is ThreadItemViewHolder -> ThreadListItemViewType.ITEM_THREAD + is ThreadListLoadingMoreViewHolder -> ThreadListItemViewType.ITEM_LOADING_MORE + else -> throw IllegalArgumentException("Unhandled ThreadList view holder: $viewHolder") + } + } + + /** + * Creates a new ViewHolder based on the provided [viewType] to be used in the Thread List. + * The [viewType] parameter is determined by [getItemViewType]. + * + * @param parentView The parent of the view. + * @param viewType The type of the item for which the viewHolder is created. + */ + public open fun createViewHolder( + parentView: ViewGroup, + viewType: Int, + ): BaseThreadListItemViewHolder { + return when (viewType) { + ThreadListItemViewType.ITEM_THREAD -> createThreadItemViewHolder(parentView) + ThreadListItemViewType.ITEM_LOADING_MORE -> createLoadingMoreViewHolder(parentView) + else -> throw IllegalArgumentException("Unhandled ThreadList view type: $viewType") + } + } + + /** + * Creates the ViewHolder for the [ThreadListItemViewType.ITEM_THREAD] ([ThreadListItem.ThreadItem]) type. + * + * @param parentView The parent of the view. + */ + protected open fun createThreadItemViewHolder( + parentView: ViewGroup, + ): BaseThreadListItemViewHolder { + return ThreadItemViewHolder(parentView, style, clickListener) + } + + /** + * Creates the ViewHolder for the [ThreadListItemViewType.ITEM_LOADING_MORE] ([ThreadListItem.LoadingMoreItem]) + * type. + * + * @param parentView The parent of the view. + */ + protected open fun createLoadingMoreViewHolder( + parentView: ViewGroup, + ): BaseThreadListItemViewHolder { + return ThreadListLoadingMoreViewHolder(parentView) + } + + /** + * Sets the [ThreadListViewStyle] to be used by the created ViewHolders. + */ + internal fun setStyle(style: ThreadListViewStyle) { + this.style = style + } + + /** + * Sets the [ThreadListView.ThreadClickListener] for clicks on the thread items. + */ + internal fun setThreadClickListener(clickListener: ThreadListView.ThreadClickListener?) { + this.clickListener = clickListener + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType.kt new file mode 100644 index 00000000000..617360ec5fd --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType.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.ui.feature.threads.list.adapter + +/** + * Defines the possible types of a Thread List item. + */ +public object ThreadListItemViewType { + + /** + * Represents an item of type 'thread'. + */ + public const val ITEM_THREAD: Int = 0 + + /** + * Represent a loading more indicator item. + */ + public const val ITEM_LOADING_MORE: Int = 1 +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/internal/ThreadListAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/internal/ThreadListAdapter.kt new file mode 100644 index 00000000000..016bf4924d8 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/internal/ThreadListAdapter.kt @@ -0,0 +1,88 @@ +/* + * 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.feature.threads.list.adapter.internal + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItemViewHolderFactory +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.BaseThreadListItemViewHolder +import io.getstream.log.taggedLogger + +/** + * RecyclerView adapter implementation for displaying a list of threads. + * + * @param style The [ThreadListViewStyle] for item customization. + * @param viewHolderFactory The factory for creating view holders. + */ +internal class ThreadListAdapter( + private val style: ThreadListViewStyle, + private val viewHolderFactory: ThreadListItemViewHolderFactory, +) : ListAdapter>(ThreadListItemDiffCallback) { + + private val logger by taggedLogger("Chat:ThreadListAdapter") + + init { + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long { + return getItem(position).stableId + } + + override fun getItemViewType(position: Int): Int { + return viewHolderFactory.getItemViewType(getItem(position)) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): BaseThreadListItemViewHolder { + return viewHolderFactory.createViewHolder(parent, viewType) + } + + override fun onBindViewHolder(holder: BaseThreadListItemViewHolder, position: Int) { + val item = getItem(position) + val itemViewType = viewHolderFactory.getItemViewType(item) + val holderViewType = viewHolderFactory.getItemViewType(holder) + if (itemViewType != holderViewType) { + // Should never happen + logger.d { "Item view type $itemViewType does not match the holder view type $holderViewType" } + return + } + holder.bindInternal(item) + } + + /** + * [DiffUtil.ItemCallback] for calculating differences between [ThreadListItem]s. + */ + private object ThreadListItemDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { + return oldItem.stableId == newItem.stableId + } + + override fun areContentsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { + return if (oldItem is ThreadListItem.ThreadItem && newItem is ThreadListItem.ThreadItem) { + oldItem.thread == newItem.thread // [Thread] is a data class, equality check is enough + } else { + false + } + } + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder.kt new file mode 100644 index 00000000000..ba94914f13f --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder.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.ui.feature.threads.list.adapter.viewholder + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.log.taggedLogger + +/** + * Base ViewHolder used for displaying items by the + * [io.getstream.chat.android.ui.feature.threads.list.adapter.internal.ThreadListAdapter]. + */ +public abstract class BaseThreadListItemViewHolder(itemView: View) : + RecyclerView.ViewHolder(itemView) { + + private val logger by taggedLogger("Chat:BaseThreadListItemViewHolder") + + /** + * Binds the item to the ViewHolder. + * + * @param item The item to bind. + */ + public abstract fun bind(item: T) + + /** + * Workaround to allow a downcast of the [ThreadListItem] to [T]. + */ + @Suppress("UNCHECKED_CAST") + internal fun bindInternal(item: ThreadListItem) { + val actual = item as? T + if (actual == null) { + // Should never happen + logger.d { "[bindInternal] Failed to cast $item to the expected type." } + return + } + bind(actual) + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt new file mode 100644 index 00000000000..853f3b740bc --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt @@ -0,0 +1,158 @@ +/* + * 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.feature.threads.list.adapter.viewholder.internal + +import android.text.SpannableStringBuilder +import android.view.ViewGroup +import androidx.core.view.isVisible +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.User +import io.getstream.chat.android.ui.ChatUI +import io.getstream.chat.android.ui.R +import io.getstream.chat.android.ui.common.extensions.internal.context +import io.getstream.chat.android.ui.common.extensions.internal.singletonList +import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListBinding +import io.getstream.chat.android.ui.feature.threads.list.ThreadListView +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.BaseThreadListItemViewHolder +import io.getstream.chat.android.ui.font.setTextStyle +import io.getstream.chat.android.ui.utils.extensions.asMention +import io.getstream.chat.android.ui.utils.extensions.bold +import io.getstream.chat.android.ui.utils.extensions.getAttachmentsText +import io.getstream.chat.android.ui.utils.extensions.getTranslatedText +import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater + +/** + * Default ViewHolder for thread items. + * + * @param parentView The parent view group. + * @param style The style object for customizing the thread item appearance. + * @param clickListener The listener for item clicks. + * @param binding The view binding for the thread item. + */ +internal class ThreadItemViewHolder( + parentView: ViewGroup, + style: ThreadListViewStyle, + private val clickListener: ThreadListView.ThreadClickListener?, + private val binding: StreamUiItemThreadListBinding = StreamUiItemThreadListBinding.inflate( + parentView.streamThemeInflater, + parentView, + false, + ), +) : BaseThreadListItemViewHolder(binding.root) { + + private lateinit var thread: Thread + + init { + applyStyle(style) + binding.root.setOnClickListener { + clickListener?.onThreadClick(thread) + } + } + + override fun bind(item: ThreadListItem.ThreadItem) { + this.thread = item.thread + val currentUser = ChatUI.currentUserProvider.getCurrentUser() + bindThreadTitle(currentUser) + bindReplyTo(currentUser) + bindUnreadCountBadge(currentUser) + bindLatestReply(currentUser) + } + + private fun applyStyle(style: ThreadListViewStyle) { + binding.threadImage.setImageDrawable(style.threadIconDrawable) + binding.threadTitleTextView.setTextStyle(style.threadTitleStyle) + binding.replyToTextView.setTextStyle(style.threadReplyToStyle) + // Remove ripple from latestReplyMessageView + binding.latestReplyMessageView.binding.root.background = null + binding.latestReplyMessageView.styleView(style.latestReplyStyle) + binding.unreadCountBadge.setTextStyle(style.unreadCountBadgeTextStyle) + binding.unreadCountBadge.background = style.unreadCountBadgeBackground + } + + private fun bindThreadTitle(currentUser: User?) { + val channel = thread.channel + val title = channel + ?.let { ChatUI.channelNameFormatter.formatChannelName(channel = it, currentUser = currentUser) } + ?: thread.title + binding.threadTitleTextView.text = title + } + + private fun bindReplyTo(currentUser: User?) { + val prefix = binding.root.context.getString(R.string.stream_ui_thread_list_replied_to) + val parentMessageText = formatMessage(thread.parentMessage, currentUser?.asMention(context)) + val replyToText = "$prefix$parentMessageText" + binding.replyToTextView.text = replyToText + } + + private fun bindUnreadCountBadge(currentUser: User?) { + val unreadCount = thread.read + .find { it.user.id == currentUser?.id } + ?.unreadMessages + ?: 0 + if (unreadCount > 0) { + binding.unreadCountBadge.text = + if (unreadCount > MAX_UNREAD_COUNT) "$MAX_UNREAD_COUNT+" else unreadCount.toString() + binding.unreadCountBadge.isVisible = true + } else { + binding.unreadCountBadge.isVisible = false + } + } + + private fun bindLatestReply(currentUser: User?) { + val latestReply = thread.latestReplies.lastOrNull() + if (latestReply != null) { + // User avatar + binding.latestReplyMessageView.binding.userAvatarView.setUser(latestReply.user) + // Sender name + binding.latestReplyMessageView.binding.senderNameLabel.text = latestReply.user.name.bold() + // Reply text + binding.latestReplyMessageView.binding.messageLabel.text = + formatMessage(latestReply, currentUser?.asMention(context)) + // Timestamp + binding.latestReplyMessageView.binding.messageTimeLabel.text = + ChatUI.dateFormatter.formatDate(latestReply.createdAt ?: latestReply.createdLocallyAt) + binding.latestReplyMessageView.isVisible = true + } else { + // Note: should never happen + binding.latestReplyMessageView.isVisible = false + } + } + + private fun formatMessage(message: Message, currentUserMention: String?): CharSequence { + val attachmentsText = message.getAttachmentsText() + val displayedText = message.getTranslatedText() + val previewText = displayedText.trim().let { + if (currentUserMention != null) { + // bold mentions of the current user + it.bold(currentUserMention.singletonList(), ignoreCase = true) + } else { + it + } + } + + return listOf(previewText, attachmentsText) + .filterNot { it.isNullOrEmpty() } + .joinTo(SpannableStringBuilder(), " ") + } + + private companion object { + private const val MAX_UNREAD_COUNT = 99 + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadListLoadingMoreViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadListLoadingMoreViewHolder.kt new file mode 100644 index 00000000000..b1492d0f09c --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadListLoadingMoreViewHolder.kt @@ -0,0 +1,35 @@ +/* + * 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.feature.threads.list.adapter.viewholder.internal + +import android.view.ViewGroup +import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListLoadingMoreBinding +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.BaseThreadListItemViewHolder +import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater + +/** + * ViewHolder for the thread list loading more indicator item. + */ +internal class ThreadListLoadingMoreViewHolder( + parentView: ViewGroup, +) : BaseThreadListItemViewHolder( + itemView = StreamUiItemThreadListLoadingMoreBinding.inflate(parentView.streamThemeInflater, parentView, false).root, +) { + + override fun bind(item: ThreadListItem.LoadingMoreItem) = Unit // No-op +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt deleted file mode 100644 index 708e2db596f..00000000000 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.chat.android.ui.feature.threads.list.internal - -import android.text.SpannableStringBuilder -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.Thread -import io.getstream.chat.android.models.User -import io.getstream.chat.android.ui.ChatUI -import io.getstream.chat.android.ui.R -import io.getstream.chat.android.ui.common.extensions.internal.context -import io.getstream.chat.android.ui.common.extensions.internal.singletonList -import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListBinding -import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListLoadingMoreBinding -import io.getstream.chat.android.ui.feature.threads.list.ThreadListView -import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle -import io.getstream.chat.android.ui.font.setTextStyle -import io.getstream.chat.android.ui.utils.extensions.asMention -import io.getstream.chat.android.ui.utils.extensions.bold -import io.getstream.chat.android.ui.utils.extensions.getAttachmentsText -import io.getstream.chat.android.ui.utils.extensions.getTranslatedText -import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater - -/** - * RecyclerView adapter implementation for displaying a list of threads. - * - * @param style The [ThreadListViewStyle] for item customization. - */ -internal class ThreadListAdapter(private val style: ThreadListViewStyle) : - ListAdapter(ThreadListItemDiffCallback) { - - private var clickListener: ThreadListView.ThreadClickListener? = null - - init { - setHasStableIds(true) - } - - override fun getItemId(position: Int): Long { - return getItem(position).stableId - } - - override fun getItemViewType(position: Int): Int { - val item = getItem(position) - return when (item) { - is ThreadListItem.ThreadItem -> ITEM_THREAD - is ThreadListItem.LoadingMoreItem -> ITEM_LOADING_MORE - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - ITEM_THREAD -> { - StreamUiItemThreadListBinding - .inflate(parent.streamThemeInflater, parent, false) - .let(::ThreadItemViewHolder) - .also { it.applyStyle(style) } - } - - else -> { - StreamUiItemThreadListLoadingMoreBinding - .inflate(parent.streamThemeInflater, parent, false) - .let(::LoadingMoreViewHolder) - } - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - val item = getItem(position) - if (item is ThreadListItem.ThreadItem && holder is ThreadItemViewHolder) { - holder.bind(item.thread) - } - } - - /** - * Sets the listener for clicks on thread items. - */ - fun setThreadClickListener(clickListener: ThreadListView.ThreadClickListener) { - this.clickListener = clickListener - } - - companion object { - private const val ITEM_THREAD = 0 - private const val ITEM_LOADING_MORE = 1 - - private const val MAX_UNREAD_COUNT = 99 - } - - /** - * ViewHolder for thread items. - */ - inner class ThreadItemViewHolder( - private val binding: StreamUiItemThreadListBinding, - ) : RecyclerView.ViewHolder(binding.root) { - - private lateinit var thread: Thread - - init { - binding.root.setOnClickListener { - clickListener?.onThreadClick(thread) - } - } - - /** - * Applies the [ThreadListViewStyle] to the [ThreadItemViewHolder]. - * - * @param style The style to be applied. - */ - fun applyStyle(style: ThreadListViewStyle) { - binding.threadImage.setImageDrawable(style.threadIconDrawable) - binding.threadTitleTextView.setTextStyle(style.threadTitleStyle) - binding.replyToTextView.setTextStyle(style.threadReplyToStyle) - // Remove ripple from latestReplyMessageView - binding.latestReplyMessageView.binding.root.background = null - binding.latestReplyMessageView.styleView(style.latestReplyStyle) - binding.unreadCountBadge.setTextStyle(style.unreadCountBadgeTextStyle) - binding.unreadCountBadge.background = style.unreadCountBadgeBackground - } - - /** - * Binds the given [Thread] to the view. - */ - fun bind(thread: Thread) { - this.thread = thread - val currentUser = ChatUI.currentUserProvider.getCurrentUser() - bindThreadTitle(thread, currentUser) - bindReplyTo(thread, currentUser) - bindUnreadCountBadge(thread, currentUser) - bindLatestReply(thread, currentUser) - } - - private fun bindThreadTitle(thread: Thread, currentUser: User?) { - val channel = thread.channel - val title = channel - ?.let { ChatUI.channelNameFormatter.formatChannelName(channel = it, currentUser = currentUser) } - ?: thread.title - binding.threadTitleTextView.text = title - } - - private fun bindReplyTo(thread: Thread, currentUser: User?) { - val prefix = binding.root.context.getString(R.string.stream_ui_thread_list_replied_to) - val parentMessageText = formatMessage(thread.parentMessage, currentUser?.asMention(context)) - val replyToText = "$prefix$parentMessageText" - binding.replyToTextView.text = replyToText - } - - private fun bindUnreadCountBadge(thread: Thread, currentUser: User?) { - val unreadCount = thread.read - .find { it.user.id == currentUser?.id } - ?.unreadMessages - ?: 0 - if (unreadCount > 0) { - binding.unreadCountBadge.text = - if (unreadCount > MAX_UNREAD_COUNT) "${MAX_UNREAD_COUNT}+" else unreadCount.toString() - binding.unreadCountBadge.isVisible = true - } else { - binding.unreadCountBadge.isVisible = false - } - } - - private fun bindLatestReply(thread: Thread, currentUser: User?) { - val latestReply = thread.latestReplies.lastOrNull() - if (latestReply != null) { - // User avatar - binding.latestReplyMessageView.binding.userAvatarView.setUser(latestReply.user) - // Sender name - binding.latestReplyMessageView.binding.senderNameLabel.text = latestReply.user.name.bold() - // Reply text - binding.latestReplyMessageView.binding.messageLabel.text = - formatMessage(latestReply, currentUser?.asMention(context)) - // Timestamp - binding.latestReplyMessageView.binding.messageTimeLabel.text = - ChatUI.dateFormatter.formatDate(latestReply.createdAt ?: latestReply.createdLocallyAt) - binding.latestReplyMessageView.isVisible = true - } else { - // Note: should never happen - binding.latestReplyMessageView.isVisible = false - } - } - - private fun formatMessage(message: Message, currentUserMention: String?): CharSequence { - val attachmentsText = message.getAttachmentsText() - val displayedText = message.getTranslatedText() - val previewText = displayedText.trim().let { - if (currentUserMention != null) { - // bold mentions of the current user - it.bold(currentUserMention.singletonList(), ignoreCase = true) - } else { - it - } - } - - return listOf(previewText, attachmentsText) - .filterNot { it.isNullOrEmpty() } - .joinTo(SpannableStringBuilder(), " ") - } - } - - /** - * ViewHolder for the loading more item. - */ - inner class LoadingMoreViewHolder( - binding: StreamUiItemThreadListLoadingMoreBinding, - ) : RecyclerView.ViewHolder(binding.root) - - /** - * [DiffUtil.ItemCallback] for calculating differences between [ThreadListItem]s. - */ - private object ThreadListItemDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { - return oldItem.stableId == newItem.stableId - } - - override fun areContentsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { - return if (oldItem is ThreadListItem.ThreadItem && newItem is ThreadListItem.ThreadItem) { - oldItem.thread == newItem.thread // [Thread] is a data class, equality check is enough - } else { - false - } - } - } -} From d02ebe2067a93e1df3defca4821715ffddd8889b Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Mon, 2 Dec 2024 10:06:02 +0100 Subject: [PATCH 07/22] [AND-3] Add @JvmName to ThreadListViewModelBinding.kt --- .../api/stream-chat-android-ui-components.api | 8 ++++---- ...adListViewBinding.kt => ThreadListViewModelBinding.kt} | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) rename stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/{ThreadListViewBinding.kt => ThreadListViewModelBinding.kt} (96%) diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 33905e9d0c8..765a79ec172 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -5058,10 +5058,6 @@ public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel public static final fun bind (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel;Lio/getstream/chat/android/ui/feature/search/list/SearchResultListView;Landroidx/lifecycle/LifecycleOwner;)V } -public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBindingKt { - public static final fun bindView (Lio/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel;Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView;Landroidx/lifecycle/LifecycleOwner;)V -} - public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel : androidx/lifecycle/ViewModel { public fun (Lio/getstream/chat/android/ui/common/feature/threads/ThreadListController;)V public final fun getState ()Landroidx/lifecycle/LiveData; @@ -5069,6 +5065,10 @@ public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListView public final fun loadNextPage ()V } +public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModelBinding { + public static final fun bind (Lio/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel;Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView;Landroidx/lifecycle/LifecycleOwner;)V +} + public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { public fun ()V public fun (III)V diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModelBinding.kt similarity index 96% rename from stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt rename to stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModelBinding.kt index 957d326ccb8..b55d3b8a6be 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModelBinding.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:JvmName("ThreadListViewModelBinding") + package io.getstream.chat.android.ui.viewmodel.threads import androidx.lifecycle.LifecycleOwner @@ -26,6 +28,7 @@ import io.getstream.chat.android.ui.feature.threads.list.ThreadListView * This function sets listeners on the view and ViewModel. Call this method * before setting any additional listeners on these objects yourself. */ +@JvmName("bind") public fun ThreadListViewModel.bindView(view: ThreadListView, lifecycleOwner: LifecycleOwner) { state.observe(lifecycleOwner) { state -> when { From 1feeb69ea072b6109abfc6e8f7d1a670e6eae38e Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Mon, 25 Nov 2024 15:27:00 +0100 Subject: [PATCH 08/22] [AND-3] Implement ThreadListView for XML SDK. --- .../src/main/res/values/strings.xml | 2 +- .../api/stream-chat-android-ui-components.api | 118 ++ .../ui/feature/threads/list/ThreadListView.kt | 239 ++++ .../threads/list/ThreadListViewStyle.kt | 373 ++++++ .../list/internal/ThreadListAdapter.kt | 240 ++++ .../threads/list/internal/ThreadListItem.kt | 51 + .../chat/android/ui/helper/TransformStyle.kt | 4 + .../threads/ThreadListViewBinding.kt | 39 + .../viewmodel/threads/ThreadListViewModel.kt | 54 + .../threads/ThreadsViewModelFactory.kt | 51 + .../main/res/drawable/stream_ui_ic_thread.xml | 27 + .../drawable/stream_ui_ic_threads_empty.xml | 27 + .../main/res/drawable/stream_ui_ic_union.xml | 27 + .../stream_ui_shape_unread_threads_banner.xml | 20 + .../res/layout/stream_ui_item_thread_list.xml | 97 ++ ...tream_ui_item_thread_list_loading_more.xml | 39 + .../res/layout/stream_ui_thread_list_view.xml | 101 ++ .../src/main/res/values/attrs.xml | 1 + .../res/values/attrs_thread_list_view.xml | 122 ++ .../main/res/values/strings_thread_list.xml | 24 + .../src/main/res/values/styles.xml | 1062 +++++++++++++---- 21 files changed, 2461 insertions(+), 257 deletions(-) create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory.kt create mode 100644 stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_thread.xml create mode 100644 stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_threads_empty.xml create mode 100644 stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_union.xml create mode 100644 stream-chat-android-ui-components/src/main/res/drawable/stream_ui_shape_unread_threads_banner.xml create mode 100644 stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list.xml create mode 100644 stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list_loading_more.xml create mode 100644 stream-chat-android-ui-components/src/main/res/layout/stream_ui_thread_list_view.xml create mode 100644 stream-chat-android-ui-components/src/main/res/values/attrs_thread_list_view.xml create mode 100644 stream-chat-android-ui-components/src/main/res/values/strings_thread_list.xml 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 0337193015b..0549fc3e955 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -237,7 +237,7 @@ View Comments - No threads here yet... + No threads here yet… %d new thread %d new threads diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index b4ed41a030a..70ac6c31e1c 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -3771,6 +3771,104 @@ public final class io/getstream/chat/android/ui/feature/search/list/SearchResult public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/ui/feature/threads/list/ThreadListView : androidx/constraintlayout/widget/ConstraintLayout { + public fun (Landroid/content/Context;)V + public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V + public final fun setLoadMoreListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$LoadMoreListener;)V + public final fun setThreadClickListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$ThreadClickListener;)V + public final fun setUnreadThreadsBannerClickListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$UnreadThreadsBannerClickListener;)V + public final fun showLoading ()V + public final fun showThreads (Ljava/util/List;Z)V + public final fun showUnreadThreadsBanner (I)V +} + +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/ThreadListView$LoadMoreListener { + public abstract fun onLoadMore ()V +} + +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/ThreadListView$ThreadClickListener { + public abstract fun onThreadClick (Lio/getstream/chat/android/models/Thread;)V +} + +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/ThreadListView$UnreadThreadsBannerClickListener { + public abstract fun onUnreadThreadsBannerClick ()V +} + +public final class io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle : io/getstream/chat/android/ui/helper/ViewStyle { + public fun (ILandroid/graphics/drawable/Drawable;Ljava/lang/String;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIIIIIII)V + public final fun component1 ()I + public final fun component10 ()Landroid/graphics/drawable/Drawable; + public final fun component11 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component12 ()Landroid/graphics/drawable/Drawable; + public final fun component13 ()Landroid/graphics/drawable/Drawable; + public final fun component14 ()I + public final fun component15 ()I + public final fun component16 ()I + public final fun component17 ()I + public final fun component18 ()I + public final fun component19 ()I + public final fun component2 ()Landroid/graphics/drawable/Drawable; + public final fun component20 ()I + public final fun component21 ()I + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component5 ()Landroid/graphics/drawable/Drawable; + public final fun component6 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component7 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component8 ()Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle; + public final fun component9 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun copy (ILandroid/graphics/drawable/Drawable;Ljava/lang/String;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIIIIIII)Lio/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle;ILandroid/graphics/drawable/Drawable;Ljava/lang/String;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIIIIIIIILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle; + public fun equals (Ljava/lang/Object;)Z + public final fun getBackgroundColor ()I + public final fun getBannerBackground ()Landroid/graphics/drawable/Drawable; + public final fun getBannerIcon ()Landroid/graphics/drawable/Drawable; + public final fun getBannerMarginBottom ()I + public final fun getBannerMarginLeft ()I + public final fun getBannerMarginRight ()I + public final fun getBannerMarginTop ()I + public final fun getBannerPaddingBottom ()I + public final fun getBannerPaddingLeft ()I + public final fun getBannerPaddingRight ()I + public final fun getBannerPaddingTop ()I + public final fun getBannerTextStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun getEmptyStateDrawable ()Landroid/graphics/drawable/Drawable; + public final fun getEmptyStateText ()Ljava/lang/String; + public final fun getEmptyStateTextStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun getLatestReplyStyle ()Lio/getstream/chat/android/ui/feature/messages/preview/MessagePreviewStyle; + public final fun getThreadIconDrawable ()Landroid/graphics/drawable/Drawable; + public final fun getThreadReplyToStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun getThreadTitleStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun getUnreadCountBadgeBackground ()Landroid/graphics/drawable/Drawable; + public final fun getUnreadCountBadgeTextStyle ()Lio/getstream/chat/android/ui/font/TextStyle; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { + public abstract fun getStableId ()J +} + +public final class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$LoadingMoreItem : io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { + public static final field INSTANCE Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$LoadingMoreItem; + public fun equals (Ljava/lang/Object;)Z + public fun getStableId ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem : io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { + public fun (Lio/getstream/chat/android/models/Thread;)V + public final fun component1 ()Lio/getstream/chat/android/models/Thread; + public final fun copy (Lio/getstream/chat/android/models/Thread;)Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem;Lio/getstream/chat/android/models/Thread;ILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem; + public fun equals (Ljava/lang/Object;)Z + public fun getStableId ()J + public final fun getThread ()Lio/getstream/chat/android/models/Thread; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class io/getstream/chat/android/ui/font/ChatFonts { public abstract fun getFont (Lio/getstream/chat/android/ui/font/TextStyle;)Landroid/graphics/Typeface; public abstract fun setFont (Lio/getstream/chat/android/ui/font/TextStyle;Landroid/widget/TextView;)V @@ -3915,6 +4013,7 @@ public final class io/getstream/chat/android/ui/helper/TransformStyle { public static final fun getSearchInputViewStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getSearchResultListViewStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getSingleReactionViewStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; + public static final fun getThreadListViewStyle ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getTypingIndicatorViewStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getUnreadLabelButtonStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; public static final fun getUnsupportedAttachmentStyleTransformer ()Lio/getstream/chat/android/ui/helper/StyleTransformer; @@ -3945,6 +4044,7 @@ public final class io/getstream/chat/android/ui/helper/TransformStyle { public static final fun setSearchInputViewStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setSearchResultListViewStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setSingleReactionViewStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V + public static final fun setThreadListViewStyle (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setTypingIndicatorViewStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setUnreadLabelButtonStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V public static final fun setUnsupportedAttachmentStyleTransformer (Lio/getstream/chat/android/ui/helper/StyleTransformer;)V @@ -4935,6 +5035,24 @@ public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel public static final fun bind (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel;Lio/getstream/chat/android/ui/feature/search/list/SearchResultListView;Landroidx/lifecycle/LifecycleOwner;)V } +public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBindingKt { + public static final fun bindView (Lio/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel;Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView;Landroidx/lifecycle/LifecycleOwner;)V +} + +public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel : androidx/lifecycle/ViewModel { + public fun (Lio/getstream/chat/android/ui/common/feature/threads/ThreadListController;)V + public final fun getState ()Landroidx/lifecycle/LiveData; + public final fun load ()V + public final fun loadNextPage ()V +} + +public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { + public fun ()V + public fun (III)V + public synthetic fun (IIIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; +} + public final class io/getstream/chat/android/ui/viewmodel/typing/TypingIndicatorViewModel : androidx/lifecycle/ViewModel { public fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;)V public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt new file mode 100644 index 00000000000..3803f93cb93 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt @@ -0,0 +1,239 @@ +/* + * 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.feature.threads.list + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.content.res.AppCompatResources +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.ui.R +import io.getstream.chat.android.ui.databinding.StreamUiThreadListViewBinding +import io.getstream.chat.android.ui.feature.threads.list.internal.ThreadListAdapter +import io.getstream.chat.android.ui.feature.threads.list.internal.ThreadListItem +import io.getstream.chat.android.ui.font.setTextStyle +import io.getstream.chat.android.ui.utils.extensions.createStreamThemeWrapper +import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater +import io.getstream.chat.android.ui.widgets.EndlessScrollListener + +/** + * View rendering a paginated list of threads. + * Optionally, it renders a banner informing about new threads/thread messages outside of the loaded pages of threads. + */ +public class ThreadListView : ConstraintLayout { + + private val binding = StreamUiThreadListViewBinding.inflate(streamThemeInflater, this) + private lateinit var style: ThreadListViewStyle + private lateinit var adapter: ThreadListAdapter + private val scrollListener = EndlessScrollListener(LOAD_MORE_THRESHOLD) { + loadMoreListener?.onLoadMore() + } + + /** + * Creates a [ThreadListView] from the given [Context]. + */ + public constructor(context: Context) : super(context.createStreamThemeWrapper()) { + init(null) + } + + /** + * Creates a [ThreadListView] from the given [Context] and [AttributeSet]. + */ + public constructor(context: Context, attrs: AttributeSet?) : super(context.createStreamThemeWrapper(), attrs) { + init(attrs) + } + + private var loadMoreListener: LoadMoreListener? = null + + private fun init(attrs: AttributeSet?) { + style = ThreadListViewStyle(context, attrs) + adapter = ThreadListAdapter(style) + + binding.threadListRecyclerView.setHasFixedSize(true) + binding.threadListRecyclerView.adapter = adapter + binding.threadListRecyclerView.addOnScrollListener(scrollListener) + + binding.threadListRecyclerView.addItemDecoration( + DividerItemDecoration( + context, + LinearLayoutManager.VERTICAL, + ).apply { + setDrawable(AppCompatResources.getDrawable(context, R.drawable.stream_ui_divider)!!) + }, + ) + + setBackgroundColor(style.backgroundColor) + applyEmptyStateStyle(style) + applyBannerStyle(style) + } + + /** + * Shows a list of threads. + * + * @param threads The list of [Thread]s to show. + * @param isLoadingMore Indicator if the loading more view should be shown. + */ + public fun showThreads(threads: List, isLoadingMore: Boolean) { + val isCurrentlyEmpty = adapter.itemCount == 0 + val hasThreads = threads.isNotEmpty() + + binding.threadListRecyclerView.isVisible = hasThreads + binding.emptyContainer.isVisible = !hasThreads + binding.progressBar.isVisible = false + + val threadItems = threads.map(ThreadListItem::ThreadItem) + val loadingMoreItems = if (isLoadingMore) listOf(ThreadListItem.LoadingMoreItem) else emptyList() + adapter.submitList(threadItems + loadingMoreItems) + + scrollListener.enablePagination() + + if (isCurrentlyEmpty && hasThreads) { + // Data is fully reloaded, ensure list is scrolled to the top + binding.threadListRecyclerView.scrollToPosition(0) + } + } + + /** + * Shows the loading state of the thread list. + */ + public fun showLoading() { + adapter.submitList(emptyList()) // clear current list + binding.threadListRecyclerView.isVisible = false + binding.emptyContainer.isVisible = false + binding.progressBar.isVisible = true + scrollListener.disablePagination() + } + + /** + * Show the 'unread threads' banner. + * Hides the banner if [unreadThreadsCount] == 0. + * + * @param unreadThreadsCount The number of unread threads. + */ + public fun showUnreadThreadsBanner(unreadThreadsCount: Int) { + val bannerText = context.resources.getQuantityString( + R.plurals.stream_ui_thread_list_new_threads, + unreadThreadsCount, + unreadThreadsCount, + ) + binding.unreadThreadsBannerTextView.isVisible = unreadThreadsCount > 0 + binding.unreadThreadsBannerTextView.text = bannerText + } + + /** + * Sets the listener for clicks on the unread threads banner. + * + * @param listener The [UnreadThreadsBannerClickListener] to be invoked when the user clicks on the unread threads + * banner. + */ + public fun setUnreadThreadsBannerClickListener(listener: UnreadThreadsBannerClickListener) { + binding.unreadThreadsBannerTextView.setOnClickListener { + listener.onUnreadThreadsBannerClick() + } + } + + /** + * Sets the listener for clicks on threads. + * + * @param listener The [ThreadClickListener] to be invoked when the user clicks on a thread. + */ + public fun setThreadClickListener(listener: ThreadClickListener) { + adapter.setThreadClickListener(listener) + } + + /** + * Sets the listener requesting loading of more threads. + * + * @param listener The [LoadMoreListener] to be invoked when the end of the thread list is reached. + */ + public fun setLoadMoreListener(listener: LoadMoreListener) { + this.loadMoreListener = listener + } + + private fun applyEmptyStateStyle(style: ThreadListViewStyle) { + binding.emptyImage.setImageDrawable(style.emptyStateDrawable) + binding.emptyTextView.text = style.emptyStateText + binding.emptyTextView.setTextStyle(style.emptyStateTextStyle) + } + + private fun applyBannerStyle(style: ThreadListViewStyle) { + binding.unreadThreadsBannerTextView.setTextStyle(style.bannerTextStyle) + binding.unreadThreadsBannerTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( + null, + null, + style.bannerIcon, + null, + ) + binding.unreadThreadsBannerTextView.background = style.bannerBackground + binding.unreadThreadsBannerTextView.updatePadding( + left = style.bannerPaddingLeft, + top = style.bannerPaddingTop, + right = style.bannerPaddingRight, + bottom = style.bannerPaddingBottom, + ) + binding.unreadThreadsBannerTextView.updateLayoutParams { + leftMargin = style.bannerMarginLeft + topMargin = style.bannerMarginTop + rightMargin = style.bannerMarginRight + bottomMargin = style.bannerMarginBottom + } + } + + private companion object { + private const val LOAD_MORE_THRESHOLD = 10 + } + + /** + * Listener for clicks on the "unread threads" banner. + */ + public fun interface UnreadThreadsBannerClickListener { + + /** + * Called when the user clicks on the unread threads banner. + */ + public fun onUnreadThreadsBannerClick() + } + + /** + * Listener for clicks on the thread list. + */ + public fun interface ThreadClickListener { + + /** + * Called when the user clicks on a thread in the list. + * + * @param thread The clicked [Thread]. + */ + public fun onThreadClick(thread: Thread) + } + + /** + * Listener invoked when the end of the thread list is reached, and a new page of threads should be loaded. + */ + public fun interface LoadMoreListener { + + /** + * Called when a new page of threads should be loaded. + */ + public fun onLoadMore() + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt new file mode 100644 index 00000000000..969f6d42aa4 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt @@ -0,0 +1,373 @@ +/* + * 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.feature.threads.list + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.annotation.ColorInt +import androidx.annotation.Px +import io.getstream.chat.android.ui.R +import io.getstream.chat.android.ui.feature.messages.preview.MessagePreviewStyle +import io.getstream.chat.android.ui.font.TextStyle +import io.getstream.chat.android.ui.helper.TransformStyle +import io.getstream.chat.android.ui.helper.ViewStyle +import io.getstream.chat.android.ui.utils.extensions.getColorCompat +import io.getstream.chat.android.ui.utils.extensions.getDimension +import io.getstream.chat.android.ui.utils.extensions.getDrawableCompat + +/** + * Class holding the customizable styling parameters for [ThreadListView]. + * + * @param backgroundColor The background color of the thread list. + * @param emptyStateDrawable Drawable shown when there are no threads. + * @param emptyStateText Text shown when there are no threads. + * @param emptyStateTextStyle Style for the empty state text. + * @param threadIconDrawable Drawable for the thread icon. + * @param threadTitleStyle Style for the thread title. + * @param threadReplyToStyle Style for the thread reply to text. + * @param latestReplyStyle Style for the latest reply message preview. + * @param unreadCountBadgeTextStyle Style for the unread count badge. + * @param unreadCountBadgeBackground Background for the unread count badge. + * @param bannerTextStyle Style for the unread threads banner text. + * @param bannerIcon Icon for the unread threads banner. + * @param bannerBackground Background for the unread threads banner. + * @param bannerPaddingLeft Left padding for the unread threads banner. + * @param bannerPaddingTop Top padding for the unread threads banner. + * @param bannerPaddingRight Right padding for the unread threads banner. + * @param bannerPaddingBottom Bottom padding for the unread threads banner. + * @param bannerMarginLeft Left margin for the unread threads banner. + * @param bannerMarginTop Top margin for the unread threads banner. + * @param bannerMarginRight Right margin for the unread threads banner. + * @param bannerMarginBottom Bottom margin for the unread threads banner. + */ +public data class ThreadListViewStyle( + // General + @ColorInt public val backgroundColor: Int, + // Empty state + public val emptyStateDrawable: Drawable, + public val emptyStateText: String, + public val emptyStateTextStyle: TextStyle, + // Results + public val threadIconDrawable: Drawable, + public val threadTitleStyle: TextStyle, + public val threadReplyToStyle: TextStyle, + public val latestReplyStyle: MessagePreviewStyle, + public val unreadCountBadgeTextStyle: TextStyle, + public val unreadCountBadgeBackground: Drawable, + // Unread threads banner + public val bannerTextStyle: TextStyle, + public val bannerIcon: Drawable, + public val bannerBackground: Drawable, + @Px public val bannerPaddingLeft: Int, + @Px public val bannerPaddingTop: Int, + @Px public val bannerPaddingRight: Int, + @Px public val bannerPaddingBottom: Int, + @Px public val bannerMarginLeft: Int, + @Px public val bannerMarginTop: Int, + @Px public val bannerMarginRight: Int, + @Px public val bannerMarginBottom: Int, +) : ViewStyle { + + @Suppress("TooManyFunctions") + internal companion object { + + /** + * Creates a [ThreadListViewStyle] from the declared XML properties. + */ + operator fun invoke(context: Context, attrs: AttributeSet?): ThreadListViewStyle { + context.obtainStyledAttributes( + attrs, + R.styleable.ThreadListView, + R.attr.streamUiThreadListStyle, + R.style.StreamUi_ThreadList, + ).use { typedArray -> + // General + val backgroundColor = backgroundColor(context, typedArray) + // Empty state + val emptyStateDrawable = emptyStateDrawable(context, typedArray) + val emptyStateText = emptyStateText(context, typedArray) + val emptyStateTextStyle = emptyStateTextStyle(context, typedArray) + // Results + val threadIconDrawable = threadIconDrawable(context, typedArray) + val threadTitleStyle = threadTitleStyle(context, typedArray) + val threadReplyToStyle = threadReplyToStyle(context, typedArray) + val latestReplySenderStyle = threadLatestReplySenderStyle(context, typedArray) + val latestReplyMessageStyle = threadLatestReplyMessageStyle(context, typedArray) + val latestReplyTimeStyle = threadLatestReplyTimeStyle(context, typedArray) + val unreadCountBadgeTextStyle = unreadCountBadgeTextStyle(context, typedArray) + val unreadCountBadgeBackground = unreadCountBadgeBackground(context, typedArray) + // Unread threads banner + val bannerTextStyle = bannerTextStyle(context, typedArray) + val bannerIcon = bannerIcon(context, typedArray) + val bannerBackground = bannerBackground(context, typedArray) + val bannerPaddingLeft = bannerPaddingLeft(context, typedArray) + val bannerPaddingTop = bannerPaddingTop(context, typedArray) + val bannerPaddingRight = bannerPaddingRight(context, typedArray) + val bannerPaddingBottom = bannerPaddingBottom(context, typedArray) + val bannerMarginLeft = bannerMarginLeft(context, typedArray) + val bannerMarginTop = bannerMarginTop(context, typedArray) + val bannerMarginRight = bannerMarginRight(context, typedArray) + val bannerMarginBottom = bannerMarginBottom(context, typedArray) + return ThreadListViewStyle( + backgroundColor = backgroundColor, + emptyStateDrawable = emptyStateDrawable, + emptyStateText = emptyStateText, + emptyStateTextStyle = emptyStateTextStyle, + threadIconDrawable = threadIconDrawable, + threadTitleStyle = threadTitleStyle, + threadReplyToStyle = threadReplyToStyle, + latestReplyStyle = MessagePreviewStyle( + messageSenderTextStyle = latestReplySenderStyle, + messageTextStyle = latestReplyMessageStyle, + messageTimeTextStyle = latestReplyTimeStyle, + ), + unreadCountBadgeTextStyle = unreadCountBadgeTextStyle, + unreadCountBadgeBackground = unreadCountBadgeBackground, + bannerTextStyle = bannerTextStyle, + bannerIcon = bannerIcon, + bannerBackground = bannerBackground, + bannerPaddingLeft = bannerPaddingLeft, + bannerPaddingTop = bannerPaddingTop, + bannerPaddingRight = bannerPaddingRight, + bannerPaddingBottom = bannerPaddingBottom, + bannerMarginLeft = bannerMarginLeft, + bannerMarginTop = bannerMarginTop, + bannerMarginRight = bannerMarginRight, + bannerMarginBottom = bannerMarginBottom, + ).let(TransformStyle.threadListViewStyle::transform) + } + } + + private fun backgroundColor(context: Context, typedArray: TypedArray) = typedArray.getColor( + R.styleable.ThreadListView_streamUiThreadListBackground, + context.getColorCompat(R.color.stream_ui_white_snow), + ) + + private fun emptyStateDrawable(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListEmptyStateDrawable) + ?: context.getDrawableCompat(R.drawable.stream_ui_ic_threads_empty)!! + + private fun emptyStateText(context: Context, typedArray: TypedArray) = + typedArray.getString(R.styleable.ThreadListView_streamUiThreadListEmptyStateText) + ?: context.getString(R.string.stream_ui_thread_list_empty_title) + + private fun emptyStateTextStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListEmptyStateTextSize, + context.getDimension(R.dimen.stream_ui_text_large), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListEmptyStateTextColor, + context.getColorCompat(R.color.stream_ui_text_color_secondary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListEmptyStateTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListEmptyStateTextFont, + ) + .style(R.styleable.ThreadListView_streamUiThreadListEmptyStateTextStyle, Typeface.NORMAL) + .build() + + private fun threadIconDrawable(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListThreadIconDrawable) + ?: context.getDrawableCompat(R.drawable.stream_ui_ic_thread)!! + + private fun threadTitleStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadTitleTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadTitleTextColor, + context.getColorCompat(R.color.stream_ui_text_color_primary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadTitleTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadTitleTextFont, + ) + .style(R.styleable.ThreadListView_streamUiThreadListThreadTitleTextStyle, Typeface.BOLD) + .build() + + private fun threadReplyToStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextColor, + context.getColorCompat(R.color.stream_ui_text_color_secondary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextFont, + ) + .style(R.styleable.ThreadListView_streamUiThreadListThreadReplyToTextStyle, Typeface.NORMAL) + .build() + + private fun threadLatestReplySenderStyle(context: Context, typedArray: TypedArray) = + TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextColor, + context.getColorCompat(R.color.stream_ui_text_color_primary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplySenderTextStyle, + Typeface.BOLD, + ) + .build() + + private fun threadLatestReplyMessageStyle(context: Context, typedArray: TypedArray) = + TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextColor, + context.getColorCompat(R.color.stream_ui_text_color_secondary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyMessageTextStyle, + Typeface.NORMAL, + ) + .build() + + private fun threadLatestReplyTimeStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextSize, + context.getDimension(R.dimen.stream_ui_text_medium), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextColor, + context.getColorCompat(R.color.stream_ui_text_color_secondary), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListThreadLatestReplyTimeTextStyle, + Typeface.NORMAL, + ) + .build() + + private fun unreadCountBadgeTextStyle(context: Context, typedArray: TypedArray) = TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextSize, + context.getDimension(R.dimen.stream_ui_text_small), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextColor, + context.getColorCompat(R.color.stream_ui_white), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeTextStyle, + Typeface.NORMAL, + ) + .build() + + private fun unreadCountBadgeBackground(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListThreadUnreadCountBadgeBackground) + ?: context.getDrawableCompat(R.drawable.stream_ui_shape_badge_background)!! + + private fun bannerTextStyle(context: Context, typedArray: TypedArray) = + TextStyle.Builder(typedArray) + .size( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextSize, + context.getDimension(R.dimen.stream_ui_text_large), + ) + .color( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextColor, + context.getColorCompat(R.color.stream_ui_white), + ) + .font( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextFontAssets, + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextFont, + ) + .style( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerTextStyle, + Typeface.NORMAL, + ) + .build() + + private fun bannerIcon(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerIcon) + ?: context.getDrawableCompat(R.drawable.stream_ui_ic_union)!! + + private fun bannerBackground(context: Context, typedArray: TypedArray) = + typedArray.getDrawable(R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerBackground) + ?: context.getDrawableCompat(R.drawable.stream_ui_shape_unread_threads_banner)!! + + private fun bannerPaddingLeft(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerPaddingLeft, + context.getDimension(R.dimen.stream_ui_spacing_medium), + ) + + private fun bannerPaddingTop(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerPaddingTop, + context.getDimension(R.dimen.stream_ui_spacing_medium), + ) + + private fun bannerPaddingRight(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerPaddingRight, + context.getDimension(R.dimen.stream_ui_spacing_medium), + ) + + private fun bannerPaddingBottom(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerPaddingBottom, + context.getDimension(R.dimen.stream_ui_spacing_medium), + ) + + private fun bannerMarginLeft(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerMarginLeft, + context.getDimension(R.dimen.stream_ui_spacing_small), + ) + + private fun bannerMarginTop(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerMarginTop, + context.getDimension(R.dimen.stream_ui_spacing_small), + ) + + private fun bannerMarginRight(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerMarginRight, + context.getDimension(R.dimen.stream_ui_spacing_small), + ) + + private fun bannerMarginBottom(context: Context, typedArray: TypedArray) = typedArray.getDimensionPixelSize( + R.styleable.ThreadListView_streamUiThreadListUnreadThreadsBannerMarginBottom, + context.getDimension(R.dimen.stream_ui_spacing_small), + ) + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt new file mode 100644 index 00000000000..708e2db596f --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt @@ -0,0 +1,240 @@ +/* + * 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.feature.threads.list.internal + +import android.text.SpannableStringBuilder +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.User +import io.getstream.chat.android.ui.ChatUI +import io.getstream.chat.android.ui.R +import io.getstream.chat.android.ui.common.extensions.internal.context +import io.getstream.chat.android.ui.common.extensions.internal.singletonList +import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListBinding +import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListLoadingMoreBinding +import io.getstream.chat.android.ui.feature.threads.list.ThreadListView +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle +import io.getstream.chat.android.ui.font.setTextStyle +import io.getstream.chat.android.ui.utils.extensions.asMention +import io.getstream.chat.android.ui.utils.extensions.bold +import io.getstream.chat.android.ui.utils.extensions.getAttachmentsText +import io.getstream.chat.android.ui.utils.extensions.getTranslatedText +import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater + +/** + * RecyclerView adapter implementation for displaying a list of threads. + * + * @param style The [ThreadListViewStyle] for item customization. + */ +internal class ThreadListAdapter(private val style: ThreadListViewStyle) : + ListAdapter(ThreadListItemDiffCallback) { + + private var clickListener: ThreadListView.ThreadClickListener? = null + + init { + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long { + return getItem(position).stableId + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return when (item) { + is ThreadListItem.ThreadItem -> ITEM_THREAD + is ThreadListItem.LoadingMoreItem -> ITEM_LOADING_MORE + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + ITEM_THREAD -> { + StreamUiItemThreadListBinding + .inflate(parent.streamThemeInflater, parent, false) + .let(::ThreadItemViewHolder) + .also { it.applyStyle(style) } + } + + else -> { + StreamUiItemThreadListLoadingMoreBinding + .inflate(parent.streamThemeInflater, parent, false) + .let(::LoadingMoreViewHolder) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = getItem(position) + if (item is ThreadListItem.ThreadItem && holder is ThreadItemViewHolder) { + holder.bind(item.thread) + } + } + + /** + * Sets the listener for clicks on thread items. + */ + fun setThreadClickListener(clickListener: ThreadListView.ThreadClickListener) { + this.clickListener = clickListener + } + + companion object { + private const val ITEM_THREAD = 0 + private const val ITEM_LOADING_MORE = 1 + + private const val MAX_UNREAD_COUNT = 99 + } + + /** + * ViewHolder for thread items. + */ + inner class ThreadItemViewHolder( + private val binding: StreamUiItemThreadListBinding, + ) : RecyclerView.ViewHolder(binding.root) { + + private lateinit var thread: Thread + + init { + binding.root.setOnClickListener { + clickListener?.onThreadClick(thread) + } + } + + /** + * Applies the [ThreadListViewStyle] to the [ThreadItemViewHolder]. + * + * @param style The style to be applied. + */ + fun applyStyle(style: ThreadListViewStyle) { + binding.threadImage.setImageDrawable(style.threadIconDrawable) + binding.threadTitleTextView.setTextStyle(style.threadTitleStyle) + binding.replyToTextView.setTextStyle(style.threadReplyToStyle) + // Remove ripple from latestReplyMessageView + binding.latestReplyMessageView.binding.root.background = null + binding.latestReplyMessageView.styleView(style.latestReplyStyle) + binding.unreadCountBadge.setTextStyle(style.unreadCountBadgeTextStyle) + binding.unreadCountBadge.background = style.unreadCountBadgeBackground + } + + /** + * Binds the given [Thread] to the view. + */ + fun bind(thread: Thread) { + this.thread = thread + val currentUser = ChatUI.currentUserProvider.getCurrentUser() + bindThreadTitle(thread, currentUser) + bindReplyTo(thread, currentUser) + bindUnreadCountBadge(thread, currentUser) + bindLatestReply(thread, currentUser) + } + + private fun bindThreadTitle(thread: Thread, currentUser: User?) { + val channel = thread.channel + val title = channel + ?.let { ChatUI.channelNameFormatter.formatChannelName(channel = it, currentUser = currentUser) } + ?: thread.title + binding.threadTitleTextView.text = title + } + + private fun bindReplyTo(thread: Thread, currentUser: User?) { + val prefix = binding.root.context.getString(R.string.stream_ui_thread_list_replied_to) + val parentMessageText = formatMessage(thread.parentMessage, currentUser?.asMention(context)) + val replyToText = "$prefix$parentMessageText" + binding.replyToTextView.text = replyToText + } + + private fun bindUnreadCountBadge(thread: Thread, currentUser: User?) { + val unreadCount = thread.read + .find { it.user.id == currentUser?.id } + ?.unreadMessages + ?: 0 + if (unreadCount > 0) { + binding.unreadCountBadge.text = + if (unreadCount > MAX_UNREAD_COUNT) "${MAX_UNREAD_COUNT}+" else unreadCount.toString() + binding.unreadCountBadge.isVisible = true + } else { + binding.unreadCountBadge.isVisible = false + } + } + + private fun bindLatestReply(thread: Thread, currentUser: User?) { + val latestReply = thread.latestReplies.lastOrNull() + if (latestReply != null) { + // User avatar + binding.latestReplyMessageView.binding.userAvatarView.setUser(latestReply.user) + // Sender name + binding.latestReplyMessageView.binding.senderNameLabel.text = latestReply.user.name.bold() + // Reply text + binding.latestReplyMessageView.binding.messageLabel.text = + formatMessage(latestReply, currentUser?.asMention(context)) + // Timestamp + binding.latestReplyMessageView.binding.messageTimeLabel.text = + ChatUI.dateFormatter.formatDate(latestReply.createdAt ?: latestReply.createdLocallyAt) + binding.latestReplyMessageView.isVisible = true + } else { + // Note: should never happen + binding.latestReplyMessageView.isVisible = false + } + } + + private fun formatMessage(message: Message, currentUserMention: String?): CharSequence { + val attachmentsText = message.getAttachmentsText() + val displayedText = message.getTranslatedText() + val previewText = displayedText.trim().let { + if (currentUserMention != null) { + // bold mentions of the current user + it.bold(currentUserMention.singletonList(), ignoreCase = true) + } else { + it + } + } + + return listOf(previewText, attachmentsText) + .filterNot { it.isNullOrEmpty() } + .joinTo(SpannableStringBuilder(), " ") + } + } + + /** + * ViewHolder for the loading more item. + */ + inner class LoadingMoreViewHolder( + binding: StreamUiItemThreadListLoadingMoreBinding, + ) : RecyclerView.ViewHolder(binding.root) + + /** + * [DiffUtil.ItemCallback] for calculating differences between [ThreadListItem]s. + */ + private object ThreadListItemDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { + return oldItem.stableId == newItem.stableId + } + + override fun areContentsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { + return if (oldItem is ThreadListItem.ThreadItem && newItem is ThreadListItem.ThreadItem) { + oldItem.thread == newItem.thread // [Thread] is a data class, equality check is enough + } else { + false + } + } + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt new file mode 100644 index 00000000000..d5b90ef539e --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.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.ui.feature.threads.list.internal + +import io.getstream.chat.android.models.Thread + +/** + * Class representing the different types of items that can be rendered in the + * by the [ThreadListAdapter]. + */ +public sealed interface ThreadListItem { + + /** + * The item stable ID. + */ + public val stableId: Long + + /** + * Represents a thread item. + */ + public data class ThreadItem(val thread: Thread) : ThreadListItem { + override val stableId: Long + get() = thread.parentMessage.identifierHash() + } + + /** + * Represents a loading more item. + */ + public data object LoadingMoreItem : ThreadListItem { + override val stableId: Long + get() = LOADING_MORE_ITEM_STABLE_ID + } + + private companion object { + private const val LOADING_MORE_ITEM_STABLE_ID = 1L + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/helper/TransformStyle.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/helper/TransformStyle.kt index cf384d10607..f6f3153b784 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/helper/TransformStyle.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/helper/TransformStyle.kt @@ -44,6 +44,7 @@ import io.getstream.chat.android.ui.feature.messages.list.reactions.view.ViewRea import io.getstream.chat.android.ui.feature.pinned.list.PinnedMessageListViewStyle import io.getstream.chat.android.ui.feature.search.SearchInputViewStyle import io.getstream.chat.android.ui.feature.search.list.SearchResultListViewStyle +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle import io.getstream.chat.android.ui.widgets.avatar.AvatarStyle import io.getstream.chat.android.ui.widgets.typing.TypingIndicatorViewStyle @@ -142,6 +143,9 @@ public object TransformStyle { @JvmStatic public var audioRecordPlayerViewStyle: StyleTransformer = noopTransformer() + @JvmStatic + public var threadListViewStyle: StyleTransformer = noopTransformer() + private fun noopTransformer() = StyleTransformer { it } } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt new file mode 100644 index 00000000000..957d326ccb8 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt @@ -0,0 +1,39 @@ +/* + * 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.viewmodel.threads + +import androidx.lifecycle.LifecycleOwner +import io.getstream.chat.android.ui.feature.threads.list.ThreadListView + +/** + * Binds [ThreadListView] to a [ThreadListViewModel], updating the view's state based on + * data provided by the ViewModel and propagating view events to the ViewModel as needed. + * + * This function sets listeners on the view and ViewModel. Call this method + * before setting any additional listeners on these objects yourself. + */ +public fun ThreadListViewModel.bindView(view: ThreadListView, lifecycleOwner: LifecycleOwner) { + state.observe(lifecycleOwner) { state -> + when { + state.threads.isEmpty() && state.isLoading -> view.showLoading() + else -> view.showThreads(state.threads, state.isLoadingMore) + } + view.showUnreadThreadsBanner(state.unseenThreadsCount) + } + view.setUnreadThreadsBannerClickListener(::load) + view.setLoadMoreListener(::loadNextPage) +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt new file mode 100644 index 00000000000..345ea0a0d1c --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt @@ -0,0 +1,54 @@ +/* + * 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.viewmodel.threads + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import io.getstream.chat.android.ui.common.feature.threads.ThreadListController +import io.getstream.chat.android.ui.common.state.threads.ThreadListState + +/** + * 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: LiveData = controller.state.asLiveData() + + /** + * 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-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory.kt new file mode 100644 index 00000000000..8b0f5cc8e15 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/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.ui.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-ui-components/src/main/res/drawable/stream_ui_ic_thread.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_thread.xml new file mode 100644 index 00000000000..2a2936a530c --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_thread.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_threads_empty.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_threads_empty.xml new file mode 100644 index 00000000000..602bdbf230e --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_threads_empty.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_union.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_union.xml new file mode 100644 index 00000000000..0826e2b4597 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_union.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_shape_unread_threads_banner.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_shape_unread_threads_banner.xml new file mode 100644 index 00000000000..5973a46f13f --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_shape_unread_threads_banner.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list.xml b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list.xml new file mode 100644 index 00000000000..8568f280ead --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list_loading_more.xml b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list_loading_more.xml new file mode 100644 index 00000000000..ed811e5a58a --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_item_thread_list_loading_more.xml @@ -0,0 +1,39 @@ + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/layout/stream_ui_thread_list_view.xml b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_thread_list_view.xml new file mode 100644 index 00000000000..5cf301eccea --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_thread_list_view.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/values/attrs.xml b/stream-chat-android-ui-components/src/main/res/values/attrs.xml index 460493cba7e..84b41285881 100644 --- a/stream-chat-android-ui-components/src/main/res/values/attrs.xml +++ b/stream-chat-android-ui-components/src/main/res/values/attrs.xml @@ -57,5 +57,6 @@ + diff --git a/stream-chat-android-ui-components/src/main/res/values/attrs_thread_list_view.xml b/stream-chat-android-ui-components/src/main/res/values/attrs_thread_list_view.xml new file mode 100644 index 00000000000..e3a2256ab2c --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/values/attrs_thread_list_view.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/values/strings_thread_list.xml b/stream-chat-android-ui-components/src/main/res/values/strings_thread_list.xml new file mode 100644 index 00000000000..3976600e83a --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/values/strings_thread_list.xml @@ -0,0 +1,24 @@ + + + + No threads here yet… + replied to:  + + %d new thread + %d new threads + + \ No newline at end of file diff --git a/stream-chat-android-ui-components/src/main/res/values/styles.xml b/stream-chat-android-ui-components/src/main/res/values/styles.xml index 56cb341e76b..720f01271eb 100644 --- a/stream-chat-android-ui-components/src/main/res/values/styles.xml +++ b/stream-chat-android-ui-components/src/main/res/values/styles.xml @@ -224,40 +224,80 @@ @@ -273,7 +313,9 @@ true topRight 0dp - ?attr/streamUiGlobalAvatarOnlineIndicatorColor + + ?attr/streamUiGlobalAvatarOnlineIndicatorColor + @color/stream_ui_white circle @@ -290,7 +332,9 @@ 6dp @drawable/stream_ui_ic_down 3dp - @dimen/stream_ui_scroll_button_unread_badge_text_size + + @dimen/stream_ui_scroll_button_unread_badge_text_size + @color/stream_ui_literal_white normal @@ -317,7 +361,8 @@ @color/stream_ui_text_color_primary normal @dimen/stream_ui_text_small - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + normal @dimen/stream_ui_text_small @color/stream_ui_text_color_secondary @@ -355,17 +400,20 @@ @dimen/stream_ui_text_small - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + normal @dimen/stream_ui_text_small - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + normal @color/stream_ui_bars_background - @color/stream_ui_grey_gainsboro + @color/stream_ui_grey_gainsboro + @dimen/stream_ui_text_medium @@ -381,7 +429,8 @@ @color/stream_ui_accent_blue @color/stream_ui_accent_blue @color/stream_ui_blue_alice - @color/stream_ui_blue_alice + @color/stream_ui_blue_alice + @color/stream_ui_literal_transparent @@ -398,8 +447,10 @@ @color/stream_ui_grey_whisper - @color/stream_ui_grey_gainsboro - @color/stream_ui_grey_whisper + @color/stream_ui_grey_gainsboro + + @color/stream_ui_grey_whisper + @color/stream_ui_white @@ -416,7 +467,8 @@ @color/stream_ui_grey_whisper @dimen/stream_ui_text_medium - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + italic @@ -490,7 +542,8 @@ normal @dimen/stream_ui_text_medium - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + normal @dimen/stream_ui_text_medium @@ -506,14 +559,17 @@ bottom - @color/stream_ui_text_color_secondary + @color/stream_ui_text_color_secondary + @dimen/stream_ui_text_small normal @drawable/stream_ui_ic_pin @color/stream_ui_highlight - @dimen/stream_ui_message_viewholder_avatar_missing_margin + + @dimen/stream_ui_message_viewholder_avatar_missing_margin + 0dp @@ -521,14 +577,24 @@ 75% - @style/StreamUi.AudioRecordPlayerView - @style/StreamUi.AudioRecordPlayerView + + @style/StreamUi.AudioRecordPlayerView + + + @style/StreamUi.AudioRecordPlayerView + @@ -570,20 +640,29 @@ @@ -607,7 +687,9 @@ @dimen/stream_ui_text_medium - @color/stream_ui_text_color_primary + + @color/stream_ui_text_color_primary + normal @@ -640,8 +722,11 @@ @color/stream_ui_accent_blue true @drawable/stream_ui_ic_pen - @color/stream_ui_icon_button_background_selector - @drawable/stream_ui_divider + @color/stream_ui_icon_button_background_selector + + + @drawable/stream_ui_divider + @@ -677,11 +763,18 @@ @color/stream_ui_white_smoke - @dimen/stream_ui_channel_list_item_margin_start + @dimen/stream_ui_channel_list_item_margin_start + @dimen/stream_ui_channel_list_item_margin_end - @dimen/stream_ui_channel_list_item_title_margin_start - @dimen/stream_ui_channel_list_item_vertical_spacer_height - @dimen/stream_ui_channel_list_item_vertical_spacer_position + + @dimen/stream_ui_channel_list_item_title_margin_start + + + @dimen/stream_ui_channel_list_item_vertical_spacer_height + + + @dimen/stream_ui_channel_list_item_vertical_spacer_position + @dimen/stream_ui_channel_item_title @color/stream_ui_text_color_primary bold @@ -690,7 +783,8 @@ @color/stream_ui_text_color_secondary normal - @dimen/stream_ui_channel_item_message_date + @dimen/stream_ui_channel_item_message_date + @color/stream_ui_text_color_secondary normal @@ -788,12 +882,16 @@ @@ -916,29 +1087,41 @@ @@ -1063,83 +1263,177 @@ + + From 39e48ff66e1ee8344a0f9b1839793da72d7b488b Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Tue, 26 Nov 2024 12:43:32 +0100 Subject: [PATCH 09/22] Remove thread list dividers. --- .../ui/feature/threads/list/ThreadListView.kt | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt index 3803f93cb93..4134e003bfa 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt @@ -18,13 +18,10 @@ package io.getstream.chat.android.ui.feature.threads.list import android.content.Context import android.util.AttributeSet -import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager import io.getstream.chat.android.models.Thread import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.databinding.StreamUiThreadListViewBinding @@ -72,15 +69,6 @@ public class ThreadListView : ConstraintLayout { binding.threadListRecyclerView.adapter = adapter binding.threadListRecyclerView.addOnScrollListener(scrollListener) - binding.threadListRecyclerView.addItemDecoration( - DividerItemDecoration( - context, - LinearLayoutManager.VERTICAL, - ).apply { - setDrawable(AppCompatResources.getDrawable(context, R.drawable.stream_ui_divider)!!) - }, - ) - setBackgroundColor(style.backgroundColor) applyEmptyStateStyle(style) applyBannerStyle(style) From a1b0d0b4f40abea00f619b8f9253149596126ee6 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Tue, 26 Nov 2024 17:31:21 +0100 Subject: [PATCH 10/22] [AND-3] Add Threads tab to sample app. --- .../channel/list/ChannelListFragment.kt | 2 +- .../ui/sample/feature/home/HomeFragment.kt | 5 ++ .../ui/sample/feature/home/HomeViewModel.kt | 5 ++ .../feature/mentions/MentionsFragment.kt | 2 +- .../sample/feature/threads/ThreadsFragment.kt | 78 +++++++++++++++++++ .../src/main/res/drawable/ic_threads.xml | 27 +++++++ .../src/main/res/layout/fragment_threads.xml | 33 ++++++++ .../main/res/menu/menu_bottom_navigation.xml | 5 ++ .../main/res/navigation/home_nav_graph.xml | 8 ++ .../src/main/res/values/strings.xml | 1 + 10 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt create mode 100644 stream-chat-android-ui-components-sample/src/main/res/drawable/ic_threads.xml create mode 100644 stream-chat-android-ui-components-sample/src/main/res/layout/fragment_threads.xml diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt index 4dfdcf00cb3..7214e55edae 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt @@ -147,7 +147,7 @@ class ChannelListFragment : Fragment() { binding.searchResultListView.setSearchResultSelectedListener { message -> requireActivity().findNavController(R.id.hostFragmentContainer) - .navigateSafely(HomeFragmentDirections.actionOpenChat(message.cid, message.id)) + .navigateSafely(HomeFragmentDirections.actionOpenChat(message.cid, message.id, message.parentId)) } } diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeFragment.kt index 420218d8874..fd085325cd6 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeFragment.kt @@ -186,6 +186,10 @@ class HomeFragment : Fragment() { backgroundColor = ContextCompat.getColor(requireContext(), R.color.stream_ui_accent_red) badgeTextColor = ContextCompat.getColor(requireContext(), R.color.stream_ui_literal_white) } + getOrCreateBadge(R.id.threads_fragment).apply { + backgroundColor = ContextCompat.getColor(requireContext(), R.color.stream_ui_accent_red) + badgeTextColor = ContextCompat.getColor(requireContext(), R.color.stream_ui_literal_white) + } } } @@ -229,6 +233,7 @@ class HomeFragment : Fragment() { binding.bottomNavigationView.apply { setBadgeNumber(R.id.channels_fragment, state.totalUnreadCount) setBadgeNumber(R.id.mentions_fragment, state.mentionsUnreadCount) + setBadgeNumber(R.id.threads_fragment, state.unreadThreadsCount) } nameTextView.text = state.user.name diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt index 3f8bb061a62..d3bc2fabdde 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt @@ -78,6 +78,9 @@ class HomeViewModel( _state.addSource(globalState.totalUnreadCount.asLiveData()) { count -> setState { copy(totalUnreadCount = count) } } + _state.addSource(globalState.unreadThreadsCount.asLiveData()) { count -> + setState { copy(unreadThreadsCount = count) } + } _state.addSource(clientState.user.asLiveData()) { user -> setState { copy(user = user ?: User()) } } @@ -119,11 +122,13 @@ class HomeViewModel( * @param user The currently logged in user. * @param totalUnreadCount The total unread messages count for the current user. * @param mentionsUnreadCount The number of unread mentions by the current user. + * @param unreadThreadsCount The number of unread threads by the current user. */ data class UiState( val user: User = User(), val totalUnreadCount: Int = 0, val mentionsUnreadCount: Int = 0, + val unreadThreadsCount: Int = 0, ) /** diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/mentions/MentionsFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/mentions/MentionsFragment.kt index 24367ffe159..abe6d195a3d 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/mentions/MentionsFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/mentions/MentionsFragment.kt @@ -56,7 +56,7 @@ class MentionsFragment : Fragment() { viewModel.bindView(binding.mentionsListView, viewLifecycleOwner) binding.mentionsListView.setMentionSelectedListener { message -> requireActivity().findNavController(R.id.hostFragmentContainer) - .navigateSafely(HomeFragmentDirections.actionOpenChat(message.cid, message.id)) + .navigateSafely(HomeFragmentDirections.actionOpenChat(message.cid, message.id, message.parentId)) } setupOnClickListeners() } diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt new file mode 100644 index 00000000000..eadbfdf0938 --- /dev/null +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt @@ -0,0 +1,78 @@ +/* + * 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.ui.sample.feature.threads + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.findNavController +import io.getstream.chat.android.ui.viewmodel.threads.ThreadListViewModel +import io.getstream.chat.android.ui.viewmodel.threads.ThreadsViewModelFactory +import io.getstream.chat.android.ui.viewmodel.threads.bindView +import io.getstream.chat.ui.sample.R +import io.getstream.chat.ui.sample.common.navigateSafely +import io.getstream.chat.ui.sample.databinding.FragmentThreadsBinding +import io.getstream.chat.ui.sample.feature.home.HomeFragmentDirections + +/** + * Fragment displaying the list of threads for the currently logged in user. + */ +class ThreadsFragment : Fragment() { + + private var _binding: FragmentThreadsBinding? = null + private val binding: FragmentThreadsBinding + get() = _binding!! + + private val viewModel: ThreadListViewModel by viewModels { ThreadsViewModelFactory() } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + _binding = FragmentThreadsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.bindView(binding.threadListView, viewLifecycleOwner) + binding.threadListView.setThreadClickListener { thread -> + requireActivity().findNavController(R.id.hostFragmentContainer) + .navigateSafely( + HomeFragmentDirections.actionOpenChat( + cid = thread.parentMessage.cid, + parentMessageId = thread.parentMessageId, + ), + ) + } + setupBackHandler() + } + + private fun setupBackHandler() { + activity?.apply { + onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + finish() + } + } + } +} diff --git a/stream-chat-android-ui-components-sample/src/main/res/drawable/ic_threads.xml b/stream-chat-android-ui-components-sample/src/main/res/drawable/ic_threads.xml new file mode 100644 index 00000000000..0a6c48ebee4 --- /dev/null +++ b/stream-chat-android-ui-components-sample/src/main/res/drawable/ic_threads.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_threads.xml b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_threads.xml new file mode 100644 index 00000000000..a3b35a8c4b9 --- /dev/null +++ b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_threads.xml @@ -0,0 +1,33 @@ + + + + + + + \ No newline at end of file diff --git a/stream-chat-android-ui-components-sample/src/main/res/menu/menu_bottom_navigation.xml b/stream-chat-android-ui-components-sample/src/main/res/menu/menu_bottom_navigation.xml index da419a96a13..24cedaf6352 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/menu/menu_bottom_navigation.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/menu/menu_bottom_navigation.xml @@ -25,4 +25,9 @@ android:icon="@drawable/ic_mentions" android:title="@string/home_bottom_nav_mentions" /> + diff --git a/stream-chat-android-ui-components-sample/src/main/res/navigation/home_nav_graph.xml b/stream-chat-android-ui-components-sample/src/main/res/navigation/home_nav_graph.xml index 77a9c75665d..d94a877f12b 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/navigation/home_nav_graph.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/navigation/home_nav_graph.xml @@ -35,4 +35,12 @@ android:label="MentionsFragment" tools:layout="@layout/fragment_mentions" /> + + + diff --git a/stream-chat-android-ui-components-sample/src/main/res/values/strings.xml b/stream-chat-android-ui-components-sample/src/main/res/values/strings.xml index c944804eda2..b064b677711 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ Sign Out Chats Mentions + Threads Let\'s start chatting! From 7425183c27f059e7c826e398d28e6585742c1525 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Wed, 27 Nov 2024 14:06:03 +0100 Subject: [PATCH 11/22] Add ThreadListView to CHANGELOG.md. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe8bf81ead..97dedfefc1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ ### ⬆️ Improved ### ✅ Added +- Add `ThreadListView` component for showing the list of threads for the user. [#5491](https://github.com/GetStream/stream-chat-android/pull/5491) ### ⚠️ Changed From 058dc3d56575d997ac836d0765ed9531fd97d1a9 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Wed, 27 Nov 2024 14:22:01 +0100 Subject: [PATCH 12/22] Fix sample app thread navigation. --- .../getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt index eadbfdf0938..96c0f2618b7 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt @@ -61,6 +61,7 @@ class ThreadsFragment : Fragment() { .navigateSafely( HomeFragmentDirections.actionOpenChat( cid = thread.parentMessage.cid, + messageId = thread.parentMessageId, parentMessageId = thread.parentMessageId, ), ) From 6165cdad43042cceb2a35d72cc7aa23b44f09b27 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Thu, 28 Nov 2024 13:52:02 +0100 Subject: [PATCH 13/22] [AND-3] Create ThreadListItemViewHolderFactory for ViewHolder customization. --- .../sample/feature/threads/ThreadsFragment.kt | 1 - .../api/stream-chat-android-ui-components.api | 35 ++- .../ui/feature/threads/list/ThreadListView.kt | 55 +++- .../{internal => adapter}/ThreadListItem.kt | 4 +- .../ThreadListItemViewHolderFactory.kt | 128 ++++++++++ .../list/adapter/ThreadListItemViewType.kt | 33 +++ .../adapter/internal/ThreadListAdapter.kt | 88 +++++++ .../BaseThreadListItemViewHolder.kt | 53 ++++ .../internal/ThreadItemViewHolder.kt | 158 ++++++++++++ .../ThreadListLoadingMoreViewHolder.kt | 35 +++ .../list/internal/ThreadListAdapter.kt | 240 ------------------ 11 files changed, 572 insertions(+), 258 deletions(-) rename stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/{internal => adapter}/ThreadListItem.kt (89%) create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/internal/ThreadListAdapter.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt create mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadListLoadingMoreViewHolder.kt delete mode 100644 stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt index 96c0f2618b7..eadbfdf0938 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/threads/ThreadsFragment.kt @@ -61,7 +61,6 @@ class ThreadsFragment : Fragment() { .navigateSafely( HomeFragmentDirections.actionOpenChat( cid = thread.parentMessage.cid, - messageId = thread.parentMessageId, parentMessageId = thread.parentMessageId, ), ) diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 70ac6c31e1c..33905e9d0c8 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -3777,6 +3777,7 @@ public final class io/getstream/chat/android/ui/feature/threads/list/ThreadListV public final fun setLoadMoreListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$LoadMoreListener;)V public final fun setThreadClickListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$ThreadClickListener;)V public final fun setUnreadThreadsBannerClickListener (Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$UnreadThreadsBannerClickListener;)V + public final fun setViewHolderFactory (Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory;)V public final fun showLoading ()V public final fun showThreads (Ljava/util/List;Z)V public final fun showUnreadThreadsBanner (I)V @@ -3845,23 +3846,23 @@ public final class io/getstream/chat/android/ui/feature/threads/list/ThreadListV public fun toString ()Ljava/lang/String; } -public abstract interface class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { +public abstract interface class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem { public abstract fun getStableId ()J } -public final class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$LoadingMoreItem : io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { - public static final field INSTANCE Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$LoadingMoreItem; +public final class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$LoadingMoreItem : io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem { + public static final field INSTANCE Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$LoadingMoreItem; public fun equals (Ljava/lang/Object;)Z public fun getStableId ()J public fun hashCode ()I public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem : io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem { +public final class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$ThreadItem : io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem { public fun (Lio/getstream/chat/android/models/Thread;)V public final fun component1 ()Lio/getstream/chat/android/models/Thread; - public final fun copy (Lio/getstream/chat/android/models/Thread;)Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem;Lio/getstream/chat/android/models/Thread;ILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem$ThreadItem; + public final fun copy (Lio/getstream/chat/android/models/Thread;)Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$ThreadItem; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$ThreadItem;Lio/getstream/chat/android/models/Thread;ILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem$ThreadItem; public fun equals (Ljava/lang/Object;)Z public fun getStableId ()J public final fun getThread ()Lio/getstream/chat/android/models/Thread; @@ -3869,6 +3870,28 @@ public final class io/getstream/chat/android/ui/feature/threads/list/internal/Th public fun toString ()Ljava/lang/String; } +public class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory { + public fun ()V + protected fun createLoadingMoreViewHolder (Landroid/view/ViewGroup;)Lio/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder; + protected fun createThreadItemViewHolder (Landroid/view/ViewGroup;)Lio/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder; + public fun createViewHolder (Landroid/view/ViewGroup;I)Lio/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder; + protected final fun getClickListener ()Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView$ThreadClickListener; + public fun getItemViewType (Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem;)I + public fun getItemViewType (Lio/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder;)I + protected final fun getStyle ()Lio/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle; +} + +public final class io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType { + public static final field INSTANCE Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType; + public static final field ITEM_LOADING_MORE I + public static final field ITEM_THREAD I +} + +public abstract class io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder : androidx/recyclerview/widget/RecyclerView$ViewHolder { + public fun (Landroid/view/View;)V + public abstract fun bind (Lio/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem;)V +} + public abstract interface class io/getstream/chat/android/ui/font/ChatFonts { public abstract fun getFont (Lio/getstream/chat/android/ui/font/TextStyle;)Landroid/graphics/Typeface; public abstract fun setFont (Lio/getstream/chat/android/ui/font/TextStyle;Landroid/widget/TextView;)V diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt index 4134e003bfa..2583df6470f 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListView.kt @@ -25,8 +25,9 @@ import androidx.core.view.updatePadding import io.getstream.chat.android.models.Thread import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.databinding.StreamUiThreadListViewBinding -import io.getstream.chat.android.ui.feature.threads.list.internal.ThreadListAdapter -import io.getstream.chat.android.ui.feature.threads.list.internal.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItemViewHolderFactory +import io.getstream.chat.android.ui.feature.threads.list.adapter.internal.ThreadListAdapter import io.getstream.chat.android.ui.font.setTextStyle import io.getstream.chat.android.ui.utils.extensions.createStreamThemeWrapper import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater @@ -40,7 +41,9 @@ public class ThreadListView : ConstraintLayout { private val binding = StreamUiThreadListViewBinding.inflate(streamThemeInflater, this) private lateinit var style: ThreadListViewStyle + private lateinit var viewHolderFactory: ThreadListItemViewHolderFactory private lateinit var adapter: ThreadListAdapter + private var clickListener: ThreadClickListener? = null private val scrollListener = EndlessScrollListener(LOAD_MORE_THRESHOLD) { loadMoreListener?.onLoadMore() } @@ -63,10 +66,7 @@ public class ThreadListView : ConstraintLayout { private fun init(attrs: AttributeSet?) { style = ThreadListViewStyle(context, attrs) - adapter = ThreadListAdapter(style) - binding.threadListRecyclerView.setHasFixedSize(true) - binding.threadListRecyclerView.adapter = adapter binding.threadListRecyclerView.addOnScrollListener(scrollListener) setBackgroundColor(style.backgroundColor) @@ -81,7 +81,7 @@ public class ThreadListView : ConstraintLayout { * @param isLoadingMore Indicator if the loading more view should be shown. */ public fun showThreads(threads: List, isLoadingMore: Boolean) { - val isCurrentlyEmpty = adapter.itemCount == 0 + val isCurrentlyEmpty = requireAdapter().itemCount == 0 val hasThreads = threads.isNotEmpty() binding.threadListRecyclerView.isVisible = hasThreads @@ -90,7 +90,7 @@ public class ThreadListView : ConstraintLayout { val threadItems = threads.map(ThreadListItem::ThreadItem) val loadingMoreItems = if (isLoadingMore) listOf(ThreadListItem.LoadingMoreItem) else emptyList() - adapter.submitList(threadItems + loadingMoreItems) + requireAdapter().submitList(threadItems + loadingMoreItems) scrollListener.enablePagination() @@ -104,7 +104,7 @@ public class ThreadListView : ConstraintLayout { * Shows the loading state of the thread list. */ public fun showLoading() { - adapter.submitList(emptyList()) // clear current list + requireAdapter().submitList(emptyList()) // clear current list binding.threadListRecyclerView.isVisible = false binding.emptyContainer.isVisible = false binding.progressBar.isVisible = true @@ -127,6 +127,21 @@ public class ThreadListView : ConstraintLayout { binding.unreadThreadsBannerTextView.text = bannerText } + /** + * Sets the [ThreadListItemViewHolderFactory] used to create the thread list view holders. + * Use if you want completely custom views for the thread list items. + * Make sure to call this before setting/updating the data in the thread list view. + * + * @param factory The [ThreadListItemViewHolderFactory] to be used for creating the item view holders. + * @throws IllegalStateException if called when a [factory] was already set. + */ + public fun setViewHolderFactory(factory: ThreadListItemViewHolderFactory) { + check(::adapter.isInitialized.not()) { + "Adapter was already initialized, please set ChannelListItemViewHolderFactory first" + } + viewHolderFactory = factory + } + /** * Sets the listener for clicks on the unread threads banner. * @@ -145,7 +160,7 @@ public class ThreadListView : ConstraintLayout { * @param listener The [ThreadClickListener] to be invoked when the user clicks on a thread. */ public fun setThreadClickListener(listener: ThreadClickListener) { - adapter.setThreadClickListener(listener) + this.clickListener = listener } /** @@ -157,6 +172,28 @@ public class ThreadListView : ConstraintLayout { this.loadMoreListener = listener } + /** + * Ensures the [adapter] is initialized before accessing it. + * Useful for cases where a custom [viewHolderFactory] is provided. + */ + private fun requireAdapter(): ThreadListAdapter { + if (::adapter.isInitialized.not()) { + initAdapter() + } + return adapter + } + + private fun initAdapter() { + // Ensure the viewHolderFactory is initialized + if (::viewHolderFactory.isInitialized.not()) { + viewHolderFactory = ThreadListItemViewHolderFactory() + } + viewHolderFactory.setStyle(style) + viewHolderFactory.setThreadClickListener(clickListener) + adapter = ThreadListAdapter(style, viewHolderFactory) + binding.threadListRecyclerView.adapter = adapter + } + private fun applyEmptyStateStyle(style: ThreadListViewStyle) { binding.emptyImage.setImageDrawable(style.emptyStateDrawable) binding.emptyTextView.text = style.emptyStateText diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem.kt similarity index 89% rename from stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt rename to stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem.kt index d5b90ef539e..080930f1428 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListItem.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItem.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package io.getstream.chat.android.ui.feature.threads.list.internal +package io.getstream.chat.android.ui.feature.threads.list.adapter import io.getstream.chat.android.models.Thread /** * Class representing the different types of items that can be rendered in the - * by the [ThreadListAdapter]. + * by the [io.getstream.chat.android.ui.feature.threads.list.adapter.internal.ThreadListAdapter]. */ public sealed interface ThreadListItem { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory.kt new file mode 100644 index 00000000000..5d2a74b94a2 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewHolderFactory.kt @@ -0,0 +1,128 @@ +/* + * 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.feature.threads.list.adapter + +import android.view.ViewGroup +import io.getstream.chat.android.ui.feature.threads.list.ThreadListView +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.BaseThreadListItemViewHolder +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.internal.ThreadItemViewHolder +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.internal.ThreadListLoadingMoreViewHolder + +/** + * Factory responsible for creating ViewHolder instances for the RecyclerView used in the + * [io.getstream.chat.android.ui.feature.threads.list.ThreadListView]. + */ +public open class ThreadListItemViewHolderFactory { + + /** + * The [ThreadListViewStyle] for styling the viewHolders. + */ + protected lateinit var style: ThreadListViewStyle + private set + + /** + * The listener for clicks on the thread items. + */ + protected var clickListener: ThreadListView.ThreadClickListener? = null + private set + + /** + * Returns a view type value based on the type of the given [item]. + * The view type returned here will be used as a parameter in [createViewHolder]. + * + * For built-in view types, see [ThreadListItemViewType] and its constants. + * + * @param item The [ThreadListItem] to check the type of. + */ + public open fun getItemViewType(item: ThreadListItem): Int { + return when (item) { + is ThreadListItem.ThreadItem -> ThreadListItemViewType.ITEM_THREAD + is ThreadListItem.LoadingMoreItem -> ThreadListItemViewType.ITEM_LOADING_MORE + } + } + + /** + * Returns a view type value based on the type of the given [viewHolder]. + * + * For built-in view types, see [ThreadListItemViewType] and its constants. + * + * @param viewHolder The [BaseThreadListItemViewHolder] to check the type of. + */ + public open fun getItemViewType(viewHolder: BaseThreadListItemViewHolder): Int { + return when (viewHolder) { + is ThreadItemViewHolder -> ThreadListItemViewType.ITEM_THREAD + is ThreadListLoadingMoreViewHolder -> ThreadListItemViewType.ITEM_LOADING_MORE + else -> throw IllegalArgumentException("Unhandled ThreadList view holder: $viewHolder") + } + } + + /** + * Creates a new ViewHolder based on the provided [viewType] to be used in the Thread List. + * The [viewType] parameter is determined by [getItemViewType]. + * + * @param parentView The parent of the view. + * @param viewType The type of the item for which the viewHolder is created. + */ + public open fun createViewHolder( + parentView: ViewGroup, + viewType: Int, + ): BaseThreadListItemViewHolder { + return when (viewType) { + ThreadListItemViewType.ITEM_THREAD -> createThreadItemViewHolder(parentView) + ThreadListItemViewType.ITEM_LOADING_MORE -> createLoadingMoreViewHolder(parentView) + else -> throw IllegalArgumentException("Unhandled ThreadList view type: $viewType") + } + } + + /** + * Creates the ViewHolder for the [ThreadListItemViewType.ITEM_THREAD] ([ThreadListItem.ThreadItem]) type. + * + * @param parentView The parent of the view. + */ + protected open fun createThreadItemViewHolder( + parentView: ViewGroup, + ): BaseThreadListItemViewHolder { + return ThreadItemViewHolder(parentView, style, clickListener) + } + + /** + * Creates the ViewHolder for the [ThreadListItemViewType.ITEM_LOADING_MORE] ([ThreadListItem.LoadingMoreItem]) + * type. + * + * @param parentView The parent of the view. + */ + protected open fun createLoadingMoreViewHolder( + parentView: ViewGroup, + ): BaseThreadListItemViewHolder { + return ThreadListLoadingMoreViewHolder(parentView) + } + + /** + * Sets the [ThreadListViewStyle] to be used by the created ViewHolders. + */ + internal fun setStyle(style: ThreadListViewStyle) { + this.style = style + } + + /** + * Sets the [ThreadListView.ThreadClickListener] for clicks on the thread items. + */ + internal fun setThreadClickListener(clickListener: ThreadListView.ThreadClickListener?) { + this.clickListener = clickListener + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType.kt new file mode 100644 index 00000000000..617360ec5fd --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/ThreadListItemViewType.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.ui.feature.threads.list.adapter + +/** + * Defines the possible types of a Thread List item. + */ +public object ThreadListItemViewType { + + /** + * Represents an item of type 'thread'. + */ + public const val ITEM_THREAD: Int = 0 + + /** + * Represent a loading more indicator item. + */ + public const val ITEM_LOADING_MORE: Int = 1 +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/internal/ThreadListAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/internal/ThreadListAdapter.kt new file mode 100644 index 00000000000..016bf4924d8 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/internal/ThreadListAdapter.kt @@ -0,0 +1,88 @@ +/* + * 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.feature.threads.list.adapter.internal + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItemViewHolderFactory +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.BaseThreadListItemViewHolder +import io.getstream.log.taggedLogger + +/** + * RecyclerView adapter implementation for displaying a list of threads. + * + * @param style The [ThreadListViewStyle] for item customization. + * @param viewHolderFactory The factory for creating view holders. + */ +internal class ThreadListAdapter( + private val style: ThreadListViewStyle, + private val viewHolderFactory: ThreadListItemViewHolderFactory, +) : ListAdapter>(ThreadListItemDiffCallback) { + + private val logger by taggedLogger("Chat:ThreadListAdapter") + + init { + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long { + return getItem(position).stableId + } + + override fun getItemViewType(position: Int): Int { + return viewHolderFactory.getItemViewType(getItem(position)) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): BaseThreadListItemViewHolder { + return viewHolderFactory.createViewHolder(parent, viewType) + } + + override fun onBindViewHolder(holder: BaseThreadListItemViewHolder, position: Int) { + val item = getItem(position) + val itemViewType = viewHolderFactory.getItemViewType(item) + val holderViewType = viewHolderFactory.getItemViewType(holder) + if (itemViewType != holderViewType) { + // Should never happen + logger.d { "Item view type $itemViewType does not match the holder view type $holderViewType" } + return + } + holder.bindInternal(item) + } + + /** + * [DiffUtil.ItemCallback] for calculating differences between [ThreadListItem]s. + */ + private object ThreadListItemDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { + return oldItem.stableId == newItem.stableId + } + + override fun areContentsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { + return if (oldItem is ThreadListItem.ThreadItem && newItem is ThreadListItem.ThreadItem) { + oldItem.thread == newItem.thread // [Thread] is a data class, equality check is enough + } else { + false + } + } + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder.kt new file mode 100644 index 00000000000..ba94914f13f --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/BaseThreadListItemViewHolder.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.ui.feature.threads.list.adapter.viewholder + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.log.taggedLogger + +/** + * Base ViewHolder used for displaying items by the + * [io.getstream.chat.android.ui.feature.threads.list.adapter.internal.ThreadListAdapter]. + */ +public abstract class BaseThreadListItemViewHolder(itemView: View) : + RecyclerView.ViewHolder(itemView) { + + private val logger by taggedLogger("Chat:BaseThreadListItemViewHolder") + + /** + * Binds the item to the ViewHolder. + * + * @param item The item to bind. + */ + public abstract fun bind(item: T) + + /** + * Workaround to allow a downcast of the [ThreadListItem] to [T]. + */ + @Suppress("UNCHECKED_CAST") + internal fun bindInternal(item: ThreadListItem) { + val actual = item as? T + if (actual == null) { + // Should never happen + logger.d { "[bindInternal] Failed to cast $item to the expected type." } + return + } + bind(actual) + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt new file mode 100644 index 00000000000..853f3b740bc --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt @@ -0,0 +1,158 @@ +/* + * 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.feature.threads.list.adapter.viewholder.internal + +import android.text.SpannableStringBuilder +import android.view.ViewGroup +import androidx.core.view.isVisible +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.User +import io.getstream.chat.android.ui.ChatUI +import io.getstream.chat.android.ui.R +import io.getstream.chat.android.ui.common.extensions.internal.context +import io.getstream.chat.android.ui.common.extensions.internal.singletonList +import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListBinding +import io.getstream.chat.android.ui.feature.threads.list.ThreadListView +import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.BaseThreadListItemViewHolder +import io.getstream.chat.android.ui.font.setTextStyle +import io.getstream.chat.android.ui.utils.extensions.asMention +import io.getstream.chat.android.ui.utils.extensions.bold +import io.getstream.chat.android.ui.utils.extensions.getAttachmentsText +import io.getstream.chat.android.ui.utils.extensions.getTranslatedText +import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater + +/** + * Default ViewHolder for thread items. + * + * @param parentView The parent view group. + * @param style The style object for customizing the thread item appearance. + * @param clickListener The listener for item clicks. + * @param binding The view binding for the thread item. + */ +internal class ThreadItemViewHolder( + parentView: ViewGroup, + style: ThreadListViewStyle, + private val clickListener: ThreadListView.ThreadClickListener?, + private val binding: StreamUiItemThreadListBinding = StreamUiItemThreadListBinding.inflate( + parentView.streamThemeInflater, + parentView, + false, + ), +) : BaseThreadListItemViewHolder(binding.root) { + + private lateinit var thread: Thread + + init { + applyStyle(style) + binding.root.setOnClickListener { + clickListener?.onThreadClick(thread) + } + } + + override fun bind(item: ThreadListItem.ThreadItem) { + this.thread = item.thread + val currentUser = ChatUI.currentUserProvider.getCurrentUser() + bindThreadTitle(currentUser) + bindReplyTo(currentUser) + bindUnreadCountBadge(currentUser) + bindLatestReply(currentUser) + } + + private fun applyStyle(style: ThreadListViewStyle) { + binding.threadImage.setImageDrawable(style.threadIconDrawable) + binding.threadTitleTextView.setTextStyle(style.threadTitleStyle) + binding.replyToTextView.setTextStyle(style.threadReplyToStyle) + // Remove ripple from latestReplyMessageView + binding.latestReplyMessageView.binding.root.background = null + binding.latestReplyMessageView.styleView(style.latestReplyStyle) + binding.unreadCountBadge.setTextStyle(style.unreadCountBadgeTextStyle) + binding.unreadCountBadge.background = style.unreadCountBadgeBackground + } + + private fun bindThreadTitle(currentUser: User?) { + val channel = thread.channel + val title = channel + ?.let { ChatUI.channelNameFormatter.formatChannelName(channel = it, currentUser = currentUser) } + ?: thread.title + binding.threadTitleTextView.text = title + } + + private fun bindReplyTo(currentUser: User?) { + val prefix = binding.root.context.getString(R.string.stream_ui_thread_list_replied_to) + val parentMessageText = formatMessage(thread.parentMessage, currentUser?.asMention(context)) + val replyToText = "$prefix$parentMessageText" + binding.replyToTextView.text = replyToText + } + + private fun bindUnreadCountBadge(currentUser: User?) { + val unreadCount = thread.read + .find { it.user.id == currentUser?.id } + ?.unreadMessages + ?: 0 + if (unreadCount > 0) { + binding.unreadCountBadge.text = + if (unreadCount > MAX_UNREAD_COUNT) "$MAX_UNREAD_COUNT+" else unreadCount.toString() + binding.unreadCountBadge.isVisible = true + } else { + binding.unreadCountBadge.isVisible = false + } + } + + private fun bindLatestReply(currentUser: User?) { + val latestReply = thread.latestReplies.lastOrNull() + if (latestReply != null) { + // User avatar + binding.latestReplyMessageView.binding.userAvatarView.setUser(latestReply.user) + // Sender name + binding.latestReplyMessageView.binding.senderNameLabel.text = latestReply.user.name.bold() + // Reply text + binding.latestReplyMessageView.binding.messageLabel.text = + formatMessage(latestReply, currentUser?.asMention(context)) + // Timestamp + binding.latestReplyMessageView.binding.messageTimeLabel.text = + ChatUI.dateFormatter.formatDate(latestReply.createdAt ?: latestReply.createdLocallyAt) + binding.latestReplyMessageView.isVisible = true + } else { + // Note: should never happen + binding.latestReplyMessageView.isVisible = false + } + } + + private fun formatMessage(message: Message, currentUserMention: String?): CharSequence { + val attachmentsText = message.getAttachmentsText() + val displayedText = message.getTranslatedText() + val previewText = displayedText.trim().let { + if (currentUserMention != null) { + // bold mentions of the current user + it.bold(currentUserMention.singletonList(), ignoreCase = true) + } else { + it + } + } + + return listOf(previewText, attachmentsText) + .filterNot { it.isNullOrEmpty() } + .joinTo(SpannableStringBuilder(), " ") + } + + private companion object { + private const val MAX_UNREAD_COUNT = 99 + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadListLoadingMoreViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadListLoadingMoreViewHolder.kt new file mode 100644 index 00000000000..b1492d0f09c --- /dev/null +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadListLoadingMoreViewHolder.kt @@ -0,0 +1,35 @@ +/* + * 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.feature.threads.list.adapter.viewholder.internal + +import android.view.ViewGroup +import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListLoadingMoreBinding +import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem +import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.BaseThreadListItemViewHolder +import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater + +/** + * ViewHolder for the thread list loading more indicator item. + */ +internal class ThreadListLoadingMoreViewHolder( + parentView: ViewGroup, +) : BaseThreadListItemViewHolder( + itemView = StreamUiItemThreadListLoadingMoreBinding.inflate(parentView.streamThemeInflater, parentView, false).root, +) { + + override fun bind(item: ThreadListItem.LoadingMoreItem) = Unit // No-op +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt deleted file mode 100644 index 708e2db596f..00000000000 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/internal/ThreadListAdapter.kt +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.chat.android.ui.feature.threads.list.internal - -import android.text.SpannableStringBuilder -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.Thread -import io.getstream.chat.android.models.User -import io.getstream.chat.android.ui.ChatUI -import io.getstream.chat.android.ui.R -import io.getstream.chat.android.ui.common.extensions.internal.context -import io.getstream.chat.android.ui.common.extensions.internal.singletonList -import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListBinding -import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListLoadingMoreBinding -import io.getstream.chat.android.ui.feature.threads.list.ThreadListView -import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle -import io.getstream.chat.android.ui.font.setTextStyle -import io.getstream.chat.android.ui.utils.extensions.asMention -import io.getstream.chat.android.ui.utils.extensions.bold -import io.getstream.chat.android.ui.utils.extensions.getAttachmentsText -import io.getstream.chat.android.ui.utils.extensions.getTranslatedText -import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater - -/** - * RecyclerView adapter implementation for displaying a list of threads. - * - * @param style The [ThreadListViewStyle] for item customization. - */ -internal class ThreadListAdapter(private val style: ThreadListViewStyle) : - ListAdapter(ThreadListItemDiffCallback) { - - private var clickListener: ThreadListView.ThreadClickListener? = null - - init { - setHasStableIds(true) - } - - override fun getItemId(position: Int): Long { - return getItem(position).stableId - } - - override fun getItemViewType(position: Int): Int { - val item = getItem(position) - return when (item) { - is ThreadListItem.ThreadItem -> ITEM_THREAD - is ThreadListItem.LoadingMoreItem -> ITEM_LOADING_MORE - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - ITEM_THREAD -> { - StreamUiItemThreadListBinding - .inflate(parent.streamThemeInflater, parent, false) - .let(::ThreadItemViewHolder) - .also { it.applyStyle(style) } - } - - else -> { - StreamUiItemThreadListLoadingMoreBinding - .inflate(parent.streamThemeInflater, parent, false) - .let(::LoadingMoreViewHolder) - } - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - val item = getItem(position) - if (item is ThreadListItem.ThreadItem && holder is ThreadItemViewHolder) { - holder.bind(item.thread) - } - } - - /** - * Sets the listener for clicks on thread items. - */ - fun setThreadClickListener(clickListener: ThreadListView.ThreadClickListener) { - this.clickListener = clickListener - } - - companion object { - private const val ITEM_THREAD = 0 - private const val ITEM_LOADING_MORE = 1 - - private const val MAX_UNREAD_COUNT = 99 - } - - /** - * ViewHolder for thread items. - */ - inner class ThreadItemViewHolder( - private val binding: StreamUiItemThreadListBinding, - ) : RecyclerView.ViewHolder(binding.root) { - - private lateinit var thread: Thread - - init { - binding.root.setOnClickListener { - clickListener?.onThreadClick(thread) - } - } - - /** - * Applies the [ThreadListViewStyle] to the [ThreadItemViewHolder]. - * - * @param style The style to be applied. - */ - fun applyStyle(style: ThreadListViewStyle) { - binding.threadImage.setImageDrawable(style.threadIconDrawable) - binding.threadTitleTextView.setTextStyle(style.threadTitleStyle) - binding.replyToTextView.setTextStyle(style.threadReplyToStyle) - // Remove ripple from latestReplyMessageView - binding.latestReplyMessageView.binding.root.background = null - binding.latestReplyMessageView.styleView(style.latestReplyStyle) - binding.unreadCountBadge.setTextStyle(style.unreadCountBadgeTextStyle) - binding.unreadCountBadge.background = style.unreadCountBadgeBackground - } - - /** - * Binds the given [Thread] to the view. - */ - fun bind(thread: Thread) { - this.thread = thread - val currentUser = ChatUI.currentUserProvider.getCurrentUser() - bindThreadTitle(thread, currentUser) - bindReplyTo(thread, currentUser) - bindUnreadCountBadge(thread, currentUser) - bindLatestReply(thread, currentUser) - } - - private fun bindThreadTitle(thread: Thread, currentUser: User?) { - val channel = thread.channel - val title = channel - ?.let { ChatUI.channelNameFormatter.formatChannelName(channel = it, currentUser = currentUser) } - ?: thread.title - binding.threadTitleTextView.text = title - } - - private fun bindReplyTo(thread: Thread, currentUser: User?) { - val prefix = binding.root.context.getString(R.string.stream_ui_thread_list_replied_to) - val parentMessageText = formatMessage(thread.parentMessage, currentUser?.asMention(context)) - val replyToText = "$prefix$parentMessageText" - binding.replyToTextView.text = replyToText - } - - private fun bindUnreadCountBadge(thread: Thread, currentUser: User?) { - val unreadCount = thread.read - .find { it.user.id == currentUser?.id } - ?.unreadMessages - ?: 0 - if (unreadCount > 0) { - binding.unreadCountBadge.text = - if (unreadCount > MAX_UNREAD_COUNT) "${MAX_UNREAD_COUNT}+" else unreadCount.toString() - binding.unreadCountBadge.isVisible = true - } else { - binding.unreadCountBadge.isVisible = false - } - } - - private fun bindLatestReply(thread: Thread, currentUser: User?) { - val latestReply = thread.latestReplies.lastOrNull() - if (latestReply != null) { - // User avatar - binding.latestReplyMessageView.binding.userAvatarView.setUser(latestReply.user) - // Sender name - binding.latestReplyMessageView.binding.senderNameLabel.text = latestReply.user.name.bold() - // Reply text - binding.latestReplyMessageView.binding.messageLabel.text = - formatMessage(latestReply, currentUser?.asMention(context)) - // Timestamp - binding.latestReplyMessageView.binding.messageTimeLabel.text = - ChatUI.dateFormatter.formatDate(latestReply.createdAt ?: latestReply.createdLocallyAt) - binding.latestReplyMessageView.isVisible = true - } else { - // Note: should never happen - binding.latestReplyMessageView.isVisible = false - } - } - - private fun formatMessage(message: Message, currentUserMention: String?): CharSequence { - val attachmentsText = message.getAttachmentsText() - val displayedText = message.getTranslatedText() - val previewText = displayedText.trim().let { - if (currentUserMention != null) { - // bold mentions of the current user - it.bold(currentUserMention.singletonList(), ignoreCase = true) - } else { - it - } - } - - return listOf(previewText, attachmentsText) - .filterNot { it.isNullOrEmpty() } - .joinTo(SpannableStringBuilder(), " ") - } - } - - /** - * ViewHolder for the loading more item. - */ - inner class LoadingMoreViewHolder( - binding: StreamUiItemThreadListLoadingMoreBinding, - ) : RecyclerView.ViewHolder(binding.root) - - /** - * [DiffUtil.ItemCallback] for calculating differences between [ThreadListItem]s. - */ - private object ThreadListItemDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { - return oldItem.stableId == newItem.stableId - } - - override fun areContentsTheSame(oldItem: ThreadListItem, newItem: ThreadListItem): Boolean { - return if (oldItem is ThreadListItem.ThreadItem && newItem is ThreadListItem.ThreadItem) { - oldItem.thread == newItem.thread // [Thread] is a data class, equality check is enough - } else { - false - } - } - } -} From 986dad29d846b195448996e2527f88303b4ab07c Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Mon, 2 Dec 2024 10:06:02 +0100 Subject: [PATCH 14/22] [AND-3] Add @JvmName to ThreadListViewModelBinding.kt --- .../api/stream-chat-android-ui-components.api | 8 ++++---- ...adListViewBinding.kt => ThreadListViewModelBinding.kt} | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) rename stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/{ThreadListViewBinding.kt => ThreadListViewModelBinding.kt} (96%) diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 33905e9d0c8..765a79ec172 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -5058,10 +5058,6 @@ public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel public static final fun bind (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel;Lio/getstream/chat/android/ui/feature/search/list/SearchResultListView;Landroidx/lifecycle/LifecycleOwner;)V } -public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBindingKt { - public static final fun bindView (Lio/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel;Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView;Landroidx/lifecycle/LifecycleOwner;)V -} - public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel : androidx/lifecycle/ViewModel { public fun (Lio/getstream/chat/android/ui/common/feature/threads/ThreadListController;)V public final fun getState ()Landroidx/lifecycle/LiveData; @@ -5069,6 +5065,10 @@ public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListView public final fun loadNextPage ()V } +public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModelBinding { + public static final fun bind (Lio/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel;Lio/getstream/chat/android/ui/feature/threads/list/ThreadListView;Landroidx/lifecycle/LifecycleOwner;)V +} + public final class io/getstream/chat/android/ui/viewmodel/threads/ThreadsViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { public fun ()V public fun (III)V diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModelBinding.kt similarity index 96% rename from stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt rename to stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModelBinding.kt index 957d326ccb8..b55d3b8a6be 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewBinding.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModelBinding.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:JvmName("ThreadListViewModelBinding") + package io.getstream.chat.android.ui.viewmodel.threads import androidx.lifecycle.LifecycleOwner @@ -26,6 +28,7 @@ import io.getstream.chat.android.ui.feature.threads.list.ThreadListView * This function sets listeners on the view and ViewModel. Call this method * before setting any additional listeners on these objects yourself. */ +@JvmName("bind") public fun ThreadListViewModel.bindView(view: ThreadListView, lifecycleOwner: LifecycleOwner) { state.observe(lifecycleOwner) { state -> when { From 7ebe487f4f72ddcb192190241cfb140ea2b0d3a8 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Thu, 5 Dec 2024 16:16:06 +0100 Subject: [PATCH 15/22] [AND-3] Fix crash in ThreadListViewStyle due to wrong 'use' import. --- .../chat/android/ui/feature/threads/list/ThreadListViewStyle.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt index 969f6d42aa4..b364416681f 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/ThreadListViewStyle.kt @@ -31,6 +31,7 @@ import io.getstream.chat.android.ui.helper.ViewStyle import io.getstream.chat.android.ui.utils.extensions.getColorCompat import io.getstream.chat.android.ui.utils.extensions.getDimension import io.getstream.chat.android.ui.utils.extensions.getDrawableCompat +import io.getstream.chat.android.ui.utils.extensions.use /** * Class holding the customizable styling parameters for [ThreadListView]. From f00eadf66fb1af3ef47cf667480d2dc69fcc4d85 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Fri, 6 Dec 2024 09:29:23 +0100 Subject: [PATCH 16/22] [AND-15] Fix demo app unread counts. --- .../io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt index d3bc2fabdde..de6fd6edd53 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/home/HomeViewModel.kt @@ -73,7 +73,7 @@ class HomeViewModel( val events: LiveData> = _events init { - _state.postValue(initialState) + setState { initialState } _state.addSource(globalState.totalUnreadCount.asLiveData()) { count -> setState { copy(totalUnreadCount = count) } From 4231b53f97da9b8ff7a64d2e5d41e489e97ee388 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Fri, 6 Dec 2024 10:22:20 +0100 Subject: [PATCH 17/22] [AND-15] Fix current user mentions displayed in bold. --- .../internal/ThreadItemViewHolder.kt | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt index 853f3b740bc..0b3e2db1f4b 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/threads/list/adapter/viewholder/internal/ThreadItemViewHolder.kt @@ -24,15 +24,12 @@ import io.getstream.chat.android.models.Thread import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.R -import io.getstream.chat.android.ui.common.extensions.internal.context -import io.getstream.chat.android.ui.common.extensions.internal.singletonList import io.getstream.chat.android.ui.databinding.StreamUiItemThreadListBinding import io.getstream.chat.android.ui.feature.threads.list.ThreadListView import io.getstream.chat.android.ui.feature.threads.list.ThreadListViewStyle import io.getstream.chat.android.ui.feature.threads.list.adapter.ThreadListItem import io.getstream.chat.android.ui.feature.threads.list.adapter.viewholder.BaseThreadListItemViewHolder import io.getstream.chat.android.ui.font.setTextStyle -import io.getstream.chat.android.ui.utils.extensions.asMention import io.getstream.chat.android.ui.utils.extensions.bold import io.getstream.chat.android.ui.utils.extensions.getAttachmentsText import io.getstream.chat.android.ui.utils.extensions.getTranslatedText @@ -70,9 +67,9 @@ internal class ThreadItemViewHolder( this.thread = item.thread val currentUser = ChatUI.currentUserProvider.getCurrentUser() bindThreadTitle(currentUser) - bindReplyTo(currentUser) + bindReplyTo() bindUnreadCountBadge(currentUser) - bindLatestReply(currentUser) + bindLatestReply() } private fun applyStyle(style: ThreadListViewStyle) { @@ -94,9 +91,9 @@ internal class ThreadItemViewHolder( binding.threadTitleTextView.text = title } - private fun bindReplyTo(currentUser: User?) { + private fun bindReplyTo() { val prefix = binding.root.context.getString(R.string.stream_ui_thread_list_replied_to) - val parentMessageText = formatMessage(thread.parentMessage, currentUser?.asMention(context)) + val parentMessageText = formatMessage(thread.parentMessage) val replyToText = "$prefix$parentMessageText" binding.replyToTextView.text = replyToText } @@ -115,7 +112,7 @@ internal class ThreadItemViewHolder( } } - private fun bindLatestReply(currentUser: User?) { + private fun bindLatestReply() { val latestReply = thread.latestReplies.lastOrNull() if (latestReply != null) { // User avatar @@ -123,8 +120,7 @@ internal class ThreadItemViewHolder( // Sender name binding.latestReplyMessageView.binding.senderNameLabel.text = latestReply.user.name.bold() // Reply text - binding.latestReplyMessageView.binding.messageLabel.text = - formatMessage(latestReply, currentUser?.asMention(context)) + binding.latestReplyMessageView.binding.messageLabel.text = formatMessage(latestReply) // Timestamp binding.latestReplyMessageView.binding.messageTimeLabel.text = ChatUI.dateFormatter.formatDate(latestReply.createdAt ?: latestReply.createdLocallyAt) @@ -135,18 +131,10 @@ internal class ThreadItemViewHolder( } } - private fun formatMessage(message: Message, currentUserMention: String?): CharSequence { + private fun formatMessage(message: Message): CharSequence { val attachmentsText = message.getAttachmentsText() val displayedText = message.getTranslatedText() - val previewText = displayedText.trim().let { - if (currentUserMention != null) { - // bold mentions of the current user - it.bold(currentUserMention.singletonList(), ignoreCase = true) - } else { - it - } - } - + val previewText = displayedText.trim() return listOf(previewText, attachmentsText) .filterNot { it.isNullOrEmpty() } .joinTo(SpannableStringBuilder(), " ") From 5d2f9f795e1cc312504cb34ebe1b9007d8e2f463 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Mon, 9 Dec 2024 12:06:59 +0100 Subject: [PATCH 18/22] [AND-15] Update MessageList back navigation when opened for thread. --- .../getstream/chat/android/ai/assistant/AiMessagesScreen.kt | 2 ++ .../api/stream-chat-android-compose.api | 1 + .../chat/android/compose/ui/messages/MessagesScreen.kt | 2 ++ .../compose/viewmodel/messages/MessageListViewModel.kt | 6 ++++++ .../api/stream-chat-android-ui-common.api | 1 + .../common/feature/messages/list/MessageListController.kt | 5 +++++ .../android/ui/viewmodel/messages/MessageListViewModel.kt | 6 +++--- 7 files changed, 20 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-ai-assistant/src/main/kotlin/io/getstream/chat/android/ai/assistant/AiMessagesScreen.kt b/stream-chat-android-ai-assistant/src/main/kotlin/io/getstream/chat/android/ai/assistant/AiMessagesScreen.kt index 7b633ae4a5f..aea446a61a3 100644 --- a/stream-chat-android-ai-assistant/src/main/kotlin/io/getstream/chat/android/ai/assistant/AiMessagesScreen.kt +++ b/stream-chat-android-ai-assistant/src/main/kotlin/io/getstream/chat/android/ai/assistant/AiMessagesScreen.kt @@ -161,6 +161,7 @@ public fun AiMessagesScreen( val backAction: BackAction = remember(listViewModel, composerViewModel, attachmentsPickerViewModel) { { + val isStartedForThread = listViewModel.isStartedForThread val isInThread = listViewModel.isInThread val isShowingOverlay = listViewModel.isShowingOverlay @@ -171,6 +172,7 @@ public fun AiMessagesScreen( ) isShowingOverlay -> listViewModel.selectMessage(null) + isStartedForThread -> onBackPressed() isInThread -> { listViewModel.leaveThread() composerViewModel.leaveThread() 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 a276b9eaa77..0e4a583a1b2 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -3614,6 +3614,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageL public final fun isOnline ()Lkotlinx/coroutines/flow/Flow; public final fun isShowingOverlay ()Z public final fun isShowingPollOptionDetails ()Z + public final fun isStartedForThread ()Z public final fun leaveThread ()V public final fun loadNewerMessages (Ljava/lang/String;I)V public static synthetic fun loadNewerMessages$default (Lio/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel;Ljava/lang/String;IILjava/lang/Object;)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt index b851c6462d1..a0f0a0f958e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt @@ -183,6 +183,7 @@ public fun MessagesScreen( val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 val backAction: BackAction = remember(listViewModel, composerViewModel, attachmentsPickerViewModel) { { + val isStartedForThread = listViewModel.isStartedForThread val isInThread = listViewModel.isInThread val isShowingOverlay = listViewModel.isShowingOverlay @@ -193,6 +194,7 @@ public fun MessagesScreen( ) isShowingOverlay -> listViewModel.selectMessage(null) + isStartedForThread -> onBackPressed() isInThread -> { listViewModel.leaveThread() composerViewModel.leaveThread() diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel.kt index 0e897edcede..fd4e3f40b83 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel.kt @@ -106,6 +106,12 @@ public class MessageListViewModel( */ public val messageActions: Set by messageListController.messageActions.asState(viewModelScope) + /** + * Gives us information if the [MessageListViewModel] was started for the purpose of showing a thread. + */ + public val isStartedForThread: Boolean + get() = messageListController.isStartedForThread + /** * Gives us information if we're currently in the [Thread] message mode. */ 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 f97355fef37..d37dea6fb2e 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 @@ -172,6 +172,7 @@ public final class io/getstream/chat/android/ui/common/feature/messages/list/Mes public final fun getUser ()Lkotlinx/coroutines/flow/StateFlow; public final fun isInThread ()Z public final fun isInsideSearch ()Lkotlinx/coroutines/flow/StateFlow; + public final fun isStartedForThread ()Z public final fun loadMessageById (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V public static synthetic fun loadMessageById$default (Lio/getstream/chat/android/ui/common/feature/messages/list/MessageListController;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public final fun loadNewerMessages (Ljava/lang/String;I)V 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 37c159086fc..7fd7ccff298 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 @@ -258,6 +258,11 @@ public class MessageListController( private val _mode: MutableStateFlow = MutableStateFlow(MessageMode.Normal) public val mode: StateFlow = _mode + /** + * Gives us information if the [MessageListController] was started for the purpose of showing a thread. + */ + public val isStartedForThread: Boolean = parentMessageId != null + /** * Gives us information if we're currently in the [MessageMode.MessageThread] mode. */ diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModel.kt index 51f134e7ecd..01fde74ff20 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModel.kt @@ -392,11 +392,11 @@ public class MessageListViewModel( */ private fun onBackButtonPressed() { mode.value?.run { - when (this) { - is MessageMode.Normal -> { + when { + this is MessageMode.Normal || messageListController.isStartedForThread -> { stateMerger.postValue(State.NavigateUp) } - is MessageMode.MessageThread -> { + this is MessageMode.MessageThread -> { onNormalModeEntered() } } From 702fce4faaeca9cc7fecfb6b9fe58d2914b039cd Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Mon, 9 Dec 2024 14:50:56 +0100 Subject: [PATCH 19/22] [AND-15] Fix unread badge always displayed. --- .../internal/EventHandlerSequential.kt | 42 ++++++++++++------- .../logic/channel/internal/ChannelLogic.kt | 21 ++++++++-- .../messages/list/MessageListController.kt | 16 ++++--- 3 files changed, 55 insertions(+), 24 deletions(-) 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 2de180e963f..09f84c19314 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 @@ -698,31 +698,43 @@ internal class EventHandlerSequential( // get the channel, update reads, write the channel is MessageReadEvent -> { - batch.getCurrentChannel(event.cid) - ?.updateReads(event.toChannelUserRead()) - ?.let(batch::addChannel) - // Update corresponding thread if event was received for marking a thread as read - event.thread?.let { threadInfo -> - threadFromPendingUpdateOrRepo(batch, threadInfo.parentMessageId) - ?.markAsReadByUser(threadInfo, event.user, event.createdAt) + val thread = event.thread + if (thread != null) { + // Update corresponding thread if event was received for marking a thread as read + threadFromPendingUpdateOrRepo(batch, thread.parentMessageId) + ?.markAsReadByUser(thread, event.user, event.createdAt) ?.let(batch::addThread) + } else { + batch.getCurrentChannel(event.cid) + ?.updateReads(event.toChannelUserRead()) + ?.let(batch::addChannel) } } is NotificationMarkReadEvent -> { - batch.getCurrentChannel(event.cid) - ?.updateReads(event.toChannelUserRead()) - ?.let(batch::addChannel) + val thread = event.thread + if (thread != null) { + // Update corresponding thread if event was received for marking a thread as read + threadFromPendingUpdateOrRepo(batch, thread.parentMessageId) + ?.markAsReadByUser(thread, event.user, event.createdAt) + ?.let(batch::addThread) + } else { + batch.getCurrentChannel(event.cid) + ?.updateReads(event.toChannelUserRead()) + ?.let(batch::addChannel) + } } is NotificationMarkUnreadEvent -> { - batch.getCurrentChannel(event.cid) - ?.updateReads(event.toChannelUserRead()) - ?.let(batch::addChannel) - // Update corresponding thread if event was received for marking a thread as unread - event.threadId?.let { threadId -> + val threadId = event.threadId + if (threadId != null) { + // Update corresponding thread if event was received for marking a thread as unread threadFromPendingUpdateOrRepo(batch, threadId) ?.markAsUnreadByUser(event.user, event.createdAt) ?.let(batch::addThread) + } else { + batch.getCurrentChannel(event.cid) + ?.updateReads(event.toChannelUserRead()) + ?.let(batch::addChannel) } } is GlobalUserBannedEvent -> { 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 e0bef275132..4ce58b480a6 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 @@ -596,9 +596,24 @@ internal class ChannelLogic( is NotificationChannelTruncatedEvent -> removeMessagesBefore(event.createdAt) is TypingStopEvent -> channelStateLogic.setTyping(event.user.id, null) is TypingStartEvent -> channelStateLogic.setTyping(event.user.id, event) - is MessageReadEvent -> channelStateLogic.updateRead(event.toChannelUserRead()) - is NotificationMarkReadEvent -> channelStateLogic.updateRead(event.toChannelUserRead()) - is NotificationMarkUnreadEvent -> channelStateLogic.updateRead(event.toChannelUserRead()) + is MessageReadEvent -> { + // Update reads only if the event is not related to a thread + if (event.thread == null) { + channelStateLogic.updateRead(event.toChannelUserRead()) + } + } + is NotificationMarkReadEvent -> { + // Update reads only if the event is not related to a thread + if (event.threadId == null) { + channelStateLogic.updateRead(event.toChannelUserRead()) + } + } + is NotificationMarkUnreadEvent -> { + // Update reads only if the event is not related to a thread + if (event.threadId == null) { + channelStateLogic.updateRead(event.toChannelUserRead()) + } + } is NotificationInviteAcceptedEvent -> { channelStateLogic.addMember(event.member) channelStateLogic.updateChannelData(event) 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 7fd7ccff298..941fdd50ff1 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 @@ -555,13 +555,17 @@ public class MessageListController( } } .onFirst { channelUserRead -> - val unreadMessages = (channelState.value?.messages?.value ?: emptyList()) - .fold(emptyList()) { acc, message -> - when { - channelUserRead.lastReadMessageId == message.id -> emptyList() - else -> acc + message + val unreadMessages = if (channelUserRead.unreadMessages == 0) { + emptyList() + } else { + (channelState.value?.messages?.value ?: emptyList()) + .fold(emptyList()) { acc, message -> + when { + channelUserRead.lastReadMessageId == message.id -> emptyList() + else -> acc + message + } } - } + } unreadLabelState.value = channelUserRead.lastReadMessageId ?.takeUnless { unreadMessages.isEmpty() } ?.takeUnless { unreadMessages.lastOrNull()?.id == it } From bdc361ed4c94985fcdde7be181bac63e532fa22a Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Mon, 9 Dec 2024 18:03:36 +0100 Subject: [PATCH 20/22] [AND-15] Revert mark read/unread fixes. --- .../internal/EventHandlerSequential.kt | 42 +++++++------------ .../logic/channel/internal/ChannelLogic.kt | 21 ++-------- 2 files changed, 18 insertions(+), 45 deletions(-) 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 09f84c19314..2de180e963f 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 @@ -698,43 +698,31 @@ internal class EventHandlerSequential( // get the channel, update reads, write the channel is MessageReadEvent -> { - val thread = event.thread - if (thread != null) { - // Update corresponding thread if event was received for marking a thread as read - threadFromPendingUpdateOrRepo(batch, thread.parentMessageId) - ?.markAsReadByUser(thread, event.user, event.createdAt) + batch.getCurrentChannel(event.cid) + ?.updateReads(event.toChannelUserRead()) + ?.let(batch::addChannel) + // Update corresponding thread if event was received for marking a thread as read + event.thread?.let { threadInfo -> + threadFromPendingUpdateOrRepo(batch, threadInfo.parentMessageId) + ?.markAsReadByUser(threadInfo, event.user, event.createdAt) ?.let(batch::addThread) - } else { - batch.getCurrentChannel(event.cid) - ?.updateReads(event.toChannelUserRead()) - ?.let(batch::addChannel) } } is NotificationMarkReadEvent -> { - val thread = event.thread - if (thread != null) { - // Update corresponding thread if event was received for marking a thread as read - threadFromPendingUpdateOrRepo(batch, thread.parentMessageId) - ?.markAsReadByUser(thread, event.user, event.createdAt) - ?.let(batch::addThread) - } else { - batch.getCurrentChannel(event.cid) - ?.updateReads(event.toChannelUserRead()) - ?.let(batch::addChannel) - } + batch.getCurrentChannel(event.cid) + ?.updateReads(event.toChannelUserRead()) + ?.let(batch::addChannel) } is NotificationMarkUnreadEvent -> { - val threadId = event.threadId - if (threadId != null) { - // Update corresponding thread if event was received for marking a thread as unread + batch.getCurrentChannel(event.cid) + ?.updateReads(event.toChannelUserRead()) + ?.let(batch::addChannel) + // Update corresponding thread if event was received for marking a thread as unread + event.threadId?.let { threadId -> threadFromPendingUpdateOrRepo(batch, threadId) ?.markAsUnreadByUser(event.user, event.createdAt) ?.let(batch::addThread) - } else { - batch.getCurrentChannel(event.cid) - ?.updateReads(event.toChannelUserRead()) - ?.let(batch::addChannel) } } is GlobalUserBannedEvent -> { 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 4ce58b480a6..e0bef275132 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 @@ -596,24 +596,9 @@ internal class ChannelLogic( is NotificationChannelTruncatedEvent -> removeMessagesBefore(event.createdAt) is TypingStopEvent -> channelStateLogic.setTyping(event.user.id, null) is TypingStartEvent -> channelStateLogic.setTyping(event.user.id, event) - is MessageReadEvent -> { - // Update reads only if the event is not related to a thread - if (event.thread == null) { - channelStateLogic.updateRead(event.toChannelUserRead()) - } - } - is NotificationMarkReadEvent -> { - // Update reads only if the event is not related to a thread - if (event.threadId == null) { - channelStateLogic.updateRead(event.toChannelUserRead()) - } - } - is NotificationMarkUnreadEvent -> { - // Update reads only if the event is not related to a thread - if (event.threadId == null) { - channelStateLogic.updateRead(event.toChannelUserRead()) - } - } + is MessageReadEvent -> channelStateLogic.updateRead(event.toChannelUserRead()) + is NotificationMarkReadEvent -> channelStateLogic.updateRead(event.toChannelUserRead()) + is NotificationMarkUnreadEvent -> channelStateLogic.updateRead(event.toChannelUserRead()) is NotificationInviteAcceptedEvent -> { channelStateLogic.addMember(event.member) channelStateLogic.updateChannelData(event) From 2ef9043df4f56af3f7f6ef0e625901582e6b7b57 Mon Sep 17 00:00:00 2001 From: PetarVelikov Date: Tue, 10 Dec 2024 11:57:17 +0100 Subject: [PATCH 21/22] [AND-15] Fix thread replies label not showing (add isThreadMode to MessageListItemPayloadDiff). --- .../api/stream-chat-android-ui-components.api | 8 +++++--- .../messages/list/adapter/MessageListItemPayloadDiff.kt | 4 ++++ .../list/adapter/internal/MessageListItemDiffCallback.kt | 1 + 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 765a79ec172..6683d109619 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -2889,13 +2889,14 @@ public final class io/getstream/chat/android/ui/feature/messages/list/adapter/Me public final class io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff { public static final field Companion Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff$Companion; - public fun (ZZZZZZZZZZZZZ)V + public fun (ZZZZZZZZZZZZZZ)V public final fun anyChanged ()Z public final fun component1 ()Z public final fun component10 ()Z public final fun component11 ()Z public final fun component12 ()Z public final fun component13 ()Z + public final fun component14 ()Z public final fun component2 ()Z public final fun component3 ()Z public final fun component4 ()Z @@ -2904,8 +2905,8 @@ public final class io/getstream/chat/android/ui/feature/messages/list/adapter/Me public final fun component7 ()Z public final fun component8 ()Z public final fun component9 ()Z - public final fun copy (ZZZZZZZZZZZZZ)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff;ZZZZZZZZZZZZZILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff; + public final fun copy (ZZZZZZZZZZZZZZ)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff;ZZZZZZZZZZZZZZILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff; public fun equals (Ljava/lang/Object;)Z public final fun getAttachments ()Z public final fun getDeleted ()Z @@ -2919,6 +2920,7 @@ public final class io/getstream/chat/android/ui/feature/messages/list/adapter/Me public final fun getReplyText ()Z public final fun getSyncStatus ()Z public final fun getText ()Z + public final fun getThreadMode ()Z public final fun getUser ()Z public fun hashCode ()I public final fun plus (Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff;)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff; diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff.kt index 435818f7d50..faf211c308f 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItemPayloadDiff.kt @@ -30,6 +30,7 @@ public data class MessageListItemPayloadDiff( val mentions: Boolean, val footer: Boolean, val poll: Boolean, + val threadMode: Boolean, ) { public operator fun plus(other: MessageListItemPayloadDiff): MessageListItemPayloadDiff { return MessageListItemPayloadDiff( @@ -46,6 +47,7 @@ public data class MessageListItemPayloadDiff( mentions = mentions || other.mentions, footer = footer || other.footer, poll = poll || other.poll, + threadMode = threadMode || other.threadMode, ) } @@ -66,6 +68,7 @@ public data class MessageListItemPayloadDiff( mentions = false, footer = false, poll = false, + threadMode = false, ) public val FULL: MessageListItemPayloadDiff = MessageListItemPayloadDiff( @@ -82,6 +85,7 @@ public data class MessageListItemPayloadDiff( mentions = true, footer = true, poll = true, + threadMode = true, ) } } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/internal/MessageListItemDiffCallback.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/internal/MessageListItemDiffCallback.kt index 901ad254368..62b045d6034 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/internal/MessageListItemDiffCallback.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/internal/MessageListItemDiffCallback.kt @@ -77,6 +77,7 @@ internal object MessageListItemDiffCallback : DiffUtil.ItemCallback Date: Thu, 12 Dec 2024 15:40:53 +0100 Subject: [PATCH 22/22] [AND-3] Ensure unread badge is not shown for threads. --- .../messages/list/MessageListController.kt | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) 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 941fdd50ff1..7ffa12e3d31 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 @@ -555,28 +555,30 @@ public class MessageListController( } } .onFirst { channelUserRead -> - val unreadMessages = if (channelUserRead.unreadMessages == 0) { - emptyList() + // Don't show the label if no unread messages in the channel, or the controller is started for a thread + val unreadLabel = if (isStartedForThread || channelUserRead.unreadMessages == 0) { + null } else { - (channelState.value?.messages?.value ?: emptyList()) + val unreadMessages = (channelState.value?.messages?.value ?: emptyList()) .fold(emptyList()) { acc, message -> when { channelUserRead.lastReadMessageId == message.id -> emptyList() else -> acc + message } } + channelUserRead.lastReadMessageId + ?.takeUnless { unreadMessages.isEmpty() } + ?.takeUnless { unreadMessages.lastOrNull()?.id == it } + ?.let { lastReadMessageId -> + UnreadLabel( + unreadCount = channelUserRead.unreadMessages, + lastReadMessageId = lastReadMessageId, + buttonVisibility = shouldShowButton && + unreadMessages.any { !it.isDeleted() }, + ) + } } - unreadLabelState.value = channelUserRead.lastReadMessageId - ?.takeUnless { unreadMessages.isEmpty() } - ?.takeUnless { unreadMessages.lastOrNull()?.id == it } - ?.let { lastReadMessageId -> - UnreadLabel( - unreadCount = channelUserRead.unreadMessages, - lastReadMessageId = lastReadMessageId, - buttonVisibility = shouldShowButton && - unreadMessages.any { !it.isDeleted() }, - ) - } + unreadLabelState.value = unreadLabel }.launchIn(scope) }