diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index d344102c045..63f42702c26 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -918,6 +918,7 @@ public abstract interface class io/getstream/chat/android/client/api/state/Query public abstract interface class io/getstream/chat/android/client/api/state/QueryThreadsState { public abstract fun getFilter ()Lio/getstream/chat/android/models/FilterObject; public abstract fun getLoading ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getLoadingError ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getLoadingMore ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getNext ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getSort ()Lio/getstream/chat/android/models/querysort/QuerySorter; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/QueryThreadsState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/QueryThreadsState.kt index 86dc28d8884..a8e15d9e714 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/QueryThreadsState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/QueryThreadsState.kt @@ -49,4 +49,7 @@ public interface QueryThreadsState { /** The IDs of the threads which exist, but are not (yet) loaded in the paginated list of threads. */ public val unseenThreadIds: StateFlow> + + /** Indicates that the last initial or refresh load failed. Not set for pagination failures. */ + public val loadingError: StateFlow } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 5f12b5f9fd7..f139394ff63 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -68,6 +68,7 @@ import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.api2.model.response.QueryPollVotesResponse import io.getstream.chat.android.client.api2.model.response.QueryPollsResponse import io.getstream.chat.android.client.api2.model.response.QueryRemindersResponse +import io.getstream.chat.android.client.extensions.internal.sortedByLastReply import io.getstream.chat.android.client.extensions.syncUnreadCountWithReads import io.getstream.chat.android.core.internal.StreamHandsOff import io.getstream.chat.android.models.Answer @@ -765,7 +766,7 @@ internal class DomainMapping( createdByUserId = created_by_user_id, createdBy = created_by?.toDomain(), participantCount = participant_count, - threadParticipants = thread_participants.orEmpty().map { it.toDomain() }, + threadParticipants = thread_participants.orEmpty().map { it.toDomain() }.sortedByLastReply(), lastMessageAt = last_message_at, createdAt = created_at, updatedAt = updated_at, @@ -800,7 +801,7 @@ internal class DomainMapping( title = title, updatedAt = updated_at, channel = channel?.toDomain(), - threadParticipants = thread_participants.orEmpty().map { it.toDomain() }, + threadParticipants = thread_participants.orEmpty().map { it.toDomain() }.sortedByLastReply(), extraData = extraData, ) @@ -809,6 +810,7 @@ internal class DomainMapping( */ internal fun DownstreamThreadParticipantDto.toDomain(): ThreadParticipant = ThreadParticipant( user = user?.toDomain() ?: User(id = user_id), + lastThreadMessageAt = last_thread_message_at, ) /** diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt index ae55fddc49e..10a3e9203a0 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ThreadDtos.kt @@ -113,9 +113,11 @@ internal data class DownstreamThreadInfoDto( * @param user_id The ID of the user (thread participant). * @param user The user as the thread participant. (Note: It is not always delivered, sometimes we only get the ID of * the user - [user_id]). + * @param last_thread_message_at The date of the last message in the thread at the time of participation. */ @JsonClass(generateAdapter = true) internal data class DownstreamThreadParticipantDto( val user_id: String, val user: DownstreamUserDto?, + val last_thread_message_at: Date?, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Thread.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Thread.kt index d2ab7ba17de..d53f9189602 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Thread.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Thread.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.extensions.internal import io.getstream.chat.android.client.extensions.getCreatedAtOrNull +import io.getstream.chat.android.client.internal.state.utils.internal.upsertSorted import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Message @@ -26,6 +27,21 @@ import io.getstream.chat.android.models.ThreadParticipant import io.getstream.chat.android.models.User import java.util.Date +/** + * Comparator sorting participants by [ThreadParticipant.lastThreadMessageAt] descending. + * Participants with null timestamps (mentioned-only, never replied) are placed last. + */ +private val PARTICIPANT_BY_LAST_REPLY: Comparator = + compareByDescending { it.lastThreadMessageAt?.time ?: Long.MIN_VALUE } + +/** + * Sorts participants by [ThreadParticipant.lastThreadMessageAt] descending (most recent repliers first). + * Participants with null [ThreadParticipant.lastThreadMessageAt] (e.g. mentioned-only, never replied) are placed last. + */ +@InternalStreamChatApi +public fun List.sortedByLastReply(): List = + sortedWith(PARTICIPANT_BY_LAST_REPLY) + /** * Updates the given Thread with the new message (parent or reply). */ @@ -68,15 +84,14 @@ public fun Thread.upsertReply(reply: Message): Thread { it.getCreatedAtOrNull() } val lastMessageAt = sortedNewReplies.lastOrNull()?.getCreatedAtOrNull() - // The new message could be from a new thread participant - val threadParticipants = if (isInsert) { - upsertThreadParticipantInList( - newParticipant = ThreadParticipant(user = reply.user), - participants = this.threadParticipants, - ) - } else { - this.threadParticipants - } + // Update participant recency on every new reply so avatar stack reflects most active participants. + val threadParticipants = upsertThreadParticipantInList( + newParticipant = ThreadParticipant( + user = reply.user, + lastThreadMessageAt = reply.getCreatedAtOrNull(), + ), + participants = this.threadParticipants, + ) val participantCount = threadParticipants.size // Update read counts (+1 for each non-sender of the message) val read = if (isInsert) { @@ -191,20 +206,11 @@ private fun upsertMessageInList(newMessage: Message, messages: List): L private fun upsertThreadParticipantInList( newParticipant: ThreadParticipant, participants: List, -): List { - // Insert - if (participants.none { it.getUserId() == newParticipant.getUserId() }) { - return participants + listOf(newParticipant) - } - // Update - return participants.map { participant -> - if (participant.getUserId() == newParticipant.getUserId()) { - newParticipant - } else { - participant - } - } -} +): List = participants.upsertSorted( + element = newParticipant, + idSelector = { it.getUserId() }, + comparator = PARTICIPANT_BY_LAST_REPLY, +) private fun updateReadCounts(read: List, reply: Message): List { return read.map { userRead -> diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt index 15256cf73d8..6560a0c2f3b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt @@ -88,7 +88,7 @@ import io.getstream.chat.android.client.internal.offline.repository.domain.user. ThreadOrderEntity::class, DraftMessageEntity::class, ], - version = 100, + version = 101, exportSchema = false, ) @TypeConverters( diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadMapper.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadMapper.kt index fd5acb45ba6..f1f2f3ccb37 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadMapper.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadMapper.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.internal.offline.repository.domain.threads.internal +import io.getstream.chat.android.client.extensions.internal.sortedByLastReply import io.getstream.chat.android.client.internal.offline.repository.domain.channel.userread.internal.toEntity import io.getstream.chat.android.client.internal.offline.repository.domain.channel.userread.internal.toModel import io.getstream.chat.android.models.Channel @@ -63,7 +64,7 @@ internal suspend fun ThreadEntity.toModel( createdBy = getUser(createdByUserId), activeParticipantCount = activeParticipantCount, participantCount = participantCount, - threadParticipants = threadParticipants.map { it.toModel(getUser) }, + threadParticipants = threadParticipants.map { it.toModel(getUser) }.sortedByLastReply(), lastMessageAt = lastMessageAt, createdAt = createdAt, updatedAt = updatedAt, @@ -80,6 +81,7 @@ internal suspend fun ThreadEntity.toModel( */ internal fun ThreadParticipant.toEntity() = ThreadParticipantEntity( userId = user.id, + lastThreadMessageAt = lastThreadMessageAt, ) /** @@ -89,4 +91,5 @@ internal suspend fun ThreadParticipantEntity.toModel( getUser: suspend (userId: String) -> User, ) = ThreadParticipant( user = getUser(userId), + lastThreadMessageAt = lastThreadMessageAt, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadParticipantEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadParticipantEntity.kt index fc902997f5d..3da85eadade 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadParticipantEntity.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadParticipantEntity.kt @@ -17,11 +17,16 @@ package io.getstream.chat.android.client.internal.offline.repository.domain.threads.internal import com.squareup.moshi.JsonClass +import java.util.Date /** * Database entity for a Thread Participant. * * @param userId The ID of the user (thread participant). + * @param lastThreadMessageAt The date of the last message in the thread at the time of participation. */ @JsonClass(generateAdapter = true) -internal data class ThreadParticipantEntity(val userId: String) +internal data class ThreadParticipantEntity( + val userId: String, + val lastThreadMessageAt: Date?, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt index f6ce39c6dcc..4c2e5a89d1f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsLogic.kt @@ -87,13 +87,13 @@ internal class QueryThreadsLogic( if (isNextPageRequest) { stateLogic.setLoadingMore(true) } else { + stateLogic.setLoadingError(false) stateLogic.setLoading(true) - } - if (isForceReload(request)) { - stateLogic.clearThreads() - stateLogic.clearUnseenThreadIds() - } else if (!isNextPageRequest) { - queryThreadsOffline(request) + if (stateLogic.getUnseenThreadIds().isNotEmpty()) { + stateLogic.clearUnseenThreadIds() + } else if (stateLogic.getThreads().isEmpty()) { + queryThreadsOffline(request) + } } } @@ -124,6 +124,9 @@ internal class QueryThreadsLogic( } is Result.Failure -> { + if (!request.isNextPageRequest()) { + stateLogic.setLoadingError(true) + } logger.i { "[queryThreadsResult] with request: $request failed." } } } @@ -179,9 +182,6 @@ internal class QueryThreadsLogic( private fun QueryThreadsRequest.isNextPageRequest() = this.next != null - private fun isForceReload(request: QueryThreadsRequest) = - !request.isNextPageRequest() && stateLogic.getUnseenThreadIds().isNotEmpty() - private fun onNewThreadMessageNotification(event: NotificationThreadMessageNewEvent) { val newMessageThreadId = event.message.parentId ?: return // Update the unseenThreadIsd if the relevant thread is not loaded (yet) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsStateLogic.kt index c969fa7731d..16fa6a19fa6 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsStateLogic.kt @@ -142,6 +142,14 @@ internal class QueryThreadsStateLogic( internal fun clearUnseenThreadIds() = mutableState.clearUnseenThreadIds() + /** + * Updates the loading error state of the [mutableState]. + * + * @param error Whether a non-pagination load has failed. + */ + internal fun setLoadingError(error: Boolean) = + mutableState.setLoadingError(error) + /** * Retrieves a message from the [mutableState] if it exists. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/querythreads/internal/QueryThreadsMutableState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/querythreads/internal/QueryThreadsMutableState.kt index 23b3595362a..f4bb4f6723d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/querythreads/internal/QueryThreadsMutableState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/querythreads/internal/QueryThreadsMutableState.kt @@ -43,6 +43,7 @@ internal class QueryThreadsMutableState( private var _loadingMore: MutableStateFlow? = MutableStateFlow(false) private var _next: MutableStateFlow? = MutableStateFlow(null) private var _unseenThreadIds: MutableStateFlow>? = MutableStateFlow(emptySet()) + private var _loadingError: MutableStateFlow? = MutableStateFlow(false) /** * Exposes a read-only map of the threads. @@ -56,6 +57,7 @@ internal class QueryThreadsMutableState( override val loadingMore: StateFlow = _loadingMore!! override val next: StateFlow = _next!! override val unseenThreadIds: StateFlow> = _unseenThreadIds!! + override val loadingError: StateFlow = _loadingError!! /** * Updates the loading state. Will be true only during the initial load, or during a full reload. @@ -180,6 +182,15 @@ internal class QueryThreadsMutableState( _unseenThreadIds?.value = emptySet() } + /** + * Updates the loading error state. + * + * @param error Whether a non-pagination load has failed. + */ + internal fun setLoadingError(error: Boolean) { + _loadingError?.value = error + } + /** * Clears all data from the state. */ @@ -190,5 +201,6 @@ internal class QueryThreadsMutableState( _loadingMore = null _next = null _unseenThreadIds = null + _loadingError = null } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index 4ae3a290352..2601e9e4e8d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -987,6 +987,16 @@ internal object Mother { extraData = extraData, ) + fun randomDownstreamThreadParticipantDto( + userId: String = randomString(), + user: DownstreamUserDto? = randomDownstreamUserDto(id = userId), + lastThreadMessageAt: Date? = randomDateOrNull(), + ): DownstreamThreadParticipantDto = DownstreamThreadParticipantDto( + user_id = userId, + user = user, + last_thread_message_at = lastThreadMessageAt, + ) + fun randomDownstreamThreadInfoDto( channelCid: String = randomString(), channel: DownstreamChannelDto? = randomDownstreamChannelDto(id = channelCid), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index 1df242cdded..9db3dde2751 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -47,6 +47,7 @@ import io.getstream.chat.android.client.Mother.randomDownstreamReactionGroupDto import io.getstream.chat.android.client.Mother.randomDownstreamReminderDto import io.getstream.chat.android.client.Mother.randomDownstreamThreadDto import io.getstream.chat.android.client.Mother.randomDownstreamThreadInfoDto +import io.getstream.chat.android.client.Mother.randomDownstreamThreadParticipantDto import io.getstream.chat.android.client.Mother.randomDownstreamUserBlockDto import io.getstream.chat.android.client.Mother.randomDownstreamUserDto import io.getstream.chat.android.client.Mother.randomDownstreamVoteDto @@ -60,8 +61,8 @@ import io.getstream.chat.android.client.Mother.randomUnreadChannelDto import io.getstream.chat.android.client.Mother.randomUnreadCountByTeamDto import io.getstream.chat.android.client.Mother.randomUnreadDto import io.getstream.chat.android.client.Mother.randomUnreadThreadDto -import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadParticipantDto import io.getstream.chat.android.client.api2.model.response.MessageResponse +import io.getstream.chat.android.client.extensions.internal.sortedByLastReply import io.getstream.chat.android.models.Answer import io.getstream.chat.android.models.App import io.getstream.chat.android.models.AppSettings @@ -100,7 +101,6 @@ import io.getstream.chat.android.models.ReactionGroup import io.getstream.chat.android.models.SearchWarning import io.getstream.chat.android.models.Thread import io.getstream.chat.android.models.ThreadInfo -import io.getstream.chat.android.models.ThreadParticipant import io.getstream.chat.android.models.UnreadChannel import io.getstream.chat.android.models.UnreadChannelByType import io.getstream.chat.android.models.UnreadCounts @@ -120,6 +120,7 @@ import io.getstream.chat.android.randomUser import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import java.util.Date @Suppress("LargeClass") internal class DomainMappingTest { @@ -744,13 +745,21 @@ internal class DomainMappingTest { fun `DownstreamThreadDto is correctly mapped to Thread`() { val user1 = randomDownstreamUserDto(id = "user1") val user2 = randomDownstreamUserDto(id = "user2") + val participant1Dto = randomDownstreamThreadParticipantDto( + userId = user1.id, + user = user1, + lastThreadMessageAt = Date(2000), + ) + val participant2Dto = randomDownstreamThreadParticipantDto( + userId = user2.id, + user = user2, + lastThreadMessageAt = Date(1000), + ) val downstreamThreadDto = randomDownstreamThreadDto( createdByUserId = user1.id, createdBy = user1, - threadParticipants = listOf( - DownstreamThreadParticipantDto(user_id = user1.id, user = user1), - DownstreamThreadParticipantDto(user_id = user2.id, user = user2), - ), + // Intentionally unsorted to validate sortedByLastReply() in mapping. + threadParticipants = listOf(participant2Dto, participant1Dto), draft = randomDownstreamDraftDto( message = randomDownstreamDraftMessageDto(text = "Draft message"), channelCid = "messaging:123", @@ -768,10 +777,9 @@ internal class DomainMappingTest { createdByUserId = downstreamThreadDto.created_by_user_id, createdBy = with(sut) { downstreamThreadDto.created_by?.toDomain() }, participantCount = downstreamThreadDto.participant_count, - threadParticipants = listOf( - ThreadParticipant(user = with(sut) { user1.toDomain() }), - ThreadParticipant(user = with(sut) { user2.toDomain() }), - ), + threadParticipants = with(sut) { + listOf(participant1Dto, participant2Dto).map { it.toDomain() }.sortedByLastReply() + }, lastMessageAt = downstreamThreadDto.last_message_at, createdAt = downstreamThreadDto.created_at, updatedAt = downstreamThreadDto.updated_at, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/ThreadExtensionsTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/ThreadExtensionsTests.kt index 8d8e3beac27..5760010dde2 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/ThreadExtensionsTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/ThreadExtensionsTests.kt @@ -22,9 +22,9 @@ import io.getstream.chat.android.models.Option import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.Thread import io.getstream.chat.android.models.ThreadInfo -import io.getstream.chat.android.models.ThreadParticipant import io.getstream.chat.android.models.User import io.getstream.chat.android.models.VotingVisibility +import io.getstream.chat.android.randomThreadParticipant import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldNotBeNull import org.junit.Test @@ -48,8 +48,14 @@ internal class ThreadExtensionsTests { createdAt = now, user = user2, ) - private val threadParticipant1 = ThreadParticipant(user = user1) - private val threadParticipant2 = ThreadParticipant(user = user2) + private val threadParticipant1 = randomThreadParticipant( + user = user1, + lastThreadMessageAt = now, + ) + private val threadParticipant2 = randomThreadParticipant( + user = user2, + lastThreadMessageAt = Date(now.time - 1_000), + ) private val channelUserRead1 = ChannelUserRead( user = user1, unreadMessages = 0, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt index a0998e6fe87..bae2fb404d3 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt @@ -274,6 +274,14 @@ internal fun randomLocationEntity(): LocationEntity = deviceId = randomString(), ) +internal fun randomThreadParticipantEntity( + userId: String = randomString(), + lastThreadMessageAt: Date? = randomDateOrNull(), +): ThreadParticipantEntity = ThreadParticipantEntity( + userId = userId, + lastThreadMessageAt = lastThreadMessageAt, +) + internal fun randomThreadEntity( parentMessageId: String = randomString(), cid: String = randomCID(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadMapperTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadMapperTest.kt index 5a27872c9df..d42dcc660be 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadMapperTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/threads/internal/ThreadMapperTest.kt @@ -20,7 +20,6 @@ import io.getstream.chat.android.client.internal.offline.randomThreadEntity import io.getstream.chat.android.client.internal.offline.repository.domain.channel.userread.internal.toEntity import io.getstream.chat.android.client.internal.offline.repository.domain.channel.userread.internal.toModel import io.getstream.chat.android.models.Thread -import io.getstream.chat.android.models.ThreadParticipant import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomThread @@ -47,11 +46,7 @@ internal class ThreadMapperTest { createdBy = user, activeParticipantCount = entity.activeParticipantCount, participantCount = entity.participantCount, - threadParticipants = entity.threadParticipants.map { - ThreadParticipant( - user = user, - ) - }, + threadParticipants = entity.threadParticipants.map { it.toModel { user } }, lastMessageAt = entity.lastMessageAt, createdAt = entity.createdAt, updatedAt = entity.updatedAt, @@ -83,11 +78,7 @@ internal class ThreadMapperTest { createdByUserId = thread.createdByUserId, activeParticipantCount = thread.activeParticipantCount, participantCount = thread.participantCount, - threadParticipants = thread.threadParticipants.map { - ThreadParticipantEntity( - userId = it.user.id, - ) - }, + threadParticipants = thread.threadParticipants.map { it.toEntity() }, lastMessageAt = thread.lastMessageAt, createdAt = thread.createdAt, updatedAt = thread.updatedAt, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsLogicTest.kt index ae001455470..9aff061be19 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsLogicTest.kt @@ -37,8 +37,8 @@ import io.getstream.chat.android.models.QueryThreadsResult import io.getstream.chat.android.models.Reaction import io.getstream.chat.android.models.Thread import io.getstream.chat.android.models.ThreadInfo -import io.getstream.chat.android.models.ThreadParticipant import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomThreadParticipant import io.getstream.result.Error import io.getstream.result.Result import kotlinx.coroutines.test.runTest @@ -71,8 +71,8 @@ internal class QueryThreadsLogicTest { createdBy = null, participantCount = 2, threadParticipants = listOf( - ThreadParticipant(User(id = "usrId1")), - ThreadParticipant(User(id = "usrId2")), + randomThreadParticipant(user = User(id = "usrId1")), + randomThreadParticipant(user = User(id = "usrId2")), ), lastMessageAt = Date(), createdAt = Date(), @@ -157,6 +157,7 @@ internal class QueryThreadsLogicTest { // when logic.onQueryThreadsRequest(QueryThreadsRequest()) // then + verify(stateLogic, times(1)).setLoadingError(false) verify(stateLogic, times(1)).setLoading(true) verify(stateLogic, never()).setLoadingMore(any()) verify(databaseLogic, times(1)).getLocalThreadsOrder(anyOrNull(), any()) @@ -165,7 +166,7 @@ internal class QueryThreadsLogicTest { } @Test - fun `Given QueryThreadsLogic When requesting new data by force reload Should update loading state and clear current data`() = + fun `Given QueryThreadsLogic When requesting reload with unseen threads Should keep threads and clear unseen IDs`() = runTest { // given val stateLogic = mock() @@ -175,10 +176,12 @@ internal class QueryThreadsLogicTest { // when logic.onQueryThreadsRequest(QueryThreadsRequest()) // then + verify(stateLogic, times(1)).setLoadingError(false) verify(stateLogic, times(1)).setLoading(true) verify(stateLogic, never()).setLoadingMore(any()) - verify(stateLogic, times(1)).clearThreads() + verify(stateLogic, never()).clearThreads() verify(stateLogic, times(1)).clearUnseenThreadIds() + verifyNoInteractions(databaseLogic) } @Test @@ -247,7 +250,7 @@ internal class QueryThreadsLogicTest { } @Test - fun `Given QueryThreadsLogic When handling error result Should update loading state`() = runTest { + fun `Given QueryThreadsLogic When handling error result Should update loading state and set loadingError`() = runTest { // given val stateLogic = mock() val databaseLogic = mock() @@ -259,6 +262,27 @@ internal class QueryThreadsLogicTest { // then verify(stateLogic, times(1)).setLoading(false) verify(stateLogic, times(1)).setLoadingMore(false) + verify(stateLogic, times(1)).setLoadingError(true) + verify(stateLogic, never()).setNext(any()) + verify(stateLogic, never()).upsertThreads(any()) + verify(stateLogic, never()).setThreads(any()) + verify(stateLogic, never()).clearUnseenThreadIds() + } + + @Test + fun `Given QueryThreadsLogic When handling pagination error result Should not set loadingError`() = runTest { + // given + val stateLogic = mock() + val databaseLogic = mock() + val logic = QueryThreadsLogic(stateLogic, databaseLogic) + // when + val request = QueryThreadsRequest(next = "page2Cursor") + val result = Result.Failure(Error.GenericError("error")) + logic.onQueryThreadsResult(result, request) + // then + verify(stateLogic, times(1)).setLoading(false) + verify(stateLogic, times(1)).setLoadingMore(false) + verify(stateLogic, never()).setLoadingError(any()) verify(stateLogic, never()).setNext(any()) verify(stateLogic, never()).upsertThreads(any()) verify(stateLogic, never()).setThreads(any()) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsStateLogicTest.kt index 87ea584b694..4b3cca9b897 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querythreads/internal/QueryThreadsStateLogicTest.kt @@ -57,8 +57,9 @@ internal class QueryThreadsStateLogicTest { createdBy = null, participantCount = 2, threadParticipants = listOf( - ThreadParticipant(User(id = "usrId1")), - ThreadParticipant(User(id = "usrId2")), + // Sorted descending by lastThreadMessageAt (most recent first) + ThreadParticipant(user = User(id = "usrId2"), lastThreadMessageAt = Date(2000)), + ThreadParticipant(user = User(id = "usrId1"), lastThreadMessageAt = Date(1000)), ), lastMessageAt = Date(), createdAt = Date(), @@ -441,20 +442,29 @@ internal class QueryThreadsStateLogicTest { val mutableState = mock() whenever(mutableState.threadMap) doReturn threadList.associateBy(Thread::parentMessageId) val logic = QueryThreadsStateLogic(mutableState, mutableGlobalState) + val replyCreatedAt = Date() val reply = Message( id = "mId3", cid = "messaging:123", text = "Updated text", parentId = "mId1", + createdAt = replyCreatedAt, user = User(id = "usrId3"), ) // when logic.upsertReply(reply) // then + val expectedNewParticipant = ThreadParticipant( + user = User(id = "usrId3"), + lastThreadMessageAt = replyCreatedAt, + ) val expectedUpdatedThread = threadList[0].copy( latestReplies = threadList[0].latestReplies + listOf(reply), + lastMessageAt = replyCreatedAt, + updatedAt = replyCreatedAt, participantCount = 3, - threadParticipants = threadList[0].threadParticipants + listOf(ThreadParticipant(User("usrId3"))), + // New participant has the most recent lastThreadMessageAt, so it sorts to the front + threadParticipants = listOf(expectedNewParticipant) + threadList[0].threadParticipants, read = threadList[0].read.map { read -> read.copy(unreadMessages = read.unreadMessages + 1) }, @@ -469,18 +479,29 @@ internal class QueryThreadsStateLogicTest { val mutableState = mock() whenever(mutableState.threadMap) doReturn threadList.associateBy(Thread::parentMessageId) val logic = QueryThreadsStateLogic(mutableState, mutableGlobalState) + val replyCreatedAt = Date() val reply = Message( id = "mId3", cid = "messaging:123", text = "Updated text", parentId = "mId1", + createdAt = replyCreatedAt, user = User(id = "usrId2"), ) // when logic.upsertReply(reply) // then + val expectedUpdatedParticipant = ThreadParticipant( + user = User(id = "usrId2"), + lastThreadMessageAt = replyCreatedAt, + ) val expectedUpdatedThread = threadList[0].copy( latestReplies = threadList[0].latestReplies + listOf(reply), + lastMessageAt = replyCreatedAt, + updatedAt = replyCreatedAt, + threadParticipants = threadList[0].threadParticipants.map { p -> + if (p.user.id == "usrId2") expectedUpdatedParticipant else p + }, read = threadList[0].read.map { read -> if (read.user.id == "usrId2") { read diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/querythreads/internal/QueryThreadsMutableStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/querythreads/internal/QueryThreadsMutableStateTest.kt index f5dd7e47c67..5cb830a8f27 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/querythreads/internal/QueryThreadsMutableStateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/querythreads/internal/QueryThreadsMutableStateTest.kt @@ -20,8 +20,8 @@ import app.cash.turbine.test import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Thread -import io.getstream.chat.android.models.ThreadParticipant import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomThreadParticipant import io.getstream.chat.android.test.TestCoroutineRule import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be equal to` @@ -45,8 +45,8 @@ internal class QueryThreadsMutableStateTest { createdBy = null, participantCount = 2, threadParticipants = listOf( - ThreadParticipant(User("usrId1")), - ThreadParticipant(User("usrId2")), + randomThreadParticipant(user = User("usrId1")), + randomThreadParticipant(user = User("usrId2")), ), lastMessageAt = Date(), createdAt = Date(), @@ -70,8 +70,8 @@ internal class QueryThreadsMutableStateTest { createdBy = null, participantCount = 2, threadParticipants = listOf( - ThreadParticipant(User("usrId1")), - ThreadParticipant(User("usrId2")), + randomThreadParticipant(user = User("usrId1")), + randomThreadParticipant(user = User("usrId2")), ), lastMessageAt = Date(), createdAt = Date(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ThreadDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ThreadDtoTestData.kt index 398c571ac21..0ff891099a8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ThreadDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ThreadDtoTestData.kt @@ -45,7 +45,8 @@ internal object ThreadDtoTestData { "thread_participants": [ { "user_id": "user1", - "user": ${UserDtoTestData.downstreamJson} + "user": ${UserDtoTestData.downstreamJson}, + "last_thread_message_at": null } ], "title": "Thread Title", @@ -86,6 +87,7 @@ internal object ThreadDtoTestData { DownstreamThreadParticipantDto( user_id = "user1", user = UserDtoTestData.downstreamUser, + last_thread_message_at = null, ), ), title = "Thread Title", @@ -168,7 +170,8 @@ internal object ThreadDtoTestData { "thread_participants": [ { "user_id": "user1", - "user": ${UserDtoTestData.downstreamJson} + "user": ${UserDtoTestData.downstreamJson}, + "last_thread_message_at": null } ], "last_message_at": "2020-06-10T11:04:31.588Z", @@ -197,6 +200,7 @@ internal object ThreadDtoTestData { DownstreamThreadParticipantDto( user_id = "user1", user = UserDtoTestData.downstreamUser, + last_thread_message_at = null, ), ), last_message_at = Date(1591787071588), 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 2a0fe92cb79..d42e3dd01aa 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -2942,15 +2942,11 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC public abstract fun SearchResultItemLeadingContent (Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/state/channels/list/ItemState$SearchResultItemState;Lio/getstream/chat/android/models/User;Landroidx/compose/runtime/Composer;I)V public abstract fun SearchResultItemTrailingContent (Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/state/channels/list/ItemState$SearchResultItemState;Landroidx/compose/runtime/Composer;I)V public abstract fun SwipeToReplyContent (Landroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)V + public abstract fun ThreadListBanner (Lio/getstream/chat/android/compose/ui/threads/ThreadListBannerState;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public abstract fun ThreadListEmptyContent (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V public abstract fun ThreadListItem (Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V - public abstract fun ThreadListItemLatestReplyContent (Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/User;Landroidx/compose/runtime/Composer;I)V - public abstract fun ThreadListItemReplyToContent (Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/models/Thread;Landroidx/compose/runtime/Composer;I)V - public abstract fun ThreadListItemTitle (Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Landroidx/compose/runtime/Composer;I)V - public abstract fun ThreadListItemUnreadCountContent (Landroidx/compose/foundation/layout/RowScope;ILandroidx/compose/runtime/Composer;I)V public abstract fun ThreadListLoadingContent (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V public abstract fun ThreadListLoadingMoreContent (Landroidx/compose/runtime/Composer;I)V - public abstract fun ThreadListUnreadThreadsBanner (ILkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public abstract fun UserAvatar (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/User;ZZLandroidx/compose/runtime/Composer;I)V public abstract fun messageListItemModifier (Landroidx/compose/foundation/lazy/LazyItemScope;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; } @@ -3134,15 +3130,11 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFacto public static fun SearchResultItemLeadingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/state/channels/list/ItemState$SearchResultItemState;Lio/getstream/chat/android/models/User;Landroidx/compose/runtime/Composer;I)V public static fun SearchResultItemTrailingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/compose/state/channels/list/ItemState$SearchResultItemState;Landroidx/compose/runtime/Composer;I)V public static fun SwipeToReplyContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Landroidx/compose/runtime/Composer;I)V + public static fun ThreadListBanner (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/threads/ThreadListBannerState;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public static fun ThreadListEmptyContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V public static fun ThreadListItem (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V - public static fun ThreadListItemLatestReplyContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/User;Landroidx/compose/runtime/Composer;I)V - public static fun ThreadListItemReplyToContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;Lio/getstream/chat/android/models/Thread;Landroidx/compose/runtime/Composer;I)V - public static fun ThreadListItemTitle (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Landroidx/compose/runtime/Composer;I)V - public static fun ThreadListItemUnreadCountContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/RowScope;ILandroidx/compose/runtime/Composer;I)V public static fun ThreadListLoadingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V public static fun ThreadListLoadingMoreContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/runtime/Composer;I)V - public static fun ThreadListUnreadThreadsBanner (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;ILkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public static fun UserAvatar (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/User;ZZLandroidx/compose/runtime/Composer;I)V public static fun messageListItemModifier (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/lazy/LazyItemScope;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; } @@ -3921,46 +3913,55 @@ public final class io/getstream/chat/android/compose/ui/theme/TranslationConfig public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadItemKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadItemKt; - public static field lambda-1 Lkotlin/jvm/functions/Function4; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; +} + +public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListBannerKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListBannerKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; public static field lambda-2 Lkotlin/jvm/functions/Function2; public static field lambda-3 Lkotlin/jvm/functions/Function2; public static field lambda-4 Lkotlin/jvm/functions/Function2; public static field lambda-5 Lkotlin/jvm/functions/Function2; public static field lambda-6 Lkotlin/jvm/functions/Function2; - public static field lambda-7 Lkotlin/jvm/functions/Function2; public fun ()V - public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-7$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; public static field lambda-2 Lkotlin/jvm/functions/Function2; - public static field lambda-3 Lkotlin/jvm/functions/Function2; + public static field lambda-3 Lkotlin/jvm/functions/Function4; public static field lambda-4 Lkotlin/jvm/functions/Function2; public static field lambda-5 Lkotlin/jvm/functions/Function2; public static field lambda-6 Lkotlin/jvm/functions/Function2; public static field lambda-7 Lkotlin/jvm/functions/Function2; public static field lambda-8 Lkotlin/jvm/functions/Function2; + public static field lambda-9 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-3$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-7$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-8$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-9$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } -public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$UnreadThreadsBannerKt { - public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$UnreadThreadsBannerKt; +public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListLoadingItemKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListLoadingItemKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; public static field lambda-2 Lkotlin/jvm/functions/Function2; public fun ()V @@ -3969,7 +3970,42 @@ public final class io/getstream/chat/android/compose/ui/threads/ComposableSingle } public final class io/getstream/chat/android/compose/ui/threads/ThreadItemKt { - public static final fun ThreadItem (Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun ThreadItem (Lio/getstream/chat/android/models/Thread;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/getstream/chat/android/compose/ui/threads/ThreadListBannerKt { + public static final fun ThreadListBanner (Lio/getstream/chat/android/compose/ui/threads/ThreadListBannerState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + +public abstract interface class io/getstream/chat/android/compose/ui/threads/ThreadListBannerState { +} + +public final class io/getstream/chat/android/compose/ui/threads/ThreadListBannerState$Error : io/getstream/chat/android/compose/ui/threads/ThreadListBannerState { + public static final field $stable I + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ThreadListBannerState$Error; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/compose/ui/threads/ThreadListBannerState$Loading : io/getstream/chat/android/compose/ui/threads/ThreadListBannerState { + public static final field $stable I + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ThreadListBannerState$Loading; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/compose/ui/threads/ThreadListBannerState$UnreadThreads : io/getstream/chat/android/compose/ui/threads/ThreadListBannerState { + public static final field $stable I + public fun (I)V + public final fun component1 ()I + public final fun copy (I)Lio/getstream/chat/android/compose/ui/threads/ThreadListBannerState$UnreadThreads; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/threads/ThreadListBannerState$UnreadThreads;IILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/threads/ThreadListBannerState$UnreadThreads; + public fun equals (Ljava/lang/Object;)Z + public final fun getCount ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public final class io/getstream/chat/android/compose/ui/threads/ThreadListKt { @@ -3977,10 +4013,6 @@ public final class io/getstream/chat/android/compose/ui/threads/ThreadListKt { public static final fun ThreadList (Lio/getstream/chat/android/ui/common/state/threads/ThreadListState;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V } -public final class io/getstream/chat/android/compose/ui/threads/UnreadThreadsBannerKt { - public static final fun UnreadThreadsBanner (ILandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V -} - public final class io/getstream/chat/android/compose/ui/util/ChannelUtilsKt { public static final fun getLastMessage (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/models/Message; public static final fun getMembersStatusText (Lio/getstream/chat/android/models/Channel;Landroid/content/Context;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/ui/common/model/UserPresence;)Ljava/lang/String; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ShimmerProgressIndicator.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ShimmerProgressIndicator.kt index f7a15f31ef3..2826fff5990 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ShimmerProgressIndicator.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/ShimmerProgressIndicator.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import com.valentinilk.shimmer.shimmer import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.StreamShimmerTheme @@ -28,13 +29,19 @@ import io.getstream.chat.android.compose.ui.theme.StreamShimmerTheme * Displays a shimmer progress indicator using [StreamShimmerTheme]. * * @param modifier The modifier to be applied to the component. + * @param baseColor The background color shown beneath the shimmer. + * @param highlightColor The color revealed by the shimmer animation. */ @Composable -internal fun ShimmerProgressIndicator(modifier: Modifier = Modifier) { +internal fun ShimmerProgressIndicator( + modifier: Modifier = Modifier, + baseColor: Color = ChatTheme.colors.backgroundCoreSurface, + highlightColor: Color = ChatTheme.colors.backgroundCoreApp, +) { Box( modifier = modifier - .background(color = ChatTheme.colors.backgroundCoreSurface) + .background(color = baseColor) .shimmer() - .background(color = ChatTheme.colors.backgroundCoreApp), + .background(color = highlightColor), ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 6c82df948f1..2c53033f5cb 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -187,11 +187,7 @@ import io.getstream.chat.android.compose.ui.threads.DefaultThreadListEmptyConten import io.getstream.chat.android.compose.ui.threads.DefaultThreadListLoadingContent import io.getstream.chat.android.compose.ui.threads.DefaultThreadListLoadingMoreContent import io.getstream.chat.android.compose.ui.threads.ThreadItem -import io.getstream.chat.android.compose.ui.threads.ThreadItemLatestReplyContent -import io.getstream.chat.android.compose.ui.threads.ThreadItemReplyToContent -import io.getstream.chat.android.compose.ui.threads.ThreadItemTitle -import io.getstream.chat.android.compose.ui.threads.ThreadItemUnreadCountContent -import io.getstream.chat.android.compose.ui.threads.UnreadThreadsBanner +import io.getstream.chat.android.compose.ui.threads.ThreadListBannerState import io.getstream.chat.android.compose.ui.util.ReactionResolver import io.getstream.chat.android.compose.ui.util.StreamSnackbar import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel @@ -2570,20 +2566,19 @@ public interface ChatComponentFactory { } /** - * The default "Unread threads" banner. - * Shows the number of unread threads in the "ThreadList". + * The default thread list banner. + * Shows unread thread count, a loading indicator during refresh, or an error prompt. * - * @param unreadThreads The number of unread threads. + * @param state The current [ThreadListBannerState] to render. * @param onClick Action invoked when the user clicks on the banner. */ @Composable - public fun ThreadListUnreadThreadsBanner( - unreadThreads: Int, + public fun ThreadListBanner( + state: ThreadListBannerState, onClick: () -> Unit, ) { - UnreadThreadsBanner( - unreadThreads = unreadThreads, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + io.getstream.chat.android.compose.ui.threads.ThreadListBanner( + state = state, onClick = onClick, ) } @@ -2606,62 +2601,6 @@ public interface ChatComponentFactory { ThreadItem(thread, currentUser, onThreadClick) } - /** - * Default representation of the thread title. - * - * Used in the [ThreadListItem] to display the title of the thread. - * - * @param thread The thread to display. - * @param channel The channel the thread belongs to. - * @param currentUser The current user. - */ - @Composable - public fun ThreadListItemTitle( - thread: Thread, - channel: Channel, - currentUser: User?, - ) { - ThreadItemTitle(channel, currentUser) - } - - /** - * Default representation of the parent message preview in a thread. - * - * Used in the [ThreadListItem] to display the parent message of the thread. - * - * @param thread The thread to display. - */ - @Composable - public fun RowScope.ThreadListItemReplyToContent(thread: Thread) { - ThreadItemReplyToContent(thread.parentMessage) - } - - /** - * Default representation of the unread count badge. Not shown if unreadCount == 0. - * - * Used in the [ThreadListItem] to display the number of unread replies in the thread. - * - * @param unreadCount The number of unread thread replies. - */ - @Composable - public fun RowScope.ThreadListItemUnreadCountContent(unreadCount: Int) { - ThreadItemUnreadCountContent(unreadCount) - } - - /** - * Default representation of the latest reply content in a thread. - * Shows a preview of the last message in the thread. - * - * Used in the [ThreadListItem] to display the latest reply in the thread. - * - * @param thread The thread to display. - * @param currentUser The currently logged-in user. - */ - @Composable - public fun ThreadListItemLatestReplyContent(thread: Thread, currentUser: User?) { - ThreadItemLatestReplyContent(thread, currentUser) - } - /** * The default empty placeholder that is displayed when there are no threads. * diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadItem.kt index b5c40038d7c..bd0658e8ad5 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadItem.kt @@ -22,13 +22,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Icon +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.ripple @@ -36,46 +33,36 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.getstream.chat.android.client.extensions.getCreatedAtOrNull -import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.ui.components.Timestamp +import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.components.avatar.UserAvatarStack import io.getstream.chat.android.compose.ui.components.channels.UnreadCountIndicator import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.isOneToOne import io.getstream.chat.android.models.Channel -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.previewdata.PreviewThreadData import io.getstream.chat.android.ui.common.utils.extensions.shouldShowOnlineIndicator /** - * The basic Thread item, showing information about the Thread title, parent message, last reply and number of unread - * replies. + * The basic Thread item, showing information about the thread title, parent message, latest reply + * and number of unread replies. * * @param thread The [Thread] object holding the data to be rendered. * @param currentUser The currently logged [User], used for formatting the message in the thread preview. * @param onThreadClick Action invoked when the user clicks on the item. * @param modifier [Modifier] instance for general styling. - * @param titleContent Composable rendering the title of the thread item. Defaults to a 'thread' icon and the name of - * the channel in which the thread resides. - * @param replyToContent Composable rendering the preview of the thread parent message. Defaults to a preview of the - * parent message with a 'replied to:' prefix. - * @param unreadCountContent Composable rendering the badge indicator of unread replies in a thread. Defaults to a red - * circular badge with the unread count inside. - * @param latestReplyContent Composable rendering the preview of the latest reply in the thread. Defaults to a content - * composed of the reply author image, reply author name, preview of the reply text and a timestamp. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -84,55 +71,58 @@ public fun ThreadItem( currentUser: User?, onThreadClick: (Thread) -> Unit, modifier: Modifier = Modifier, - titleContent: @Composable (Channel) -> Unit = { channel -> - ChatTheme.componentFactory.ThreadListItemTitle(thread, channel, currentUser) - }, - replyToContent: @Composable RowScope.(parentMessage: Message) -> Unit = { - with(ChatTheme.componentFactory) { - ThreadListItemReplyToContent(thread) - } - }, - unreadCountContent: @Composable RowScope.(unreadCount: Int) -> Unit = { unreadCount -> - with(ChatTheme.componentFactory) { - ThreadListItemUnreadCountContent(unreadCount) - } - }, - latestReplyContent: @Composable (reply: Message) -> Unit = { - ChatTheme.componentFactory.ThreadListItemLatestReplyContent(thread, currentUser) - }, ) { - Column( + val borderColor = ChatTheme.colors.borderCoreSubtle + val unreadCount = unreadCountForUser(thread, currentUser) + Row( modifier = modifier .fillMaxWidth() + .drawBehind { + val strokeWidth = 1.dp.toPx() + val y = size.height - strokeWidth / 2 + drawLine( + color = borderColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = strokeWidth, + ) + } + .padding(StreamTokens.spacing2xs) + .clip(RoundedCornerShape(StreamTokens.radiusLg)) .combinedClickable( onClick = { onThreadClick(thread) }, indication = ripple(), interactionSource = remember { MutableInteractionSource() }, ) - .padding(horizontal = 8.dp, vertical = 14.dp), + .padding(all = StreamTokens.spacingSm), + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), + verticalAlignment = Alignment.Top, ) { - thread.channel?.let { channel -> - titleContent(channel) - } - val unreadCount = unreadCountForUser(thread, currentUser) - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - replyToContent(thread.parentMessage) - unreadCountContent(unreadCount) - } - thread.latestReplies.lastOrNull()?.let { reply -> - latestReplyContent(reply) + ChatTheme.componentFactory.UserAvatar( + modifier = Modifier.size(AvatarSize.ExtraLarge), + user = thread.parentMessage.user, + showIndicator = thread.parentMessage.user.shouldShowOnlineIndicator( + userPresence = ChatTheme.userPresence, + currentUser = currentUser, + ), + showBorder = false, + ) + ThreadItemContentContainer( + modifier = Modifier.weight(1f), + thread = thread, + currentUser = currentUser, + ) + if (unreadCount > 0) { + UnreadCountIndicator(unreadCount) } } } /** - * Default representation of the thread title. + * Displays the channel name where the thread resides. * - * @param channel The [Channel] in which the thread resides. - * @param currentUser The currently logged [User], used for formatting the message in the thread preview. + * @param channel The [Channel] hosting the thread. + * @param currentUser The currently logged [User], used for formatting the channel name. */ @Composable internal fun ThreadItemTitle( @@ -140,137 +130,169 @@ internal fun ThreadItemTitle( currentUser: User?, ) { val title = ChatTheme.channelNameFormatter.formatChannelName(channel, currentUser) - Row(modifier = Modifier.fillMaxWidth()) { - Icon( - painter = painterResource(id = R.drawable.stream_compose_ic_thread), - contentDescription = null, - tint = ChatTheme.colors.textPrimary, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = title, - color = ChatTheme.colors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = ChatTheme.typography.bodyEmphasis, - ) - } + Text( + text = title, + color = ChatTheme.colors.textTertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ChatTheme.typography.captionEmphasis, + ) } /** - * Default representation of the parent message preview in a thread. + * Displays a single-line preview of the thread's parent message. + * Deleted messages are shown in italic using a localised "message deleted" label. * - * @param parentMessage The parent message of the thread. + * @param thread The [Thread] to render the parent message for. + * @param currentUser The currently logged in user. */ @Composable -internal fun RowScope.ThreadItemReplyToContent(parentMessage: Message) { - val prefix = stringResource(id = R.string.stream_compose_replied_to) - val text = formatMessage(parentMessage) +internal fun ThreadItemParentMessage(thread: Thread, currentUser: User?) { + val isOneToOneChannel = thread.channel?.isOneToOne(currentUser) ?: false + val message = thread.parentMessage + val formatter = ChatTheme.messagePreviewFormatter + val text = remember(message, currentUser, isOneToOneChannel, formatter) { + formatter.formatMessagePreview(message, currentUser, isOneToOneChannel) + } Text( - modifier = Modifier.weight(1f), - text = "$prefix$text", - fontSize = 12.sp, - color = ChatTheme.colors.textSecondary, + modifier = Modifier.fillMaxWidth(), + text = text, + color = ChatTheme.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, style = ChatTheme.typography.bodyDefault, + inlineContent = ChatTheme.messagePreviewIconFactory.createPreviewIcons(), ) } /** - * Default representation of the unread count badge. + * Displays a horizontal stack of participant avatars for the thread. * - * @param unreadCount The number of unread thread replies. + * @param participants The [User]s whose avatars are shown, typically the most recent thread + * participants (up to 3), in newest-first order so the latest replier sits on top. */ @Composable -internal fun RowScope.ThreadItemUnreadCountContent(unreadCount: Int) { - if (unreadCount > 0) { - UnreadCountIndicator( - unreadCount = unreadCount, - ) +internal fun ThreadItemParticipants(participants: List) { + UserAvatarStack( + overlap = StreamTokens.spacingXs, + users = participants, + avatarSize = AvatarSize.Small, + showBorder = true, + ) +} + +/** + * Displays the reply count label for a thread (e.g. "5 replies"). + * + * @param replyCount The total number of replies in the thread. + */ +@Composable +internal fun ThreadItemReplyCount(replyCount: Int) { + Text( + text = pluralStringResource( + id = R.plurals.stream_compose_thread_list_item_reply_count, + count = replyCount, + replyCount, + ), + style = ChatTheme.typography.captionEmphasis, + color = ChatTheme.colors.chatTextLink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +/** + * Displays the formatted timestamp for when the thread was last updated. + * + * @param thread The [Thread] whose [Thread.updatedAt] is formatted and displayed. + * @see ThreadTimestampFormatter + */ +@Composable +internal fun ThreadItemTimestamp(thread: Thread) { + val updatedAt = thread.updatedAt + val context = LocalContext.current + val timestamp = remember(updatedAt, context) { + ThreadTimestampFormatter.format(updatedAt, context) } + Text( + text = timestamp, + style = ChatTheme.typography.captionDefault, + color = ChatTheme.colors.chatTextTimestamp, + ) } /** - * Default representation of the latest reply content in a thread. + * Container holding the thread header ([ThreadItemTitle] and [ThreadItemParentMessage]) and the + * [ThreadRepliesFooter], filling the available horizontal space between the avatar and the + * notification badge. * - * @param thread The thread to display. + * @param thread The [Thread] to display. + * @param currentUser The currently logged [User]. + * @param modifier Modifier for styling. */ @Composable -internal fun ThreadItemLatestReplyContent( +internal fun ThreadItemContentContainer( thread: Thread, currentUser: User?, + modifier: Modifier = Modifier, ) { - val latestReply = thread.latestReplies.lastOrNull() - if (latestReply != null) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + ) { + Column( + modifier = Modifier.padding(vertical = StreamTokens.spacing3xs), + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), ) { - ChatTheme.componentFactory.UserAvatar( - modifier = Modifier.size(40.dp), - user = latestReply.user, - showIndicator = latestReply.user.shouldShowOnlineIndicator( - userPresence = ChatTheme.userPresence, - currentUser = currentUser, - ), - showBorder = false, - ) - Spacer(modifier = Modifier.width(8.dp)) - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.Center, - ) { - Text( - text = latestReply.user.name, - style = ChatTheme.typography.bodyEmphasis, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = ChatTheme.colors.textPrimary, - ) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val text = formatMessage(latestReply) - Text( - modifier = Modifier.weight(1f), - text = text, - maxLines = 1, - fontSize = 14.sp, - overflow = TextOverflow.Ellipsis, - style = ChatTheme.typography.bodyDefault, - color = ChatTheme.colors.textSecondary, - ) - Timestamp( - modifier = Modifier.padding(start = 8.dp), - date = latestReply.updatedAt ?: latestReply.getCreatedAtOrNull(), - ) - } + thread.channel?.let { channel -> + ThreadItemTitle(channel, currentUser) } + ThreadItemParentMessage(thread, currentUser) } + ThreadRepliesFooter(thread) + } +} + +/** + * Footer row inside a thread item showing [ThreadItemParticipants], [ThreadItemReplyCount], + * and [ThreadItemTimestamp]. + * + * @param thread The [Thread] to display. + */ +@Composable +internal fun ThreadRepliesFooter(thread: Thread) { + val latestReply = thread.latestReplies.lastOrNull() ?: return + // The thread author will always be a thread participant, even if he didn't reply to the thread. + // Note: Because we don't get all replies (or participants) in the response, we can not reliably know + // whether the thread author has a reply in the thread + // Small UI improvement is to ensure we only show the actual replier if we have only one reply. + val participants = if (thread.replyCount == 1) { + listOf(latestReply.user) + } else { + thread.threadParticipants + .map { it.user } + .ifEmpty { listOfNotNull(latestReply.user) } + .take(MaxParticipantCount) + .reversed() + } + Row( + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + verticalAlignment = Alignment.CenterVertically, + ) { + ThreadItemParticipants(participants) + ThreadItemReplyCount(thread.replyCount) + ThreadItemTimestamp(thread) } } +private const val MaxParticipantCount = 3 + private fun unreadCountForUser(thread: Thread, user: User?) = thread.read .find { it.user.id == user?.id } ?.unreadMessages ?: 0 -@Composable -private fun formatMessage(message: Message) = - if (message.isDeleted()) { - buildAnnotatedString { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(stringResource(id = R.string.stream_ui_message_list_message_deleted)) - } - } - } else { - ChatTheme.messagePreviewFormatter.formatMessagePreview(message, null, false) - } - @Composable @Preview private fun ThreadItemPreview() { @@ -284,45 +306,3 @@ private fun ThreadItemPreview() { } } } - -@Composable -@Preview -private fun DefaultThreadTitlePreview() { - ChatPreviewTheme { - Surface { - ThreadItemTitle( - channel = Channel( - id = "messaging:123", - type = "messaging", - name = "Group ride preparation and discussion", - ), - currentUser = null, - ) - } - } -} - -@Composable -@Preview -private fun DefaultUnreadCountContentPreview() { - ChatPreviewTheme { - Row { - ThreadItemUnreadCountContent(unreadCount = 17) - } - } -} - -@Composable -@Preview -private fun ThreadParentMessageContentPreview() { - ChatPreviewTheme { - Row { - val parentMessage = Message( - id = "message1", - cid = "messaging:123", - text = "Hey everyone, who's up for a group ride this Saturday morning?", - ) - ThreadItemReplyToContent(parentMessage) - } - } -} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadList.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadList.kt index d97382b143a..4f0ff1d36ce 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadList.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadList.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.compose.ui.threads -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -26,29 +25,35 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.handlers.LoadMoreHandler import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.compose.viewmodel.threads.ThreadListViewModel import io.getstream.chat.android.models.Thread import io.getstream.chat.android.models.User @@ -61,13 +66,13 @@ import io.getstream.chat.android.ui.common.state.threads.ThreadListState * @param viewModel The [ThreadListViewModel] handling the loading of the threads. * @param modifier [Modifier] instance for general styling. * @param currentUser The currently logged [User], used for formatting the message in the thread preview. - * @param onUnreadThreadsBannerClick Action invoked when the user clicks on the "Unread threads" banner. By default, it + * @param onBannerClick Action invoked when the user clicks on the banner. By default, it * calls [ThreadListViewModel.load] to force reload the list of threads, loading the newly created/updated threads. * @param onThreadClick Action invoked when the usr clicks on a thread item in the list. No-op by default. * @param onLoadMore Action invoked when the current thread page was scrolled to the end, and a next page should be * loaded. By default, it calls [ThreadListViewModel.loadNextPage] to load the next page of threads. - * @param unreadThreadsBanner Composable rendering the "Unread threads" banner on the top of the list. Override it to - * provide a custom component to be rendered for displaying the number of new unread threads. + * @param banner Composable rendering the banner on the top of the list. Receives the current [ThreadListBannerState] + * (or null when no banner should be shown). Override to provide a custom banner component. * @param itemContent Composable rendering each [Thread] item in the list. Override this to provide a custom component * for rendering the items. * @param emptyContent Composable shown when there are no threads to display. Override this to provide custom component @@ -82,11 +87,11 @@ public fun ThreadList( viewModel: ThreadListViewModel, modifier: Modifier = Modifier, currentUser: User? = ChatClient.instance().getCurrentUser(), - onUnreadThreadsBannerClick: () -> Unit = { viewModel.load() }, + onBannerClick: () -> Unit = { viewModel.load() }, onThreadClick: (Thread) -> Unit = {}, onLoadMore: () -> Unit = { viewModel.loadNextPage() }, - unreadThreadsBanner: @Composable (Int) -> Unit = { - ChatTheme.componentFactory.ThreadListUnreadThreadsBanner(it, onUnreadThreadsBannerClick) + banner: @Composable (ThreadListBannerState?) -> Unit = { state -> + state?.let { ChatTheme.componentFactory.ThreadListBanner(it, onBannerClick) } }, itemContent: @Composable (Thread) -> Unit = { ChatTheme.componentFactory.ThreadListItem(it, currentUser, onThreadClick) @@ -106,10 +111,10 @@ public fun ThreadList( state = state, modifier = modifier, currentUser = currentUser, - onUnreadThreadsBannerClick = onUnreadThreadsBannerClick, + onBannerClick = onBannerClick, onThreadClick = onThreadClick, onLoadMore = onLoadMore, - unreadThreadsBanner = unreadThreadsBanner, + banner = banner, itemContent = itemContent, emptyContent = emptyContent, loadingContent = loadingContent, @@ -124,12 +129,12 @@ public fun ThreadList( * @param state The [ThreadListState] holding the current thread list state. * @param modifier [Modifier] instance for general styling. * @param currentUser The currently logged [User], used for formatting the message in the thread preview. - * @param onUnreadThreadsBannerClick Action invoked when the user clicks on the "Unread threads" banner. + * @param onBannerClick Action invoked when the user clicks on the banner. * @param onThreadClick Action invoked when the usr clicks on a thread item in the list. * @param onLoadMore Action invoked when the current thread page was scrolled to the end, and a next page should be * loaded. - * @param unreadThreadsBanner Composable rendering the "Unread threads" banner on the top of the list. Override it to - * provide a custom component to be rendered for displaying the number of new unread threads. + * @param banner Composable rendering the banner on the top of the list. Receives the current [ThreadListBannerState] + * (or null when no banner should be shown). Override to provide a custom banner component. * @param itemContent Composable rendering each [Thread] item in the list. Override this to provide a custom component * for rendering the items. * @param emptyContent Composable shown when there are no threads to display. Override this to provide custom component @@ -144,11 +149,11 @@ public fun ThreadList( state: ThreadListState, modifier: Modifier = Modifier, currentUser: User? = ChatClient.instance().getCurrentUser(), - onUnreadThreadsBannerClick: () -> Unit, + onBannerClick: () -> Unit, onThreadClick: (Thread) -> Unit, onLoadMore: () -> Unit, - unreadThreadsBanner: @Composable (Int) -> Unit = { - ChatTheme.componentFactory.ThreadListUnreadThreadsBanner(it, onUnreadThreadsBannerClick) + banner: @Composable (ThreadListBannerState?) -> Unit = { bannerState -> + bannerState?.let { ChatTheme.componentFactory.ThreadListBanner(it, onBannerClick) } }, itemContent: @Composable (Thread) -> Unit = { ChatTheme.componentFactory.ThreadListItem(it, currentUser, onThreadClick) @@ -163,10 +168,17 @@ public fun ThreadList( ChatTheme.componentFactory.ThreadListLoadingMoreContent() }, ) { + val bannerState: ThreadListBannerState? = when { + state.isLoading && state.threads.isNotEmpty() -> ThreadListBannerState.Loading + state.loadingError -> ThreadListBannerState.Error + state.unseenThreadsCount > 0 -> + ThreadListBannerState.UnreadThreads(state.unseenThreadsCount) + else -> null + } Scaffold( containerColor = ChatTheme.colors.backgroundCoreApp, topBar = { - unreadThreadsBanner(state.unseenThreadsCount) + banner(bannerState) }, content = { padding -> Box(modifier = modifier.padding(padding)) { @@ -175,8 +187,9 @@ public fun ThreadList( state.threads.isEmpty() -> emptyContent() else -> Threads( threads = state.threads, + isLoading = state.isLoading, isLoadingMore = state.isLoadingMore, - modifier = modifier, + modifier = Modifier, onLoadMore = onLoadMore, itemContent = itemContent, loadingMoreContent = loadingMoreContent, @@ -191,6 +204,7 @@ public fun ThreadList( * Composable representing a non-empty list of threads. * * @param threads The non-empty [List] of [Thread]s to show. + * @param isLoading Indicator if the list is being refreshed (e.g. banner tap). * @param isLoadingMore Indicator if there is loading of the next page of threads in progress. * @param modifier [Modifier] instance for general styling. * @param onLoadMore Action invoked when the current thread page was scrolled to the end, and a next page should be @@ -202,6 +216,7 @@ public fun ThreadList( @Composable private fun Threads( threads: List, + isLoading: Boolean, isLoadingMore: Boolean, modifier: Modifier, onLoadMore: () -> Unit, @@ -209,6 +224,13 @@ private fun Threads( loadingMoreContent: @Composable () -> Unit, ) { val listState = rememberLazyListState() + var wasLoading by remember { mutableStateOf(isLoading) } + LaunchedEffect(isLoading) { + if (wasLoading && !isLoading) { + listState.animateScrollToItem(0) + } + wasLoading = isLoading + } Box(modifier = modifier) { LazyColumn(state = listState) { items( @@ -242,21 +264,19 @@ internal fun DefaultThreadListEmptyContent(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Image( - modifier = Modifier.size(112.dp), + Icon( + modifier = Modifier.size(32.dp), painter = painterResource(R.drawable.stream_compose_ic_threads_empty), contentDescription = null, + tint = ChatTheme.colors.textTertiary, ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(StreamTokens.spacingXs)) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.width(160.dp), text = stringResource(id = R.string.stream_compose_thread_list_empty_title), textAlign = TextAlign.Center, color = ChatTheme.colors.textSecondary, - fontSize = 20.sp, - lineHeight = 25.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + style = ChatTheme.typography.captionDefault, ) } } @@ -268,8 +288,15 @@ internal fun DefaultThreadListEmptyContent(modifier: Modifier = Modifier) { */ @Composable internal fun DefaultThreadListLoadingContent(modifier: Modifier = Modifier) { - Box(modifier = modifier.background(ChatTheme.colors.backgroundCoreApp)) { - LoadingIndicator(modifier) + LazyColumn( + modifier = modifier + .background(ChatTheme.colors.backgroundCoreApp) + .testTag("Stream_ThreadListLoading"), + userScrollEnabled = false, + ) { + items(count = 7) { + ThreadListLoadingItem() + } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadListBanner.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadListBanner.kt new file mode 100644 index 00000000000..5609d456eca --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadListBanner.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.threads + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.clickable + +/** + * Composable banner displayed at the top of the thread list. Supports three visual states: + * - [ThreadListBannerState.UnreadThreads]: shows the count of new threads with a refresh icon. + * - [ThreadListBannerState.Loading]: shows a spinner with "Loading..." text. + * - [ThreadListBannerState.Error]: shows an error icon with a retry prompt. + * + * @param state The current [ThreadListBannerState] to render. + * @param modifier [Modifier] instance for general styling. + * @param onClick Action invoked when the user clicks on the banner. Not invoked for [ThreadListBannerState.Loading]. + */ +@Suppress("LongMethod") +@Composable +public fun ThreadListBanner( + state: ThreadListBannerState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val isClickable = state !is ThreadListBannerState.Loading && onClick != null + val clickableModifier = if (isClickable) { + Modifier.clickable { onClick.invoke() } + } else { + Modifier + } + val color = ChatTheme.colors.chatTextSystem + + Row( + modifier = modifier + .fillMaxWidth() + .background(ChatTheme.colors.backgroundCoreSurface) + .then(clickableModifier) + .padding(StreamTokens.spacingSm), + horizontalArrangement = Arrangement.spacedBy( + StreamTokens.spacingXs, + Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + when (state) { + is ThreadListBannerState.UnreadThreads -> { + Icon( + modifier = Modifier.size(StreamTokens.size16), + painter = painterResource(R.drawable.stream_compose_ic_union), + contentDescription = null, + tint = color, + ) + Text( + text = pluralStringResource( + R.plurals.stream_compose_thread_list_new_threads, + state.count, + state.count, + ), + style = ChatTheme.typography.metadataEmphasis, + color = color, + ) + } + + is ThreadListBannerState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.size(StreamTokens.size16), + strokeWidth = 2.dp, + color = color, + ) + Text( + text = stringResource(R.string.stream_compose_thread_list_banner_loading), + style = ChatTheme.typography.metadataEmphasis, + color = color, + ) + } + + is ThreadListBannerState.Error -> { + Icon( + modifier = Modifier.size(StreamTokens.size16), + painter = painterResource(R.drawable.stream_compose_ic_exclamation_circle), + contentDescription = null, + tint = color, + ) + Text( + text = stringResource(R.string.stream_compose_thread_list_banner_error), + style = ChatTheme.typography.metadataEmphasis, + color = color, + ) + } + } + } +} + +/** + * Sealed interface representing the possible states of the [ThreadListBanner]. + */ +public sealed interface ThreadListBannerState { + /** + * Indicates that there are unseen threads available. + * + * @param count The number of unseen threads. + */ + public data class UnreadThreads(val count: Int) : ThreadListBannerState + + /** Indicates that a refresh/reload is in progress. */ + public data object Loading : ThreadListBannerState + + /** Indicates that the last load/refresh failed. */ + public data object Error : ThreadListBannerState +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ThreadListBannerUnreadPreview() { + ChatPreviewTheme { + Surface { + Column { + ThreadListBanner( + state = ThreadListBannerState.UnreadThreads(count = 5), + onClick = {}, + ) + } + } + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ThreadListBannerLoadingPreview() { + ChatPreviewTheme { + Surface { + ThreadListBanner(state = ThreadListBannerState.Loading) + } + } +} + +@Composable +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ThreadListBannerErrorPreview() { + ChatPreviewTheme { + Surface { + ThreadListBanner( + state = ThreadListBannerState.Error, + onClick = {}, + ) + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadListLoadingItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadListLoadingItem.kt new file mode 100644 index 00000000000..692fdf2f3b6 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadListLoadingItem.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.threads + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.ui.components.ShimmerProgressIndicator +import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens + +/** + * A skeleton loading placeholder that mirrors the layout of a [ThreadItem]. + * Displays an animated shimmer effect while thread data is loading. + * + * @param modifier Modifier for styling. + */ +@Suppress("LongMethod") +@Composable +internal fun ThreadListLoadingItem(modifier: Modifier = Modifier) { + val borderColor = ChatTheme.colors.borderCoreSubtle + val highlightColor = ChatTheme.colors.skeletonLoadingHighlight + + Row( + modifier = modifier + .fillMaxWidth() + .drawBehind { + val strokeWidth = 1.dp.toPx() + val y = size.height - strokeWidth / 2 + drawLine( + color = borderColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = strokeWidth, + ) + } + .padding(StreamTokens.spacingMd), + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), + verticalAlignment = Alignment.Top, + ) { + // Avatar + ShimmerProgressIndicator( + modifier = Modifier + .size(AvatarSize.ExtraLarge) + .clip(CircleShape), + highlightColor = highlightColor, + ) + + // Content + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + ) { + // Channel title + message preview + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = StreamTokens.spacing2xs), + verticalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + ) { + ShimmerProgressIndicator( + modifier = Modifier + .width(120.dp) + .height(StreamTokens.size12) + .clip(RoundedCornerShape(StreamTokens.radiusFull)), + highlightColor = highlightColor, + ) + ShimmerProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .clip(RoundedCornerShape(StreamTokens.radiusFull)), + highlightColor = highlightColor, + ) + } + + // Reply footer + Row( + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), + verticalAlignment = Alignment.CenterVertically, + ) { + ShimmerProgressIndicator( + modifier = Modifier + .size(AvatarSize.Small) + .clip(CircleShape), + highlightColor = highlightColor, + ) + ShimmerProgressIndicator( + modifier = Modifier + .width(64.dp) + .height(StreamTokens.size12) + .clip(RoundedCornerShape(StreamTokens.radiusFull)), + highlightColor = highlightColor, + ) + ShimmerProgressIndicator( + modifier = Modifier + .width(64.dp) + .height(StreamTokens.size12) + .clip(RoundedCornerShape(StreamTokens.radiusFull)), + highlightColor = highlightColor, + ) + } + } + + ShimmerProgressIndicator( + modifier = Modifier + .width(48.dp) + .height(StreamTokens.size16) + .clip(RoundedCornerShape(StreamTokens.radiusFull)), + highlightColor = highlightColor, + ) + } +} + +@Preview +@Composable +private fun ThreadListLoadingItemPreview() { + ChatTheme { + Surface { + ThreadListLoadingItem() + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadTimestampFormatter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadTimestampFormatter.kt new file mode 100644 index 00000000000..76de786bc1a --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/threads/ThreadTimestampFormatter.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.threads + +import android.content.Context +import android.text.format.DateUtils +import io.getstream.chat.android.compose.R +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.abs + +/** + * Formats [io.getstream.chat.android.models.Thread.updatedAt] timestamps for display in [ThreadItem]. + * + * Formatting rules: + * - Within the last minute → "Just now" + * - Same calendar day → "Today at