Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<String>>

/** Indicates that the last initial or refresh load failed. Not set for pagination failures. */
public val loadingError: StateFlow<Boolean>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)

Expand All @@ -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,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ThreadParticipant> =
compareByDescending<ThreadParticipant> { 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<ThreadParticipant>.sortedByLastReply(): List<ThreadParticipant> =
sortedWith(PARTICIPANT_BY_LAST_REPLY)

/**
* Updates the given Thread with the new message (parent or reply).
*/
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -191,20 +206,11 @@ private fun upsertMessageInList(newMessage: Message, messages: List<Message>): L
private fun upsertThreadParticipantInList(
newParticipant: ThreadParticipant,
participants: List<ThreadParticipant>,
): List<ThreadParticipant> {
// 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<ThreadParticipant> = participants.upsertSorted(
element = newParticipant,
idSelector = { it.getUserId() },
comparator = PARTICIPANT_BY_LAST_REPLY,
)

private fun updateReadCounts(read: List<ChannelUserRead>, reply: Message): List<ChannelUserRead> {
return read.map { userRead ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -80,6 +81,7 @@ internal suspend fun ThreadEntity.toModel(
*/
internal fun ThreadParticipant.toEntity() = ThreadParticipantEntity(
userId = user.id,
lastThreadMessageAt = lastThreadMessageAt,
)

/**
Expand All @@ -89,4 +91,5 @@ internal suspend fun ThreadParticipantEntity.toModel(
getUser: suspend (userId: String) -> User,
) = ThreadParticipant(
user = getUser(userId),
lastThreadMessageAt = lastThreadMessageAt,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -124,6 +124,9 @@ internal class QueryThreadsLogic(
}

is Result.Failure -> {
if (!request.isNextPageRequest()) {
stateLogic.setLoadingError(true)
}
logger.i { "[queryThreadsResult] with request: $request failed." }
}
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ internal class QueryThreadsMutableState(
private var _loadingMore: MutableStateFlow<Boolean>? = MutableStateFlow(false)
private var _next: MutableStateFlow<String?>? = MutableStateFlow(null)
private var _unseenThreadIds: MutableStateFlow<Set<String>>? = MutableStateFlow(emptySet())
private var _loadingError: MutableStateFlow<Boolean>? = MutableStateFlow(false)

/**
* Exposes a read-only map of the threads.
Expand All @@ -56,6 +57,7 @@ internal class QueryThreadsMutableState(
override val loadingMore: StateFlow<Boolean> = _loadingMore!!
override val next: StateFlow<String?> = _next!!
override val unseenThreadIds: StateFlow<Set<String>> = _unseenThreadIds!!
override val loadingError: StateFlow<Boolean> = _loadingError!!

/**
* Updates the loading state. Will be true only during the initial load, or during a full reload.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -190,5 +201,6 @@ internal class QueryThreadsMutableState(
_loadingMore = null
_next = null
_unseenThreadIds = null
_loadingError = null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
Loading
Loading