diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 2516e3ae86c..32a91c0ce0d 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -8,7 +8,7 @@ concurrency: jobs: base-android-ci: - uses: GetStream/stream-build-conventions-android/.github/workflows/android-ci.yml@v0.7.1 + uses: GetStream/stream-build-conventions-android/.github/workflows/android-ci.yml@v0.9.0 secrets: inherit detekt: @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4.2.2 - - uses: GetStream/stream-build-conventions-android/.github/actions/setup-gradle@v0.7.1 + - uses: GetStream/stream-build-conventions-android/.github/actions/setup-gradle@v0.9.0 - name: Detekt run: ./gradlew detekt diff --git a/.github/workflows/pr-cleanup.yaml b/.github/workflows/pr-cleanup.yaml index 9f5a4c8835c..9edb0bf7014 100644 --- a/.github/workflows/pr-cleanup.yaml +++ b/.github/workflows/pr-cleanup.yaml @@ -12,5 +12,5 @@ permissions: jobs: pr-clean-stale: - uses: GetStream/stream-build-conventions-android/.github/workflows/pr-clean-stale.yaml@v0.6.1 + uses: GetStream/stream-build-conventions-android/.github/workflows/pr-clean-stale.yaml@v0.9.0 secrets: inherit diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index fe853591e85..f34fc42a089 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -15,5 +15,5 @@ concurrency: jobs: pr-checklist: - uses: GetStream/stream-build-conventions-android/.github/workflows/pr-quality.yml@v0.7.1 + uses: GetStream/stream-build-conventions-android/.github/workflows/pr-quality.yml@v0.9.0 secrets: inherit diff --git a/.github/workflows/publish-new-version.yml b/.github/workflows/publish-new-version.yml index 0b301637876..b8feff80dbf 100644 --- a/.github/workflows/publish-new-version.yml +++ b/.github/workflows/publish-new-version.yml @@ -30,7 +30,7 @@ jobs: permissions: contents: write needs: pre_release_check - uses: GetStream/stream-build-conventions-android/.github/workflows/release.yml@v0.8.0 + uses: GetStream/stream-build-conventions-android/.github/workflows/release.yml@v0.9.0 with: bump: ${{ inputs.bump }} secrets: diff --git a/.github/workflows/sdk-size-checks.yml b/.github/workflows/sdk-size-checks.yml index 390bd2de3b6..8c8c4afb129 100644 --- a/.github/workflows/sdk-size-checks.yml +++ b/.github/workflows/sdk-size-checks.yml @@ -9,7 +9,7 @@ concurrency: jobs: compare-sdk-sizes: - uses: GetStream/stream-build-conventions-android/.github/workflows/sdk-size-checks.yml@v0.6.1 + uses: GetStream/stream-build-conventions-android/.github/workflows/sdk-size-checks.yml@v0.9.0 with: modules: "stream-chat-android-client stream-chat-android-ui-components stream-chat-android-compose" metrics-project: "stream-chat-android-metrics" diff --git a/.github/workflows/sdk-size-updates.yml b/.github/workflows/sdk-size-updates.yml index 4e7ef791cdf..7d09bea25b5 100644 --- a/.github/workflows/sdk-size-updates.yml +++ b/.github/workflows/sdk-size-updates.yml @@ -13,7 +13,7 @@ concurrency: jobs: update-sdk-sizes: - uses: GetStream/stream-build-conventions-android/.github/workflows/sdk-size-updates.yml@v0.6.1 + uses: GetStream/stream-build-conventions-android/.github/workflows/sdk-size-updates.yml@v0.9.0 with: modules: "stream-chat-android-client stream-chat-android-ui-components stream-chat-android-compose" metrics-project: "stream-chat-android-metrics" diff --git a/README.md b/README.md index 26cc1cccb62..5a6b8b4ae1b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ![stream-chat-android-client](https://img.shields.io/badge/stream--chat--android--client-5.25%20MB-lightgreen) ![stream-chat-android-ui-components](https://img.shields.io/badge/stream--chat--android--ui--components-10.62%20MB-lightgreen) -![stream-chat-android-compose](https://img.shields.io/badge/stream--chat--android--compose-12.84%20MB-lightgreen) +![stream-chat-android-compose](https://img.shields.io/badge/stream--chat--android--compose-12.85%20MB-lightgreen) diff --git a/build.gradle.kts b/build.gradle.kts index f5a6512f423..12475416f46 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,8 @@ plugins { } streamProject { + repositoryName = "stream-chat-android" + spotless { ignoredModules = setOf("stream-chat-android-docs") } diff --git a/gradle.properties b/gradle.properties index c645f50ea37..0ff5ea597d8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,4 +10,4 @@ android.enableR8.fullMode=true android.suppressUnsupportedCompileSdk=34 # Project version -version=6.32.2 +version=6.35.0 diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index d4f0cffbe37..c7b26813029 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -1766,8 +1766,11 @@ internal constructor( next: String? = null, sort: QuerySorter? = null, ): Call { - if (offset != null && (sort != null || next != null)) { - return ErrorCall(userScope, Error.GenericError("Cannot specify offset with sort or next parameters")) + if (offset != null && next != null) { + return ErrorCall( + userScope, + Error.GenericError("Cannot use both offset and next values. Specify only one of these options."), + ) } return api.searchMessages( channelFilter = channelFilter, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DtoMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DtoMapping.kt index 39205468738..3439c354cf0 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DtoMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DtoMapping.kt @@ -16,10 +16,12 @@ package io.getstream.chat.android.client.api2.mapping +import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings import io.getstream.chat.android.ReadReceipts import io.getstream.chat.android.TypingIndicators import io.getstream.chat.android.client.api2.model.dto.AttachmentDto +import io.getstream.chat.android.client.api2.model.dto.DeliveryReceiptsDto import io.getstream.chat.android.client.api2.model.dto.DeviceDto import io.getstream.chat.android.client.api2.model.dto.PrivacySettingsDto import io.getstream.chat.android.client.api2.model.dto.ReadReceiptsDto @@ -207,6 +209,7 @@ internal class DtoMapping( internal fun PrivacySettings.toDto(): PrivacySettingsDto = PrivacySettingsDto( typing_indicators = typingIndicators?.toDto(), read_receipts = readReceipts?.toDto(), + delivery_receipts = deliveryReceipts?.toDto(), ) /** @@ -223,6 +226,13 @@ internal class DtoMapping( enabled = enabled, ) + /** + * Maps the domain [DeliveryReceipts] model to a network [DeliveryReceiptsDto] model. + */ + internal fun DeliveryReceipts.toDto(): DeliveryReceiptsDto = DeliveryReceiptsDto( + enabled = enabled, + ) + /** * Maps the domain [User] model to a network [UpstreamUserDto] model. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DeleteMessageListenerDatabase.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DeleteMessageListenerDatabase.kt index 5782ff4d4dd..59de6509043 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DeleteMessageListenerDatabase.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DeleteMessageListenerDatabase.kt @@ -16,16 +16,14 @@ package io.getstream.chat.android.client.internal.offline.plugin.listener.internal -import io.getstream.chat.android.client.errors.cause.MessageModerationDeletedException import io.getstream.chat.android.client.extensions.internal.users import io.getstream.chat.android.client.persistance.repository.MessageRepository import io.getstream.chat.android.client.persistance.repository.UserRepository import io.getstream.chat.android.client.plugin.listeners.DeleteMessageListener import io.getstream.chat.android.client.setup.state.ClientState -import io.getstream.chat.android.client.utils.message.isModerationError +import io.getstream.chat.android.client.utils.message.shouldDeleteRemote import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.SyncStatus -import io.getstream.result.Error import io.getstream.result.Result import java.util.Date @@ -45,24 +43,19 @@ internal class DeleteMessageListenerDatabase( * @param messageId The message id to be deleted. */ override suspend fun onMessageDeletePrecondition(messageId: String): Result { - return messageRepository.selectMessage(messageId)?.let { message -> - val currentUserId = clientState.user.value?.id - val isModerationFailed = message.isModerationError(currentUserId) - - if (isModerationFailed) { - messageRepository.deleteChannelMessage(message) - Result.Failure( - Error.ThrowableError( - message = "Message with failed moderation has been deleted locally: $messageId", - cause = MessageModerationDeletedException( - "Message with failed moderation has been deleted locally: $messageId", - ), - ), - ) - } else { - Result.Success(Unit) - } - } ?: Result.Success(Unit) + val localMessage = messageRepository.selectMessage(messageId) + val currentUserId = clientState.user.value?.id + // We don't have the message locally, we must attempt to delete the message remotely + if (localMessage == null) { + return Result.Success(Unit) + } + // Check if the message is local-only (if attempting remote delete should be skipped + val shouldDeleteRemote = localMessage.shouldDeleteRemote(currentUserId) + if (shouldDeleteRemote is Result.Failure) { + // Delete the message ONLY locally + messageRepository.deleteChannelMessage(localMessage) + } + return shouldDeleteRemote } /** diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DeleteMessageListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DeleteMessageListenerState.kt index b0ce765d47e..5fbb80fee0f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DeleteMessageListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DeleteMessageListenerState.kt @@ -17,15 +17,13 @@ package io.getstream.chat.android.client.internal.state.plugin.listener.internal import io.getstream.chat.android.client.api.state.GlobalState -import io.getstream.chat.android.client.errors.cause.MessageModerationDeletedException import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry import io.getstream.chat.android.client.plugin.listeners.DeleteMessageListener import io.getstream.chat.android.client.setup.state.ClientState -import io.getstream.chat.android.client.utils.message.isModerationError +import io.getstream.chat.android.client.utils.message.shouldDeleteRemote import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.SyncStatus -import io.getstream.result.Error import io.getstream.result.Result import java.util.Date @@ -45,25 +43,19 @@ internal class DeleteMessageListenerState( * @param messageId The message id to be deleted. */ override suspend fun onMessageDeletePrecondition(messageId: String): Result { - val channelLogic: ChannelLogic? = logic.channelFromMessageId(messageId) - - return channelLogic?.getMessage(messageId)?.let { message -> - val isModerationFailed = message.isModerationError(clientState.user.value?.id) - - if (isModerationFailed) { - deleteMessage(message) - Result.Failure( - Error.ThrowableError( - message = "Message with failed moderation has been deleted locally: $messageId", - cause = MessageModerationDeletedException( - "Message with failed moderation has been deleted locally: $messageId", - ), - ), - ) - } else { - Result.Success(Unit) - } - } ?: Result.Success(Unit) + val localMessage = logic.channelFromMessageId(messageId)?.getMessage(messageId) + val currentUserId = clientState.user.value?.id + // We don't have the message locally, we must attempt to delete the message remotely + if (localMessage == null) { + return Result.Success(Unit) + } + // Check if the message is local-only (if attempting remote delete should be skipped) + val shouldDeleteRemote = localMessage.shouldDeleteRemote(currentUserId) + if (shouldDeleteRemote is Result.Failure) { + // Delete the message ONLY locally + deleteMessage(localMessage) + } + return shouldDeleteRemote } /** @@ -75,19 +67,13 @@ internal class DeleteMessageListenerState( val channelLogic: ChannelLogic? = logic.channelFromMessageId(messageId) channelLogic?.getMessage(messageId)?.let { message -> - val isModerationFailed = message.isModerationError(clientState.user.value?.id) + val networkAvailable = clientState.isNetworkAvailable + val messageToBeDeleted = message.copy( + deletedAt = Date(), + syncStatus = if (!networkAvailable) SyncStatus.SYNC_NEEDED else SyncStatus.IN_PROGRESS, + ) - if (isModerationFailed) { - deleteMessage(message) - } else { - val networkAvailable = clientState.isNetworkAvailable - val messageToBeDeleted = message.copy( - deletedAt = Date(), - syncStatus = if (!networkAvailable) SyncStatus.SYNC_NEEDED else SyncStatus.IN_PROGRESS, - ) - - updateMessage(messageToBeDeleted) - } + updateMessage(messageToBeDeleted) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 6254dfbaa09..206f7de60a9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -32,8 +32,6 @@ import io.getstream.log.taggedLogger import io.getstream.result.Result import kotlinx.coroutines.flow.StateFlow -private const val MESSAGE_LIMIT = 1 -private const val MEMBER_LIMIT = 30 private const val INITIAL_CHANNEL_OFFSET = 0 private const val CHANNEL_LIMIT = 30 @@ -155,13 +153,16 @@ internal class QueryChannelsLogic( */ internal suspend fun queryFirstPage(): Result> { logger.d { "[queryFirstPage] no args" } + val currentRequest = queryChannelsStateLogic.getState().currentRequest.value + val messageLimit = currentRequest?.messageLimit + val memberLimit = currentRequest?.memberLimit val request = QueryChannelsRequest( filter = filter, offset = INITIAL_CHANNEL_OFFSET, limit = CHANNEL_LIMIT, querySort = sort, - messageLimit = MESSAGE_LIMIT, - memberLimit = MEMBER_LIMIT, + messageLimit = messageLimit, + memberLimit = memberLimit, ) queryChannelsStateLogic.setCurrentRequest(request) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/SocketFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/SocketFactory.kt index 196fa19a1ee..582b1238d2f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/SocketFactory.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/SocketFactory.kt @@ -107,21 +107,14 @@ internal class SocketFactory( private fun PrivacySettings.reducePrivacySettings(): Map = mutableMapOf() .apply { - typingIndicators?.also { - put( - "typing_indicators", - mapOf( - "enabled" to it.enabled, - ), - ) + typingIndicators?.let { + put("typing_indicators", mapOf("enabled" to it.enabled)) } - readReceipts?.also { - put( - "read_receipts", - mapOf( - "enabled" to it.enabled, - ), - ) + deliveryReceipts?.let { + put("delivery_receipts", mapOf("enabled" to it.enabled)) + } + readReceipts?.let { + put("read_receipts", mapOf("enabled" to it.enabled)) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/MessageUtils.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/MessageUtils.kt index f0791349f23..2e9b6baf803 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/MessageUtils.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/MessageUtils.kt @@ -20,17 +20,14 @@ import io.getstream.chat.android.client.extensions.internal.hasPendingAttachment import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageType -import java.util.regex.Pattern - -private val COMMAND_PATTERN = Pattern.compile("^/[a-z]*$") /** * Updates the type of the [Message] based on its content. * - * If the message contains a command or has attachments to upload, the type will be [MessageType.EPHEMERAL]. + * If the message has attachments to upload, the type will be [MessageType.EPHEMERAL] (local-only - after the + * attachments are uploaded, the type is changed to [MessageType.REGULAR]). * If the message is a system message, the type will be [MessageType.SYSTEM]. - * Otherwise, the type will be [MessageType.REGULAR], as we cannot send messages which are not regular, ephemeral, or - * system. + * Otherwise, the type will be [MessageType.REGULAR], as we cannot send messages which are not regular or system. * * @param message The message to update. */ @@ -39,7 +36,7 @@ public fun getMessageType(message: Message): String { val hasAttachments = message.attachments.isNotEmpty() val hasAttachmentsToUpload = message.hasPendingAttachments() - return if (COMMAND_PATTERN.matcher(message.text).find() || (hasAttachments && hasAttachmentsToUpload)) { + return if (hasAttachments && hasAttachmentsToUpload) { MessageType.EPHEMERAL } else if (message.type == MessageType.SYSTEM) { MessageType.SYSTEM diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt index 6ff38793da3..c42d10af9e7 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.utils.message +import io.getstream.chat.android.client.errors.cause.MessageModerationDeletedException import io.getstream.chat.android.client.extensions.getCreatedAtOrNull import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.core.utils.date.after @@ -29,6 +30,8 @@ import io.getstream.chat.android.models.MessageModerationAction import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.ModerationAction import io.getstream.chat.android.models.SyncStatus +import io.getstream.result.Error +import io.getstream.result.Result import java.util.UUID private const val ITEM_COUNT_OF_TWO: Int = 2 @@ -204,6 +207,40 @@ public fun Message.isModerationFlag(): Boolean = public fun Message.isModerationError(currentUserId: String?): Boolean = isMine(currentUserId) && (isError() && isModerationBounce()) +/** + * Checks whether we should attempt to delete the message remotely. + * + * @param currentUserId The ID of the currently logged in user. + * @return [Result.Success] if remote delete should be attempted, [Result.Failure] if the message should be deleted only + * locally. + */ +@Suppress("ComplexCondition") +@InternalStreamChatApi +public fun Message.shouldDeleteRemote(currentUserId: String?): Result { + // 1. Moderation action = 'bounce' - not persisted on server, delete only locally + // Note: handled separately from pt. 2/3 for backwards-compatibility + if (isModerationError(currentUserId)) { + val error = Error.ThrowableError( + message = "Message with failed moderation has been deleted locally: $id", + cause = MessageModerationDeletedException( + "Message with failed moderation has been deleted locally: $id", + ), + ) + return Result.Failure(error) + } + // 2. type = 'error'/'ephemeral' - not persisted on server, delete only locally + // 3. syncStatus = 'IN_PROGRESS'/`FAILED_PERMANENTLY` - not persisted on server, delete only locally + if (isError() || isEphemeral() || + syncStatus == SyncStatus.IN_PROGRESS || + syncStatus == SyncStatus.FAILED_PERMANENTLY + ) { + val error = Error.GenericError("Message is local-only, don't call DeleteMessage API") + return Result.Failure(error) + } + // 4. Any other case, attempt to delete the message remotely + return Result.Success(Unit) +} + /** * Ensures the message has an id. * If the message doesn't have an id, a unique message id is generated (lowercase UUID). diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGeneralApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGeneralApiTests.kt index f2c9741c719..bd26c8a470e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGeneralApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGeneralApiTests.kt @@ -232,7 +232,7 @@ internal class ChatClientGeneralApiTests : BaseChatClientTest() { } @Test - fun searchMessagesWithoutOffsetWithSortAndNextSuccess() = runTest { + fun searchMessagesWithNextAndSort() = runTest { // given val channelFilter = Filters.neutral() val messageFilter = Filters.neutral() @@ -266,7 +266,7 @@ internal class ChatClientGeneralApiTests : BaseChatClientTest() { // when val result = sut.searchMessages(channelFilter, messageFilter, offset, limit, next, sort).await() // then - verifyGenericError(result, "Cannot specify offset with sort or next parameters") + verifySuccess(result, response) } @Test @@ -285,7 +285,7 @@ internal class ChatClientGeneralApiTests : BaseChatClientTest() { // when val result = sut.searchMessages(channelFilter, messageFilter, offset, limit, next, sort).await() // then - verifyGenericError(result, "Cannot specify offset with sort or next parameters") + verifyGenericError(result, "Cannot use both offset and next values. Specify only one of these options.") } @Test diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DtoMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DtoMappingTest.kt index 0df358ab54c..d86fe3f1454 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DtoMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DtoMappingTest.kt @@ -16,10 +16,12 @@ package io.getstream.chat.android.client.api2.mapping +import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings import io.getstream.chat.android.ReadReceipts import io.getstream.chat.android.TypingIndicators import io.getstream.chat.android.client.api2.model.dto.AttachmentDto +import io.getstream.chat.android.client.api2.model.dto.DeliveryReceiptsDto import io.getstream.chat.android.client.api2.model.dto.DeviceDto import io.getstream.chat.android.client.api2.model.dto.PrivacySettingsDto import io.getstream.chat.android.client.api2.model.dto.ReadReceiptsDto @@ -238,12 +240,31 @@ internal class DtoMappingTest { val privacySettings = PrivacySettings( typingIndicators = TypingIndicators(enabled = true), readReceipts = ReadReceipts(enabled = false), + deliveryReceipts = DeliveryReceipts(enabled = false), ) val mapping = Fixture().get() val dto = with(mapping) { privacySettings.toDto() } val expected = PrivacySettingsDto( typing_indicators = TypingIndicatorsDto(enabled = true), read_receipts = ReadReceiptsDto(enabled = false), + delivery_receipts = DeliveryReceiptsDto(enabled = false), + ) + dto shouldBeEqualTo expected + } + + @Test + fun `PrivacySettings with null deliveryReceipts is correctly mapped to Dto`() { + val privacySettings = PrivacySettings( + typingIndicators = TypingIndicators(enabled = true), + readReceipts = ReadReceipts(enabled = false), + deliveryReceipts = null, + ) + val mapping = Fixture().get() + val dto = with(mapping) { privacySettings.toDto() } + val expected = PrivacySettingsDto( + typing_indicators = TypingIndicatorsDto(enabled = true), + read_receipts = ReadReceiptsDto(enabled = false), + delivery_receipts = null, ) dto shouldBeEqualTo expected } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DeleteMessageListenerDatabaseTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DeleteMessageListenerDatabaseTest.kt index 186ea91795d..6e3fac9268f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DeleteMessageListenerDatabaseTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DeleteMessageListenerDatabaseTest.kt @@ -19,12 +19,19 @@ package io.getstream.chat.android.client.internal.offline.plugin.listener.intern import io.getstream.chat.android.client.persistance.repository.MessageRepository import io.getstream.chat.android.client.persistance.repository.UserRepository import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.models.ModerationAction import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomModeration +import io.getstream.chat.android.randomUser import io.getstream.result.Error import io.getstream.result.Result +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.argThat @@ -37,11 +44,16 @@ internal class DeleteMessageListenerDatabaseTest { private val clientState: ClientState = mock() - private val messageRepository: MessageRepository = mock() + private lateinit var messageRepository: MessageRepository private val userRepository: UserRepository = mock() - private val deleteMessageListenerState: DeleteMessageListenerDatabase = - DeleteMessageListenerDatabase(clientState, messageRepository, userRepository) + private lateinit var deleteMessageListenerState: DeleteMessageListenerDatabase + + @BeforeEach + fun setUp() { + messageRepository = mock() + deleteMessageListenerState = DeleteMessageListenerDatabase(clientState, messageRepository, userRepository) + } @Test fun `when internet is available, the message should be updated as in progress before the request`() = runTest { @@ -122,4 +134,134 @@ internal class DeleteMessageListenerDatabaseTest { }, ) } + + @Test + fun `onMessageDeletePrecondition when message not found locally should return Success`() = runTest { + val currentUser = randomUser() + + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + whenever(messageRepository.selectMessage(any())) doReturn null + + val result = deleteMessageListenerState.onMessageDeletePrecondition("unknown-message-id") + + assertTrue(result is Result.Success) + } + + @Test + fun `onMessageDeletePrecondition when message has moderation bounce should return Failure and delete from repo`() = + runTest { + val currentUser = randomUser() + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + user = currentUser, + type = MessageType.ERROR, + moderation = randomModeration(action = ModerationAction.bounce), + ) + + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + whenever(messageRepository.selectMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Failure) + verify(messageRepository).deleteChannelMessage(argThat { id == testMessage.id }) + } + + @Test + fun `onMessageDeletePrecondition when message is error type should return Failure and delete from repo`() = + runTest { + val currentUser = randomUser() + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.ERROR, + syncStatus = SyncStatus.COMPLETED, + ) + + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + whenever(messageRepository.selectMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Failure) + verify(messageRepository).deleteChannelMessage(argThat { id == testMessage.id }) + } + + @Test + fun `onMessageDeletePrecondition when message is ephemeral type should return Failure and delete from repo`() = + runTest { + val currentUser = randomUser() + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.EPHEMERAL, + syncStatus = SyncStatus.COMPLETED, + ) + + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + whenever(messageRepository.selectMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Failure) + verify(messageRepository).deleteChannelMessage(argThat { id == testMessage.id }) + } + + @Test + fun `onMessageDeletePrecondition when message has SYNC_NEEDED should return Success`() = + runTest { + val currentUser = randomUser() + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.REGULAR, + syncStatus = SyncStatus.SYNC_NEEDED, + ) + + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + whenever(messageRepository.selectMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Success) + } + + @Test + fun `onMessageDeletePrecondition when message has FAILED_PERMANENTLY should return Failure and delete from repo`() = + runTest { + val currentUser = randomUser() + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.REGULAR, + syncStatus = SyncStatus.FAILED_PERMANENTLY, + ) + + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + whenever(messageRepository.selectMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Failure) + verify(messageRepository).deleteChannelMessage(argThat { id == testMessage.id }) + } + + @Test + fun `onMessageDeletePrecondition when message is COMPLETED regular should return Success`() = runTest { + val currentUser = randomUser() + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.REGULAR, + syncStatus = SyncStatus.COMPLETED, + ) + + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + whenever(messageRepository.selectMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Success) + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DeleteMessageListenerStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DeleteMessageListenerStateTest.kt index bb0a0039bc4..4436b40876c 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DeleteMessageListenerStateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DeleteMessageListenerStateTest.kt @@ -21,14 +21,18 @@ import io.getstream.chat.android.client.internal.state.plugin.logic.channel.inte import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry import io.getstream.chat.android.client.internal.state.plugin.logic.querythreads.internal.QueryThreadsLogic import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.models.ModerationAction import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomModeration import io.getstream.chat.android.randomUser import io.getstream.result.Error import io.getstream.result.Result import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.argThat @@ -164,4 +168,139 @@ internal class DeleteMessageListenerStateTest { }, ) } + + @Test + fun `onMessageDeletePrecondition when message not found locally should return Success`() = runTest { + whenever(logicRegistry.channelFromMessageId(any())) doReturn null + + val result = deleteMessageListenerState.onMessageDeletePrecondition("unknown-message-id") + + assertTrue(result is Result.Success) + } + + @Test + fun `onMessageDeletePrecondition when message has moderation bounce should return Failure and delete locally`() = + runTest { + val currentUser = randomUser() + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + user = currentUser, + type = MessageType.ERROR, + moderation = randomModeration(action = ModerationAction.bounce), + ) + + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + whenever(logicRegistry.channelFromMessageId(any())) doReturn channelLogic + whenever(logicRegistry.channelFromMessage(any())) doReturn channelLogic + whenever(logicRegistry.getActiveQueryThreadsLogic()) doReturn activeThreadsLogic + whenever(logicRegistry.threadFromMessage(any())) doReturn null + whenever(channelLogic.getMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Failure) + verify(channelLogic).deleteMessage(argThat { id == testMessage.id }) + verify(threadsLogic).deleteMessage(argThat { id == testMessage.id }) + } + + @Test + fun `onMessageDeletePrecondition when message is error type should return Failure and delete locally`() = runTest { + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.ERROR, + syncStatus = SyncStatus.COMPLETED, + ) + + whenever(logicRegistry.channelFromMessageId(any())) doReturn channelLogic + whenever(logicRegistry.channelFromMessage(any())) doReturn channelLogic + whenever(logicRegistry.getActiveQueryThreadsLogic()) doReturn activeThreadsLogic + whenever(logicRegistry.threadFromMessage(any())) doReturn null + whenever(channelLogic.getMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Failure) + verify(channelLogic).deleteMessage(argThat { id == testMessage.id }) + } + + @Test + fun `onMessageDeletePrecondition when message is ephemeral type should return Failure and delete locally`() = + runTest { + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.EPHEMERAL, + syncStatus = SyncStatus.COMPLETED, + ) + + whenever(logicRegistry.channelFromMessageId(any())) doReturn channelLogic + whenever(logicRegistry.channelFromMessage(any())) doReturn channelLogic + whenever(logicRegistry.getActiveQueryThreadsLogic()) doReturn activeThreadsLogic + whenever(logicRegistry.threadFromMessage(any())) doReturn null + whenever(channelLogic.getMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Failure) + verify(channelLogic).deleteMessage(argThat { id == testMessage.id }) + } + + @Test + fun `onMessageDeletePrecondition when message has SYNC_NEEDED should return Success`() = + runTest { + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.REGULAR, + syncStatus = SyncStatus.SYNC_NEEDED, + ) + + whenever(logicRegistry.channelFromMessageId(any())) doReturn channelLogic + whenever(channelLogic.getMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Success) + } + + @Test + fun `onMessageDeletePrecondition when message has FAILED_PERMANENTLY should return Failure and delete locally`() = + runTest { + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.REGULAR, + syncStatus = SyncStatus.FAILED_PERMANENTLY, + ) + + whenever(logicRegistry.channelFromMessageId(any())) doReturn channelLogic + whenever(logicRegistry.channelFromMessage(any())) doReturn channelLogic + whenever(logicRegistry.getActiveQueryThreadsLogic()) doReturn activeThreadsLogic + whenever(logicRegistry.threadFromMessage(any())) doReturn null + whenever(channelLogic.getMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Failure) + verify(channelLogic).deleteMessage(argThat { id == testMessage.id }) + } + + @Test + fun `onMessageDeletePrecondition when message is COMPLETED regular should return Success`() = runTest { + val testMessage = randomMessage( + id = "msg-1", + cid = randomCID(), + type = MessageType.REGULAR, + syncStatus = SyncStatus.COMPLETED, + ) + + whenever(logicRegistry.channelFromMessageId(any())) doReturn channelLogic + whenever(channelLogic.getMessage(any())) doReturn testMessage + + val result = deleteMessageListenerState.onMessageDeletePrecondition(testMessage.id) + + assertTrue(result is Result.Success) + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index f9118be5597..206dd1f294c 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.state.QueryChannelsState import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest @@ -26,6 +27,7 @@ import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomChannel import io.getstream.chat.android.test.TestCoroutineRule +import io.getstream.chat.android.test.asCall import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -65,6 +67,7 @@ internal class QueryChannelsLogicTest { whenever(queryChannelsStateLogic.getState()) doReturn queryChannelsState whenever(queryChannelsState.recoveryNeeded) doReturn MutableStateFlow(false) + whenever(queryChannelsState.currentRequest) doReturn MutableStateFlow(null) whenever(queryChannelsStateLogic.getQuerySpecs()) doReturn queryChannelsSpec logic = QueryChannelsLogic( @@ -225,4 +228,59 @@ internal class QueryChannelsLogicTest { verify(queryChannelsStateLogic).addChannelsState(cachedChannels) verify(queryChannelsDatabaseLogic).insertQueryChannels(queryChannelsSpec) } + + // region queryFirstPage + + @Test + fun `queryFirstPage uses null messageLimit and memberLimit when no prior request exists`() = runTest { + // Given - currentRequest is null (default from setUp) + whenever(client.queryChannelsInternal(any())) + .thenReturn(emptyList().asCall()) + + // When + logic.queryFirstPage() + + // Then + val expectedRequest = QueryChannelsRequest( + filter = filter, + offset = 0, + limit = 30, + querySort = sort, + messageLimit = null, + memberLimit = null, + ) + verify(client).queryChannelsInternal(expectedRequest) + } + + @Test + fun `queryFirstPage uses messageLimit and memberLimit from prior request`() = runTest { + // Given + val priorRequest = QueryChannelsRequest( + filter = filter, + offset = 0, + limit = 30, + querySort = sort, + messageLimit = 5, + memberLimit = 50, + ) + whenever(queryChannelsState.currentRequest) doReturn MutableStateFlow(priorRequest) + whenever(client.queryChannelsInternal(any())) + .thenReturn(emptyList().asCall()) + + // When + logic.queryFirstPage() + + // Then + val expectedRequest = QueryChannelsRequest( + filter = filter, + offset = 0, + limit = 30, + querySort = sort, + messageLimit = 5, + memberLimit = 50, + ) + verify(client).queryChannelsInternal(expectedRequest) + } + + // endregion } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/SocketFactoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/SocketFactoryTest.kt index 41002a9d56c..3509bf8599e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/SocketFactoryTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/SocketFactoryTest.kt @@ -16,6 +16,10 @@ package io.getstream.chat.android.client.socket +import io.getstream.chat.android.DeliveryReceipts +import io.getstream.chat.android.PrivacySettings +import io.getstream.chat.android.ReadReceipts +import io.getstream.chat.android.TypingIndicators import io.getstream.chat.android.client.parser.ChatParser import io.getstream.chat.android.client.parser2.ParserFactory import io.getstream.chat.android.client.token.FakeTokenManager @@ -94,7 +98,7 @@ internal class SocketFactoryTest { } @JvmStatic - @Suppress("MaxLineLength") + @Suppress("MaxLineLength", "LongMethod") fun arguments() = listOf( randomUser(image = randomString(), name = randomString(), language = randomString()).let { Arguments.of( @@ -138,26 +142,72 @@ internal class SocketFactoryTest { "${endpoint}connect?json=${buildMinimumUserJson("anon")}&api_key=$apiKey&X-Stream-Client=${headersUtil.buildSdkTrackingHeaders()}&stream-auth-type=anonymous", ) }, + randomUser( + image = randomString(), + name = randomString(), + language = randomString(), + privacySettings = PrivacySettings( + typingIndicators = TypingIndicators(enabled = false), + deliveryReceipts = DeliveryReceipts(enabled = false), + readReceipts = ReadReceipts(enabled = true), + ), + ).let { + Arguments.of( + false, + SocketFactory.ConnectionConf.UserConnectionConf(endpoint, apiKey, it), + "${endpoint}connect?json=${buildFullUserJson(it, it.id)}&api_key=$apiKey&X-Stream-Client=${headersUtil.buildSdkTrackingHeaders()}&authorization=$token&stream-auth-type=jwt", + ) + }, + randomUser( + image = randomString(), + name = randomString(), + language = randomString(), + privacySettings = PrivacySettings( + typingIndicators = null, + deliveryReceipts = DeliveryReceipts(enabled = false), + readReceipts = null, + ), + ).let { + Arguments.of( + false, + SocketFactory.ConnectionConf.UserConnectionConf(endpoint, apiKey, it), + "${endpoint}connect?json=${buildFullUserJson(it, it.id)}&api_key=$apiKey&X-Stream-Client=${headersUtil.buildSdkTrackingHeaders()}&authorization=$token&stream-auth-type=jwt", + ) + }, ) private fun buildMinimumUserJson(userId: String): String = encode( defaultMap(userId, mapOf("id" to userId)), ) - private fun buildFullUserJson(user: User, userId: String): String = encode( - defaultMap( - userId, - mapOf( - "id" to userId, - "role" to user.role, - "banned" to user.isBanned, - "invisible" to user.isInvisible, - "language" to user.language, - "image" to user.image, - "name" to user.name, - ) + user.extraData, - ), - ) + private fun buildFullUserJson(user: User, userId: String): String { + val ps = user.privacySettings + return encode( + defaultMap( + userId, + linkedMapOf( + "id" to userId, + "role" to user.role, + "banned" to user.isBanned, + "invisible" to user.isInvisible, + ) + if (ps != null) { + mapOf( + "privacy_settings" to listOfNotNull( + ps.typingIndicators?.let { "typing_indicators" to mapOf("enabled" to it.enabled) }, + ps.deliveryReceipts?.let { "delivery_receipts" to mapOf("enabled" to it.enabled) }, + ps.readReceipts?.let { "read_receipts" to mapOf("enabled" to it.enabled) }, + ).toMap(), + ) + } else { + emptyMap() + } + mapOf( + "language" to user.language, + "image" to user.image, + "name" to user.name, + ) + user.extraData, + ), + ) + } private fun defaultMap(userId: String, userDetails: Map): Map = mapOf( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/MessageUtilsTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/MessageUtilsTest.kt index 596fdd3647c..6eda992e991 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/MessageUtilsTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/MessageUtilsTest.kt @@ -24,13 +24,6 @@ import org.junit.jupiter.api.Test internal class MessageUtilsTest { - @Test - fun testCommandMessage() { - val message = Message(text = "/command") - val updatedMessageType = getMessageType(message) - updatedMessageType `should be equal to` MessageType.EPHEMERAL - } - @Test fun testMessageWithAttachmentsInUploadStateIdle() { val message = Message( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt index 81dcaa1048c..6e6c9987403 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt @@ -31,12 +31,14 @@ import io.getstream.chat.android.randomModeration import io.getstream.chat.android.randomPoll import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser +import io.getstream.result.Result import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.util.Date @@ -581,6 +583,88 @@ internal class MessageUtilsTest { Assertions.assertTrue(UuidRegex.matches(draftWithId.id)) } + @Test + fun `shouldDeleteRemote should return Failure for moderation bounce message`() { + val currentUserId = randomString() + val message = randomMessage( + user = randomUser(id = currentUserId), + type = MessageType.ERROR, + moderation = randomModeration(action = ModerationAction.bounce), + ) + val result = message.shouldDeleteRemote(currentUserId) + assertTrue(result is Result.Failure) + } + + @Test + fun `shouldDeleteRemote should return Failure for error message type`() { + val message = randomMessage( + type = MessageType.ERROR, + syncStatus = SyncStatus.COMPLETED, + ) + val result = message.shouldDeleteRemote(randomString()) + assertTrue(result is Result.Failure) + } + + @Test + fun `shouldDeleteRemote should return Failure for ephemeral message type`() { + val message = randomMessage( + type = MessageType.EPHEMERAL, + syncStatus = SyncStatus.COMPLETED, + ) + val result = message.shouldDeleteRemote(randomString()) + assertTrue(result is Result.Failure) + } + + @Test + fun `shouldDeleteRemote should return Failure for IN_PROGRESS sync status`() { + val message = randomMessage( + type = MessageType.REGULAR, + syncStatus = SyncStatus.IN_PROGRESS, + ) + val result = message.shouldDeleteRemote(randomString()) + assertTrue(result is Result.Failure) + } + + @Test + fun `shouldDeleteRemote should return Failure for FAILED_PERMANENTLY sync status`() { + val message = randomMessage( + type = MessageType.REGULAR, + syncStatus = SyncStatus.FAILED_PERMANENTLY, + ) + val result = message.shouldDeleteRemote(randomString()) + assertTrue(result is Result.Failure) + } + + @Test + fun `shouldDeleteRemote should return Success for COMPLETED regular message`() { + val message = randomMessage( + type = MessageType.REGULAR, + syncStatus = SyncStatus.COMPLETED, + ) + val result = message.shouldDeleteRemote(randomString()) + assertTrue(result is Result.Success) + } + + @Test + fun `shouldDeleteRemote should return Success for AWAITING_ATTACHMENTS regular message`() { + val message = randomMessage( + type = MessageType.REGULAR, + syncStatus = SyncStatus.AWAITING_ATTACHMENTS, + ) + val result = message.shouldDeleteRemote(randomString()) + assertTrue(result is Result.Success) + } + + @Test + fun `shouldDeleteRemote should return Success for SYNC_NEEDED sync status`() { + val message = randomMessage( + type = MessageType.REGULAR, + syncStatus = SyncStatus.SYNC_NEEDED, + ) + val result = message.shouldDeleteRemote(randomString()) + assertTrue(result is Result.Success) + } + private companion object { // Regex matching lowercase UUID format 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 680e226b11f..ba783486be5 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -942,7 +942,7 @@ public final class io/getstream/chat/android/compose/ui/channel/info/GroupChanne } public final class io/getstream/chat/android/compose/ui/channels/ChannelsScreenKt { - public static final fun ChannelsScreen (Lio/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory;Ljava/lang/String;Ljava/lang/String;ZLio/getstream/chat/android/compose/ui/channels/SearchMode;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;III)V + public static final fun ChannelsScreen (Lio/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory;Ljava/lang/String;Ljava/lang/String;ZLio/getstream/chat/android/compose/ui/channels/SearchMode;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/Composer;III)V } public final class io/getstream/chat/android/compose/ui/channels/SearchMode : java/lang/Enum { @@ -2428,7 +2428,7 @@ public final class io/getstream/chat/android/compose/ui/messages/list/MessageLis } public final class io/getstream/chat/android/compose/ui/messages/list/MessagesKt { - public static final fun Messages (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/layout/Arrangement$Vertical;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V + public static final fun Messages (Lio/getstream/chat/android/ui/common/state/messages/list/MessageListState;Lio/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState;Landroidx/compose/foundation/layout/Arrangement$Vertical;Landroidx/compose/foundation/layout/Arrangement$Vertical;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;III)V } public final class io/getstream/chat/android/compose/ui/messages/list/MessagesLazyListState { @@ -3937,7 +3937,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatTheme { } public final class io/getstream/chat/android/compose/ui/theme/ChatThemeKt { - public static final fun ChatTheme (ZLio/getstream/chat/android/compose/ui/theme/ChatConfig;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Typography;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionResolver;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;ZLio/getstream/chat/android/ui/common/helper/DateFormatter;Lio/getstream/chat/android/ui/common/helper/TimeProvider;Lio/getstream/chat/android/ui/common/helper/DurationFormatter;Lio/getstream/chat/android/ui/common/utils/ChannelNameFormatter;Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter;Lio/getstream/chat/android/compose/ui/util/SearchResultNameFormatter;Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Lio/getstream/chat/android/ui/common/helper/ImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator;Lio/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor;Lio/getstream/chat/android/ui/common/helper/ImageAssetTransformer;Lio/getstream/chat/android/compose/ui/util/MessageAlignmentProvider;Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme;Lio/getstream/chat/android/compose/ui/theme/ChannelOptionsTheme;Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIII)V + public static final fun ChatTheme (ZLio/getstream/chat/android/compose/ui/theme/ChatConfig;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Typography;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionResolver;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;ZLio/getstream/chat/android/ui/common/helper/DateFormatter;Lio/getstream/chat/android/ui/common/helper/TimeProvider;Lio/getstream/chat/android/ui/common/helper/DurationFormatter;Lio/getstream/chat/android/ui/common/utils/ChannelNameFormatter;Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter;Lio/getstream/chat/android/compose/ui/util/SearchResultNameFormatter;Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Lio/getstream/chat/android/ui/common/helper/ImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator;Lio/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor;Lio/getstream/chat/android/ui/common/helper/ImageAssetTransformer;Lio/getstream/chat/android/compose/ui/util/MessageAlignmentProvider;Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme;Lio/getstream/chat/android/compose/ui/theme/ChannelOptionsTheme;Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIII)V public static final fun getLocalChatConfig ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalComponentFactory ()Landroidx/compose/runtime/ProvidableCompositionLocal; } @@ -6779,12 +6779,17 @@ public final class io/getstream/chat/android/compose/ui/util/SearchResultNameFor public abstract interface class io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory { public static final field Companion Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory$Companion; public abstract fun imageLoader (Landroid/content/Context;)Lcoil3/ImageLoader; + public abstract fun imageLoader (Landroid/content/Context;Ljava/util/List;)Lcoil3/ImageLoader; } public final class io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory$Companion { public final fun defaultFactory ()Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory; } +public final class io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory$DefaultImpls { + public static fun imageLoader (Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Landroid/content/Context;Ljava/util/List;)Lcoil3/ImageLoader; +} + public final class io/getstream/chat/android/compose/ui/util/StreamImageLoaderProvidableCompositionLocal { public static final synthetic fun box-impl (Landroidx/compose/runtime/ProvidableCompositionLocal;)Lio/getstream/chat/android/compose/ui/util/StreamImageLoaderProvidableCompositionLocal; public static synthetic fun constructor-impl$default (Landroidx/compose/runtime/ProvidableCompositionLocal;ILkotlin/jvm/internal/DefaultConstructorMarker;)Landroidx/compose/runtime/ProvidableCompositionLocal; @@ -6932,8 +6937,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelIn public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel : androidx/lifecycle/ViewModel { public static final field $stable I - public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;JZLkotlinx/coroutines/flow/Flow;)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;JZLkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V public final fun blockUser (Ljava/lang/String;)V public final fun confirmPendingAction ()V @@ -6972,8 +6977,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { public static final field $stable I public fun ()V - public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;Z)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } @@ -7024,6 +7029,7 @@ public final class io/getstream/chat/android/compose/viewmodel/mentions/MentionL public final class io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel : androidx/lifecycle/ViewModel { public static final field $stable I + public fun (Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Lkotlinx/coroutines/flow/StateFlow;)V public fun (Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;)V public synthetic fun (Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getAttachments ()Ljava/util/List; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt index 317166619ac..8ca97bada2b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/ChannelsScreen.kt @@ -78,6 +78,8 @@ import io.getstream.chat.android.ui.common.state.channels.actions.ViewInfo * @param onChannelClick Handler for Channel item clicks. * @param onViewChannelInfoAction Handler for when the user selects the [ViewInfo] option for a [Channel]. * @param onBackPressed Handler for back press action. + * @param isBackPressEnabled Indicator if the default back handler is enabled. Set to `false` to fully disable the back + * handling and catch the back-press event in a [BackHandler] higher in the compose hierarchy. Default: `true`. */ @Composable @Suppress("LongMethod") @@ -94,6 +96,7 @@ public fun ChannelsScreen( onSearchMessageItemClick: (Message) -> Unit = {}, onViewChannelInfoAction: (Channel) -> Unit = {}, onBackPressed: () -> Unit = {}, + isBackPressEnabled: Boolean = true, ) { val listViewModel: ChannelListViewModel = viewModel( ChannelListViewModel::class.java, @@ -105,7 +108,7 @@ public fun ChannelsScreen( val user by listViewModel.user.collectAsState() val connectionState by listViewModel.connectionState.collectAsState() - BackHandler(enabled = true) { + BackHandler(enabled = isBackPressEnabled) { if (selectedChannel != null) { listViewModel.selectChannel(null) } else { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt index 99968df623f..2668d8ece13 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageContainer.kt @@ -199,18 +199,17 @@ public fun MessageContainer( val messageAlignment = ChatTheme.messageAlignmentProvider.provideMessageAlignment(messageItem) val description = stringResource(id = R.string.stream_compose_cd_message_item) - val isSwipable = ChatTheme.messageOptionsTheme.optionVisibility - .canReplyToMessage( - message = message, - ownCapabilities = messageItem.ownCapabilities, - ) + val optionVisibility = ChatTheme.messageOptionsTheme.optionVisibility + val isSwipeable = remember(message, messageItem.ownCapabilities, optionVisibility) { + optionVisibility.canReplyToMessage(message, messageItem.ownCapabilities) + } // Remember the message to ensure updated values are captured in the onReply lambda val replyMessage by rememberUpdatedState(message) SwipeToReply( modifier = modifier, onReply = { onReply(replyMessage) }, - isSwipeable = { isSwipable }, + isSwipeable = isSwipeable, ) { Box( modifier = Modifier @@ -790,7 +789,7 @@ public fun RegularMessageContent( * * @param modifier Modifier for styling. * @param onReply Handler when the user swipes to reply. - * @param isSwipeable Handler to determine if the message is swipeable. + * @param isSwipeable Indicator if swipe-to-reply is enabled. * @param content The swipeable content to show when not swiping to reply. */ @Suppress("LongMethod") @@ -798,7 +797,7 @@ public fun RegularMessageContent( private fun SwipeToReply( modifier: Modifier = Modifier, onReply: () -> Unit = {}, - isSwipeable: () -> Boolean = { true }, + isSwipeable: Boolean = true, content: @Composable () -> Unit, ) { var swipeToReplyWidth by remember { mutableFloatStateOf(0f) } @@ -834,8 +833,8 @@ private fun SwipeToReply( .fillMaxWidth() .onSizeChanged { rowWidth = it.width.toFloat() } .offset { IntOffset(x = offset.value.roundToInt(), y = 0) } - .pointerInput(swipeToReplyWidth) { - if (isSwipeable()) { + .pointerInput(swipeToReplyWidth, isSwipeable) { + if (isSwipeable) { detectHorizontalDragGestures( onHorizontalDrag = { change, dragAmount -> // Only consume if horizontal drag dominates vertical diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt index 429a84c916f..7c4bdb4ff54 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt @@ -100,6 +100,8 @@ import kotlin.math.abs * @param loadingMoreContent Composable that represents the loading more content, when we're loading the next page. * @param itemModifier Modifier for styling the message item container. * @param itemContent Composable that represents the item that displays each message. + * @param headerContent The content shown at the top of the list of message items. + * @param footerContent The content shown at the bottom of the list of message items. */ @Composable @Suppress("LongParameterList", "LongMethod", "ComplexMethod") @@ -138,6 +140,8 @@ public fun Messages( } }, itemContent: @Composable LazyItemScope.(MessageListItemState) -> Unit, + headerContent: (@Composable () -> Unit)? = null, + footerContent: (@Composable () -> Unit)? = null, ) { val lazyListState = messagesLazyListState.lazyListState val messages = messagesState.messageItems @@ -178,6 +182,12 @@ public fun Messages( reverseLayout = true, contentPadding = contentPadding, ) { + footerContent?.let { content -> + item { + content.invoke() + } + } + if (isLoadingMoreNewMessages && !endOfNewMessages) { item { loadingMoreContent() @@ -209,6 +219,12 @@ public fun Messages( loadingMoreContent() } } + + headerContent?.let { content -> + item { + content.invoke() + } + } } helperContent() @@ -323,7 +339,10 @@ internal fun BoxScope.DefaultMessagesHelperContent( // Keep track of the last new message state that triggered a scroll to bottom. // If a configuration change happens, we want to keep the same state // and not scroll to bottom again if the newMessageState is the same as before the configuration change. - var lastScrollToBottomOnNewMessage by rememberSaveable(saver = MutableStateNewMessageStateSaver) { + var lastScrollToBottomOnNewMessage by rememberSaveable( + isMessageInThread, + saver = MutableStateNewMessageStateSaver, + ) { mutableStateOf(newMessageState) } @@ -368,9 +387,7 @@ internal fun BoxScope.DefaultMessagesHelperContent( count = messagesState.unreadCount, onClick = { scrollToBottom { - coroutineScope.launch { - lazyListState.scrollToItem(0) - } + coroutineScope.launch { lazyListState.scrollToItem(0) } } }, ), diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt index 09f9b7d92a9..90710a39d88 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -39,6 +40,7 @@ import com.valentinilk.shimmer.LocalShimmerTheme import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.header.VersionPrefixHeader import io.getstream.chat.android.compose.ui.attachments.preview.handler.AttachmentPreviewHandler +import io.getstream.chat.android.compose.ui.util.ImageHeadersInterceptor import io.getstream.chat.android.compose.ui.util.LocalStreamImageLoader import io.getstream.chat.android.compose.ui.util.MessageAlignmentProvider import io.getstream.chat.android.compose.ui.util.MessagePreviewFormatter @@ -47,6 +49,7 @@ import io.getstream.chat.android.compose.ui.util.MessageTextFormatter import io.getstream.chat.android.compose.ui.util.ReactionResolver import io.getstream.chat.android.compose.ui.util.SearchResultNameFormatter import io.getstream.chat.android.compose.ui.util.StreamCoilImageLoaderFactory +import io.getstream.chat.android.ui.common.helper.AsyncImageHeadersProvider import io.getstream.chat.android.ui.common.helper.DateFormatter import io.getstream.chat.android.ui.common.helper.DefaultDownloadAttachmentUriGenerator import io.getstream.chat.android.ui.common.helper.DefaultImageAssetTransformer @@ -118,6 +121,12 @@ private val LocalMessageTextFormatter = compositionLocalOf private val LocalSearchResultNameFormatter = compositionLocalOf { error("No SearchResultNameFormatter provided! Make sure to wrap all usages of Stream components in a ChatTheme.") } + +@Deprecated( + message = "ImageHeadersProvider is deprecated. Use asyncImageHeadersProvider in ChatTheme instead. " + + "Headers are now injected via Coil's interceptor pipeline, which is thread-safe and supports " + + "blocking/suspending operations.", +) private val LocalStreamImageHeadersProvider = compositionLocalOf { error("No ImageHeadersProvider provided! Make sure to wrap all usages of Stream components in a ChatTheme.") } @@ -164,7 +173,6 @@ private val LocalStreamMediaRecorder = compositionLocalOf { * @param colors The set of colors we provide, wrapped in [StreamDesign.Colors]. * @param typography The set of typography styles we provide, wrapped in [StreamDesign.Typography]. * @param rippleConfiguration Defines the appearance for ripples. - * @param userPresence The user presence display configuration. * @param componentFactory Provide to customize the stateless components that are used throughout the UI * @param attachmentPreviewHandlers Attachment preview handlers we provide. * @param reactionResolver Provides available reactions and resolves reaction types to emoji codes. @@ -178,9 +186,17 @@ private val LocalStreamMediaRecorder = compositionLocalOf { * @param channelNameFormatter [ChannelNameFormatter] Used throughout the app for channel names. * @param messagePreviewFormatter [MessagePreviewFormatter] Used to generate a string preview for the given message. * @param searchResultNameFormatter [SearchResultNameFormatter] Used to format names in search results. - * @param imageLoaderFactory A factory that creates new Coil [ImageLoader] instances. + * @param imageLoaderFactory A factory that creates new Coil [ImageLoader] instances. If used in combination with + * [asyncImageHeadersProvider] you must override the [StreamCoilImageLoaderFactory.imageLoader] method accepting the + * interceptors parameter. * @param imageAssetTransformer [ImageAssetTransformer] Used to transform image assets. - * @param imageHeadersProvider [ImageHeadersProvider] Used to provide headers for image requests. + * @param imageHeadersProvider [ImageHeadersProvider] Deprecated. Use [asyncImageHeadersProvider] instead. Headers + * provided here are injected synchronously on the main thread, which blocks the UI for any non-trivial work. + * @param asyncImageHeadersProvider [AsyncImageHeadersProvider] Used to provide headers for image + * requests. Invoked on IO Dispatcher inside Coil's interceptor pipeline, making it safe for blocking or suspending + * operations such as reading an auth token. Prefer this over [imageHeadersProvider]. If you are using this in + * combination with a custom [StreamCoilImageLoaderFactory] you must override the + * [StreamCoilImageLoaderFactory.imageLoader] method accepting the interceptors parameter. * @param downloadAttachmentUriGenerator [DownloadAttachmentUriGenerator] Used to generate download URIs for * attachments. * @param downloadRequestInterceptor [DownloadRequestInterceptor] Used to intercept download requests. @@ -228,6 +244,7 @@ public fun ChatTheme( searchResultNameFormatter: SearchResultNameFormatter = SearchResultNameFormatter.defaultFormatter(), imageLoaderFactory: StreamCoilImageLoaderFactory = StreamCoilImageLoaderFactory.defaultFactory(), imageHeadersProvider: ImageHeadersProvider = DefaultImageHeadersProvider, + asyncImageHeadersProvider: AsyncImageHeadersProvider? = null, downloadAttachmentUriGenerator: DownloadAttachmentUriGenerator = DefaultDownloadAttachmentUriGenerator, downloadRequestInterceptor: DownloadRequestInterceptor = DownloadRequestInterceptor { }, imageAssetTransformer: ImageAssetTransformer = DefaultImageAssetTransformer, @@ -252,6 +269,19 @@ public fun ChatTheme( ChatClient.VERSION_PREFIX_HEADER = VersionPrefixHeader.Compose } + val context = LocalContext.current + val imageLoader = remember(imageLoaderFactory, asyncImageHeadersProvider) { + if (asyncImageHeadersProvider == null) { + imageLoaderFactory.imageLoader(context.applicationContext) + } else { + imageLoaderFactory.imageLoader( + context.applicationContext, + listOf(ImageHeadersInterceptor(asyncImageHeadersProvider)), + ) + } + } + + @Suppress("DEPRECATION") CompositionLocalProvider( LocalChatConfig provides config, LocalColors provides colors, @@ -271,7 +301,7 @@ public fun ChatTheme( LocalMessageTextFormatter provides messageTextFormatter, LocalSearchResultNameFormatter provides searchResultNameFormatter, LocalMessageComposerTheme provides messageComposerTheme, - LocalStreamImageLoader provides imageLoaderFactory.imageLoader(LocalContext.current.applicationContext), + LocalStreamImageLoader provides imageLoader, LocalStreamImageHeadersProvider provides imageHeadersProvider, LocalStreamDownloadAttachmentUriGenerator provides downloadAttachmentUriGenerator, LocalStreamDownloadRequestInterceptor provides downloadRequestInterceptor, @@ -461,7 +491,15 @@ public object ChatTheme { /** * Retrieves the current [ImageHeadersProvider] at the call site's position in the hierarchy. + * + * @deprecated Use [AsyncImageHeadersProvider] in [ChatTheme] for thread-safe header injection. */ + @Deprecated( + message = "ImageHeadersProvider is deprecated. Pass asyncImageHeadersProvider to ChatTheme instead. " + + "Headers are now injected via Coil's interceptor pipeline, which is thread-safe and supports " + + "blocking/suspending operations.", + ) + @Suppress("DEPRECATION") public val streamImageHeadersProvider: ImageHeadersProvider @Composable @ReadOnlyComposable diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt new file mode 100644 index 00000000000..9acaf6db0cc --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt @@ -0,0 +1,45 @@ +/* + * 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.util + +import coil3.intercept.Interceptor +import coil3.network.httpHeaders +import coil3.request.ImageResult +import io.getstream.chat.android.ui.common.helper.AsyncImageHeadersProvider +import io.getstream.chat.android.ui.common.images.internal.toNetworkHeaders +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * A Coil [Interceptor] that injects HTTP headers provided by [AsyncImageHeadersProvider] into + * each image request. The provider is invoked as part of Coil's background pipeline, so + * blocking or suspending operations (e.g. fetching an auth token) are safe to perform inside + * [AsyncImageHeadersProvider.getImageRequestHeaders]. + */ +internal class ImageHeadersInterceptor(private val headersProvider: AsyncImageHeadersProvider) : Interceptor { + + override suspend fun intercept(chain: Interceptor.Chain): ImageResult { + val url = chain.request.data.toString() + val headers = withContext(Dispatchers.IO) { + headersProvider.getImageRequestHeaders(url) + } + val newRequest = chain.request.newBuilder() + .httpHeaders(headers.toNetworkHeaders()) + .build() + return chain.withRequest(newRequest).proceed() + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory.kt index c6f5cf78367..30fb779a480 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.ui.util import android.content.Context import coil3.ImageLoader import coil3.SingletonImageLoader +import coil3.intercept.Interceptor import io.getstream.chat.android.ui.common.images.StreamImageLoaderFactory /** @@ -31,15 +32,40 @@ public fun interface StreamCoilImageLoaderFactory { */ public fun imageLoader(context: Context): ImageLoader + /** + * Returns a new Coil [ImageLoader] with the given [interceptors] prepended to the component + * registry, ahead of all decoders and Coil's built-in EngineInterceptor. + * + * The default implementation **ignores [interceptors]** and delegates to [imageLoader]. + * This means that when a custom [StreamCoilImageLoaderFactory] is used alongside + * [ChatTheme]'s `asyncImageHeadersProvider`, the async headers will **not** be injected — + * the custom factory's loader is returned as-is. + * + * Custom class implementations that want to support interceptor injection should override this + * method, for example by forwarding [interceptors] to [StreamImageLoaderFactory]: + * ```kotlin + * override fun imageLoader(context: Context, interceptors: List): ImageLoader = + * StreamImageLoaderFactory(interceptors = interceptors, builder = myCustomBuilder) + * .newImageLoader(context) + * ``` + * + * Integrators using a custom [StreamCoilImageLoaderFactory] who also need auth headers on + * image requests should either override this method or inject the headers directly inside + * their factory's [imageLoader] implementation (e.g. via a custom OkHttp client). + * + * @param context The [Context] to build the [ImageLoader] with. + * @param interceptors Coil [Interceptor]s to prepend to the component registry. + */ + public fun imageLoader(context: Context, interceptors: List): ImageLoader = + imageLoader(context) + public companion object { /** * Returns the default singleton instance of [StreamCoilImageLoaderFactory]. * * @return The default implementation of [StreamCoilImageLoaderFactory]. */ - public fun defaultFactory(): StreamCoilImageLoaderFactory { - return DefaultStreamCoilImageLoaderFactory - } + public fun defaultFactory(): StreamCoilImageLoaderFactory = DefaultStreamCoilImageLoaderFactory } } @@ -68,6 +94,13 @@ internal object DefaultStreamCoilImageLoaderFactory : StreamCoilImageLoaderFacto */ override fun imageLoader(context: Context): ImageLoader = imageLoader ?: newImageLoader(context) + override fun imageLoader(context: Context, interceptors: List): ImageLoader = + if (interceptors.isEmpty()) { + imageLoader(context) + } else { + StreamImageLoaderFactory(interceptors = interceptors).newImageLoader(context) + } + /** * Builds a new [ImageLoader] using the given Android [Context]. If the loader already exists, we return it. * diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 604b6cd998f..61186b6c670 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -88,6 +88,7 @@ import kotlin.coroutines.cancellation.CancellationException * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. * @param searchDebounceMs The debounce time for search queries. * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. * @param globalState A flow emitting the current [GlobalState]. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -102,6 +103,7 @@ public class ChannelListViewModel( private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, private val isDraftMessageEnabled: Boolean = false, + private val messageSearchSort: QuerySorter? = null, private val globalState: Flow = chatClient.globalStateFlow, ) : ViewModel() { @@ -374,14 +376,18 @@ public class ChannelListViewModel( currentState: SearchMessageState, channelFilter: FilterObject, ): SearchMessageState { - val offset = currentState.messages.size val limit = channelLimit - logger.v { "[searchMessages] #$src; query: '${currentState.query}', offset: $offset, limit: $limit" } + val next = currentState.next + logger.v { + "[searchMessages] #$src; query: '${currentState.query}', sort: $messageSearchSort, next: $next, " + + "limit: $limit" + } val result = chatClient.searchMessages( channelFilter = channelFilter, messageFilter = Filters.autocomplete("text", currentState.query), - offset = offset, + sort = messageSearchSort, limit = limit, + next = next, ).await() return when (result) { is io.getstream.result.Result.Success -> { @@ -390,7 +396,8 @@ public class ChannelListViewModel( messages = currentState.messages + result.value.messages, isLoading = false, isLoadingMore = false, - canLoadMore = result.value.messages.size >= limit, + canLoadMore = !result.value.next.isNullOrEmpty(), + next = result.value.next, ) } is io.getstream.result.Result.Failure -> { @@ -398,7 +405,6 @@ public class ChannelListViewModel( currentState.copy( isLoading = false, isLoadingMore = false, - canLoadMore = true, ) } } @@ -844,6 +850,7 @@ public class ChannelListViewModel( private data class SearchMessageState( val query: String = "", val canLoadMore: Boolean = true, + val next: String? = null, val messages: List = emptyList(), val isLoading: Boolean = false, val isLoadingMore: Boolean = false, @@ -855,7 +862,8 @@ public class ChannelListViewModel( "messages.size=${messages.size}, " + "isLoading=$isLoading, " + "isLoadingMore=$isLoadingMore, " + - "canLoadMore=$canLoadMore)" + "canLoadMore=$canLoadMore, " + + "next=$next)" } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index 136ed58fa1a..dc017100179 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -23,6 +23,7 @@ import io.getstream.chat.android.client.api.event.ChatEventHandler import io.getstream.chat.android.client.api.event.ChatEventHandlerFactory import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter @@ -39,6 +40,7 @@ import io.getstream.chat.android.models.querysort.QuerySorter * @param messageLimit How many messages are fetched for each channel item when loading channels. * When `null`, the server-side default is used. * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. + * @param messageSearchSort Optional sorting for message search results. When `null`, the server-side default is used. */ public class ChannelViewModelFactory( private val chatClient: ChatClient = ChatClient.instance(), @@ -49,6 +51,7 @@ public class ChannelViewModelFactory( private val messageLimit: Int? = null, private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), private val isDraftMessageEnabled: Boolean = false, + private val messageSearchSort: QuerySorter? = null, ) : ViewModelProvider.Factory { /** @@ -68,6 +71,7 @@ public class ChannelViewModelFactory( memberLimit = memberLimit, chatEventHandlerFactory = chatEventHandlerFactory, isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, ) as T } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt index 5d5e3417ef7..06e2cd24ca3 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt @@ -64,7 +64,7 @@ import kotlinx.coroutines.channels.Channel as CoroutineChannel * @param channelState Provides the current [ChannelState] for channel-specific configuration. * @param savedStateHandle Persists picker tab and selection state across process death. */ -public class AttachmentsPickerViewModel( +public class AttachmentsPickerViewModel @JvmOverloads constructor( private val storageHelper: AttachmentStorageHelper, channelState: StateFlow, private val savedStateHandle: SavedStateHandle = SavedStateHandle(), diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt index e2c0f46f350..2fc144aed4d 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/util/extensions/MessageOptionItemVisibilityTest.kt @@ -34,6 +34,7 @@ import org.amshove.kluent.`should be` import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import java.util.Date internal class MessageOptionItemVisibilityTest { @@ -171,27 +172,38 @@ internal class MessageOptionItemVisibilityTest { @JvmStatic fun canReplyToMessageArguments() = listOf( + // case: reply disabled Arguments.of( MessageOptionItemVisibility(isReplyVisible = false), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(), false, ), + // case: message not synced Arguments.of( MessageOptionItemVisibility(), randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), randomChannelCapabilities(), false, ), + // case: no QUOTE_MESSAGE capability Arguments.of( MessageOptionItemVisibility(), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.QUOTE_MESSAGE)), false, ), + // case: message is deleted Arguments.of( MessageOptionItemVisibility(isReplyVisible = true), - randomMessage(syncStatus = SyncStatus.COMPLETED), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = Date(), deletedForMe = false), + randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), + false, + ), + // case: all conditions met + Arguments.of( + MessageOptionItemVisibility(isReplyVisible = true), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = null, deletedForMe = false), randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), true, ), diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index 538004da6a7..151364f9cd1 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -25,6 +25,7 @@ import io.getstream.chat.android.client.api.state.QueryChannelsState import io.getstream.chat.android.client.api.state.StateRegistry import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.internal.state.plugin.internal.StatePlugin +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.state.channels.list.SearchQuery @@ -35,11 +36,14 @@ import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.InitializationState +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.OrFilterObject +import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter +import io.getstream.chat.android.randomMessage import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.state.channels.actions.DeleteConversation @@ -48,10 +52,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.amshove.kluent.`should be equal to` +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -76,8 +85,8 @@ internal class ChannelListViewModelTest { .get(this) val channelsState = viewModel.channelsState - channelsState.channelItems.size `should be equal to` 0 - channelsState.isLoading `should be equal to` true + assertEquals(0, channelsState.channelItems.size) + assertTrue(channelsState.isLoading) } @Test @@ -95,8 +104,8 @@ internal class ChannelListViewModelTest { .get(this) val channelsState = viewModel.channelsState - channelsState.channelItems.size `should be equal to` 2 - channelsState.isLoading `should be equal to` false + assertEquals(2, channelsState.channelItems.size) + assertFalse(channelsState.isLoading) } @Test @@ -123,8 +132,8 @@ internal class ChannelListViewModelTest { ), ) - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) verify(channelClient).delete() } @@ -145,8 +154,8 @@ internal class ChannelListViewModelTest { viewModel.selectChannel(channel1) viewModel.muteChannel(channel1) - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) verify(chatClient).muteChannel("messaging", "channel1", null) } @@ -176,9 +185,9 @@ internal class ChannelListViewModelTest { viewModel.selectChannel(channel1) viewModel.unmuteChannel(channel1) - (viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState).isMuted `should be equal to` true - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertTrue((viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState).isMuted) + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) verify(chatClient).unmuteChannel("messaging", "channel1") } @@ -198,8 +207,8 @@ internal class ChannelListViewModelTest { viewModel.selectChannel(channel1) viewModel.dismissChannelAction() - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) } @Test @@ -228,8 +237,8 @@ internal class ChannelListViewModelTest { val captor = argumentCaptor() verify(chatClient, times(2)).queryChannels(captor.capture()) - captor.firstValue.offset `should be equal to` 0 - captor.secondValue.offset `should be equal to` 30 + assertEquals(0, captor.firstValue.offset) + assertEquals(30, captor.secondValue.offset) } @Test @@ -251,7 +260,7 @@ internal class ChannelListViewModelTest { val captor = argumentCaptor() verify(chatClient, times(1)).queryChannels(captor.capture()) - captor.firstValue.offset `should be equal to` 0 + assertEquals(0, captor.firstValue.offset) } @Test @@ -276,8 +285,8 @@ internal class ChannelListViewModelTest { val andFilterObject = captor.secondValue.filter as AndFilterObject val orFilterObject = andFilterObject.filterObjects.last() as OrFilterObject val autoCompleteFilterObject = orFilterObject.filterObjects.last() as AutocompleteFilterObject - autoCompleteFilterObject.fieldName `should be equal to` "name" - autoCompleteFilterObject.value `should be equal to` "Search query" + assertEquals("name", autoCompleteFilterObject.fieldName) + assertEquals("Search query", autoCompleteFilterObject.value) } @Test @@ -306,8 +315,8 @@ internal class ChannelListViewModelTest { val andFilterObject = captor.secondValue.filter as AndFilterObject val orFilterObject = andFilterObject.filterObjects.last() as OrFilterObject val autoCompleteFilterObject = orFilterObject.filterObjects.last() as AutocompleteFilterObject - autoCompleteFilterObject.fieldName `should be equal to` "name" - autoCompleteFilterObject.value `should be equal to` "Search query" + assertEquals("name", autoCompleteFilterObject.fieldName) + assertEquals("Search query", autoCompleteFilterObject.value) } @Test @@ -338,9 +347,189 @@ internal class ChannelListViewModelTest { val captor = argumentCaptor() verify(chatClient, times(2)).queryChannels(captor.capture()) - captor.allValues.size `should be equal to` 2 - captor.firstValue.offset `should be equal to` 0 - captor.secondValue.offset `should be equal to` 30 + assertEquals(2, captor.allValues.size) + assertEquals(0, captor.firstValue.offset) + assertEquals(30, captor.secondValue.offset) + } + + @Test + fun `Given channel list When setting message search query Should search messages without offset or cursor`() = + runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:channel1")) + val searchResult = SearchMessagesResult(messages = messages, next = "cursor_page2") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels(listOf(channel1)) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = eq(null), + limit = any(), + next = eq(null), + sort = eq(null), + ) + val items = viewModel.channelsState.channelItems + assertEquals(1, items.size) + assertInstanceOf(ItemState.SearchResultItemState::class.java, items.first()) + } + + @Test + fun `Given message search results with next cursor When loading more Should pass the cursor`() = + runTest { + val chatClient: ChatClient = mock() + val firstPageMessages = listOf(randomMessage(cid = "messaging:channel1")) + val firstPageResult = SearchMessagesResult(messages = firstPageMessages, next = "cursor_page2") + val secondPageMessages = listOf(randomMessage(cid = "messaging:channel1")) + val secondPageResult = SearchMessagesResult(messages = secondPageMessages, next = null) + + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ).doReturn( + firstPageResult.asCall(), + secondPageResult.asCall(), + ) + + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenRepositorySelectChannels(listOf(channel1)) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = captor.capture(), + sort = anyOrNull(), + ) + assertNull(captor.firstValue) + assertEquals("cursor_page2", captor.secondValue) + } + + @Test + fun `Given message search results without next cursor When loading more Should not load more`() = + runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:channel1")) + val searchResult = SearchMessagesResult(messages = messages, next = null) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels(listOf(channel1)) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + assertTrue(viewModel.channelsState.endOfChannels) + + viewModel.loadMore() + advanceUntilIdle() + + verify(chatClient, times(1)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) + } + + @Test + fun `Given no messageSearchSort When searching messages Should pass null sort to searchMessages`() = + runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("test")) + advanceUntilIdle() + + val sortCaptor = argumentCaptor>() + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = sortCaptor.capture(), + ) + assertNull(sortCaptor.firstValue) + } + + @Test + fun `Given messageSearchSort is set When searching messages Should pass the sort to searchMessages`() = + runTest { + val chatClient: ChatClient = mock() + val messageSearchSort = QuerySortByField.descByName("created_at") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + .givenMessageSearchSort(messageSearchSort) + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("test")) + advanceUntilIdle() + + val sortCaptor = argumentCaptor>() + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = sortCaptor.capture(), + ) + assertEquals(messageSearchSort, sortCaptor.firstValue) } private class Fixture( @@ -352,6 +541,8 @@ internal class ChannelListViewModelTest { private val clientState: ClientState = mock() private val stateRegistry: StateRegistry = mock() private val globalState: GlobalState = mock() + private val repositoryFacade: RepositoryFacade = mock() + private var messageSearchSort: QuerySorter? = null init { val statePlugin: StatePlugin = mock() @@ -364,6 +555,7 @@ internal class ChannelListViewModelTest { whenever(chatClient.channel(any())) doReturn channelClient whenever(chatClient.channel(any(), any())) doReturn channelClient whenever(chatClient.clientState) doReturn clientState + whenever(chatClient.repositoryFacade) doReturn repositoryFacade whenever(globalState.channelDraftMessages) doReturn MutableStateFlow(emptyMap()) } @@ -401,6 +593,20 @@ internal class ChannelListViewModelTest { whenever(chatClient.unmuteChannel(any(), any())) doReturn Unit.asCall() } + fun givenMessageSearchSort(sort: QuerySorter?) = apply { + messageSearchSort = sort + } + + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ) doReturn result.asCall() + } + + suspend fun givenRepositorySelectChannels(channels: List = emptyList()) = apply { + whenever(repositoryFacade.selectChannels(any>())) doReturn channels + } + fun givenChannelsState( channelsStateData: ChannelsStateData = ChannelsStateData.Loading, channels: List? = null, @@ -427,6 +633,7 @@ internal class ChannelListViewModelTest { initialFilters = initialFilters, isDraftMessageEnabled = false, chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + messageSearchSort = messageSearchSort, globalState = MutableStateFlow(globalState), ) testScope.advanceUntilIdle() diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_my_message.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_my_message.png index 62d17196b59..f6304aef193 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_my_message.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_my_message.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_my_message_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_my_message_in_dark_mode.png index 381cb00c85d..c14144f8bbe 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_my_message_in_dark_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_my_message_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_their_message.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_their_message.png index 0e69b29ad1a..1dba3e92547 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_their_message.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_their_message.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_their_message_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_their_message_in_dark_mode.png index e4b17aa0132..2288c19a911 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_their_message_in_dark_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_SelectedMessageMenuTest_their_message_in_dark_mode.png differ diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index 5503f90533e..b9aeab67435 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -1257,6 +1257,10 @@ public final class io/getstream/chat/android/ui/common/feature/messages/translat public final class io/getstream/chat/android/ui/common/feature/threads/ThreadListController$Companion { } +public abstract interface class io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider { + public abstract fun getImageRequestHeaders (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/ui/common/helper/ClipboardHandler { public abstract fun copyMessage (Lio/getstream/chat/android/models/Message;)V } @@ -1389,6 +1393,8 @@ public final class io/getstream/chat/android/ui/common/helper/internal/StorageHe public final class io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory : coil3/SingletonImageLoader$Factory { public static final field $stable I public fun ()V + public fun (Ljava/util/List;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/util/List;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun newImageLoader (Landroid/content/Context;)Lcoil3/ImageLoader; diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt new file mode 100644 index 00000000000..8b1c07e3fa5 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt @@ -0,0 +1,42 @@ +/* + * 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.ui.common.helper + +/** + * Provides HTTP headers for image loading requests in a suspending, thread-safe manner. + * + * Unlike [ImageHeadersProvider], this interface is designed for async operations such as + * reading an auth token from encrypted storage or fetching one from a remote endpoint. + * Implementations are always invoked on [kotlinx.coroutines.Dispatchers.IO], so blocking + * calls are safe. + * + * Prefer this over [ImageHeadersProvider] when integrating with [ChatTheme]. + * + * @see ImageHeadersProvider + */ +public interface AsyncImageHeadersProvider { + + /** + * Returns a map of headers to be used for the image loading request. + * + * Always called on [kotlinx.coroutines.Dispatchers.IO], so blocking operations are safe. + * + * @param url The URL of the image to load. + * @return A map of headers to be used for the image loading request. + */ + public suspend fun getImageRequestHeaders(url: String): Map +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt index d3510042ce7..77012a3472c 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt @@ -23,15 +23,13 @@ import coil3.SingletonImageLoader import coil3.disk.DiskCache import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder +import coil3.intercept.Interceptor import coil3.memory.MemoryCache -import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.allowHardware import coil3.request.crossfade import coil3.video.VideoFrameDecoder import io.getstream.chat.android.client.internal.file.StreamFileManager -import okhttp3.Dispatcher -import okhttp3.Interceptor -import okhttp3.OkHttpClient +import kotlinx.coroutines.Dispatchers import okio.Path.Companion.toOkioPath private const val DEFAULT_MEMORY_PERCENTAGE = 0.25 @@ -52,6 +50,24 @@ public class StreamImageLoaderFactory( private val builder: ImageLoader.Builder.() -> Unit = {}, ) : SingletonImageLoader.Factory { + /** + * Creates a [StreamImageLoaderFactory] with additional [Interceptor]s prepended to the + * component registry, before any decoders and before Coil's built-in [EngineInterceptor]. + * + * This constructor preserves the existing primary constructor signature and is purely additive. + * + * @param interceptors Coil [Interceptor]s to register ahead of all other components. + * @param builder Optional lambda to further customize the [ImageLoader] configuration. + */ + public constructor( + interceptors: List, + builder: ImageLoader.Builder.() -> Unit = {}, + ) : this(builder) { + this.interceptors = interceptors + } + + private var interceptors: List = emptyList() + private val fileManager = StreamFileManager() override fun newImageLoader(context: PlatformContext): ImageLoader { @@ -59,34 +75,15 @@ public class StreamImageLoaderFactory( .memoryCache { MemoryCache.Builder().maxSizePercent(context, DEFAULT_MEMORY_PERCENTAGE).build() } .allowHardware(false) .crossfade(true) - .components { - add( - OkHttpNetworkFetcherFactory( - callFactory = { - val cacheControlInterceptor = Interceptor { chain -> - chain.proceed(chain.request()) - .newBuilder() - .header("Cache-Control", "max-age=3600,public") - .build() - } - // Don't limit concurrent network requests by host. - val dispatcher = Dispatcher().apply { maxRequestsPerHost = maxRequests } - - OkHttpClient.Builder() - .dispatcher(dispatcher) - .addNetworkInterceptor(cacheControlInterceptor) - .build() - }, - ), - ) - } .diskCache { DiskCache.Builder() .directory(fileManager.getImageCache(context).toOkioPath()) .maxSizePercent(DEFAULT_DISK_CACHE_PERCENTAGE) .build() } + .interceptorCoroutineContext(Dispatchers.IO) .components { + interceptors.forEach { add(it) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { add(AnimatedImageDecoder.Factory(enforceMinimumFrameDelay = true)) } else { diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt index 7bbb4fe93cb..dae4338c95e 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt @@ -20,6 +20,7 @@ package io.getstream.chat.android.ui.common.utils import io.getstream.chat.android.client.utils.attachment.isGiphy import io.getstream.chat.android.client.utils.message.hasSharedLocation +import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.client.utils.message.isThreadReply import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.models.Channel @@ -60,7 +61,10 @@ public fun canReplyToMessage( replyEnabled: Boolean, message: Message, ownCapabilities: Set, -): Boolean = replyEnabled && message.isSynced() && ownCapabilities.contains(ChannelCapabilities.QUOTE_MESSAGE) +): Boolean = replyEnabled && + message.isSynced() && + !message.isDeleted() && + ownCapabilities.contains(ChannelCapabilities.QUOTE_MESSAGE) /** * Determines whether a thread reply can be made to the given message. diff --git a/stream-chat-android-ui-common/src/main/res/xml/stream_filepaths.xml b/stream-chat-android-ui-common/src/main/res/xml/stream_filepaths.xml index 60e718ca186..63cc24ea32d 100644 --- a/stream-chat-android-ui-common/src/main/res/xml/stream_filepaths.xml +++ b/stream-chat-android-ui-common/src/main/res/xml/stream_filepaths.xml @@ -15,28 +15,16 @@ limitations under the License. --> - - - - diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt index b786f4baab0..2c0e64bd863 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelperTest.kt @@ -33,6 +33,7 @@ import org.amshove.kluent.`should be` import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import java.util.Date internal class CapabilitiesHelperTest { @@ -166,27 +167,38 @@ internal class CapabilitiesHelperTest { @JvmStatic fun canReplyToMessageArguments() = listOf( + // case: reply disabled Arguments.of( false, randomMessage(), randomChannelCapabilities(), false, ), + // case: message not synced Arguments.of( randomBoolean(), randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), randomChannelCapabilities(), false, ), + // case: no QUOTE_MESSAGE capability Arguments.of( randomBoolean(), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.QUOTE_MESSAGE)), false, ), + // case: message is deleted Arguments.of( true, - randomMessage(syncStatus = SyncStatus.COMPLETED), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = Date(), deletedForMe = false), + randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), + false, + ), + // case: all conditions met + Arguments.of( + true, + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = null, deletedForMe = false), randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), true, ), diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 3c93e8c0c65..b26297cf0f2 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -5209,15 +5209,15 @@ public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State { public fun ()V - public fun (Ljava/lang/String;ZLjava/util/List;ZZ)V - public synthetic fun (Ljava/lang/String;ZLjava/util/List;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;)V + public synthetic fun (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Z public final fun component3 ()Ljava/util/List; public final fun component4 ()Z public final fun component5 ()Z - public final fun copy (Ljava/lang/String;ZLjava/util/List;ZZ)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State;Ljava/lang/String;ZLjava/util/List;ZZILjava/lang/Object;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; + public final fun copy (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State;Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; public fun equals (Ljava/lang/Object;)Z public final fun getCanLoadMore ()Z public final fun getQuery ()Ljava/lang/String; diff --git a/stream-chat-android-ui-components/detekt-baseline.xml b/stream-chat-android-ui-components/detekt-baseline.xml index 88ba9e162ff..370e3254295 100644 --- a/stream-chat-android-ui-components/detekt-baseline.xml +++ b/stream-chat-android-ui-components/detekt-baseline.xml @@ -8,7 +8,6 @@ ComplexCondition:FootnoteDecorator.kt$FootnoteDecorator$!isGiphy && !isDeleted && userLanguage != i18nLanguage && translatedText != data.message.text ComplexCondition:MediaAttachmentView.kt$MediaAttachmentView$attachment.isImage() || (attachment.isVideo() && ChatUI.videoThumbnailsEnabled && attachment.thumbUrl != null) ForbiddenComment:MediaAttachmentGridView.kt$MediaAttachmentGridView.SharedMediaSpaceItemDecorator$// TODO: leaves empty space after pagination - ForbiddenComment:SearchViewModel.kt$SearchViewModel$// TODO: use the pagination based on "limit" nad "next" params here IteratorNotThrowingNoSuchElementException:MessageComposerView.kt$<no name provided>$<no name provided> : Iterator LargeClass:MessageComposerViewStyle.kt$MessageComposerViewStyle$Companion LargeClass:MessageListView.kt$MessageListView : ConstraintLayout diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt index 0eabb402b50..2145d0635ea 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt @@ -23,7 +23,7 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.Event import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Filters -import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.ui.common.model.MessageResult import io.getstream.log.taggedLogger import io.getstream.result.Error @@ -124,7 +124,7 @@ public class SearchViewModel( val currentState = _state.value!! val result = searchMessages( query = currentState.query, - offset = currentState.results.size, + next = currentState.next, ) when (result) { is Result.Success -> handleSearchMessageSuccess(result.value) @@ -136,12 +136,12 @@ public class SearchViewModel( /** * Notifies the UI about the search results and enables the pagination. */ - private suspend fun handleSearchMessageSuccess(messages: List) { - logger.d { "Found messages: ${messages.size}" } + private suspend fun handleSearchMessageSuccess(searchResult: SearchMessagesResult) { + logger.d { "Found messages: ${searchResult.messages.size}" } val currentState = _state.value!! - val channels = chatClient.repositoryFacade.selectChannels(messages.map { it.cid }) + val channels = chatClient.repositoryFacade.selectChannels(searchResult.messages.map { it.cid }) _state.value = currentState.copy( - results = currentState.results + messages.map { + results = currentState.results + searchResult.messages.map { MessageResult( message = it, channel = channels.firstOrNull { channel -> channel.cid == it.cid }, @@ -149,7 +149,8 @@ public class SearchViewModel( }, isLoading = false, isLoadingMore = false, - canLoadMore = messages.size == QUERY_LIMIT, + canLoadMore = !searchResult.next.isNullOrEmpty(), + next = searchResult.next, ) } @@ -161,7 +162,6 @@ public class SearchViewModel( _state.value = _state.value!!.copy( isLoading = false, isLoadingMore = false, - canLoadMore = true, ) _errorEvents.value = Event(Unit) } @@ -170,21 +170,19 @@ public class SearchViewModel( * Searches messages containing [query] text across channels where the current user is a member. * * @param query The search query. - * @param offset The pagination offset offset. + * @param next The cursor for the next page of results. */ - private suspend fun searchMessages(query: String, offset: Int): Result> { - logger.d { "Searching for \"$query\" with offset: $offset" } + private suspend fun searchMessages(query: String, next: String?): Result { + logger.d { "Searching for \"$query\" with next: $next" } val currentUser = requireNotNull(chatClient.clientState.user.value) - // TODO: use the pagination based on "limit" nad "next" params here return chatClient .searchMessages( channelFilter = Filters.`in`("members", listOf(currentUser.id)), messageFilter = Filters.autocomplete("text", query), - offset = offset, limit = QUERY_LIMIT, + next = next, ) .await() - .map { it.messages } } /** @@ -195,6 +193,7 @@ public class SearchViewModel( * @param canLoadMore If we've reached the end of messages, to stop triggering pagination. * @param isLoading If we're currently loading data (initial load). * @param isLoadingMore If we're loading more items (pagination). + * @param next The cursor for the next page of results. */ public data class State( val query: String = "", @@ -202,6 +201,7 @@ public class SearchViewModel( val results: List = emptyList(), val isLoading: Boolean = false, val isLoadingMore: Boolean = false, + internal val next: String? = null, ) private companion object { diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/messages/list/internal/MessageListViewExtensionsKtTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/messages/list/internal/MessageListViewExtensionsKtTest.kt index a18a9b8ad72..e3518973c30 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/messages/list/internal/MessageListViewExtensionsKtTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/messages/list/internal/MessageListViewExtensionsKtTest.kt @@ -33,6 +33,7 @@ import org.amshove.kluent.`should be` import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import java.util.Date internal class MessageListViewExtensionsKtTest { @@ -154,27 +155,42 @@ internal class MessageListViewExtensionsKtTest { @JvmStatic fun canReplyToMessageArguments() = listOf( + // case: reply disabled Arguments.of( randomMessageListViewStyle(replyEnabled = false), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(), false, ), + // case: message not synced Arguments.of( randomMessageListViewStyle(), - randomMessage(syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED))), + randomMessage( + syncStatus = randomSyncStatus(exclude = listOf(SyncStatus.COMPLETED)), + deletedAt = null, + deletedForMe = false, + ), randomChannelCapabilities(), false, ), + // case: no QUOTE_MESSAGE capability Arguments.of( randomMessageListViewStyle(), - randomMessage(), + randomMessage(deletedAt = null, deletedForMe = false), randomChannelCapabilities(exclude = setOf(ChannelCapabilities.QUOTE_MESSAGE)), false, ), + // case: message is deleted Arguments.of( randomMessageListViewStyle(replyEnabled = true), - randomMessage(syncStatus = SyncStatus.COMPLETED), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = Date(), deletedForMe = false), + randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), + false, + ), + // case: all conditions met + Arguments.of( + randomMessageListViewStyle(replyEnabled = true), + randomMessage(syncStatus = SyncStatus.COMPLETED, deletedAt = null, deletedForMe = false), randomChannelCapabilities(include = setOf(ChannelCapabilities.QUOTE_MESSAGE)), true, ), diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt new file mode 100644 index 00000000000..2a78f15a05e --- /dev/null +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt @@ -0,0 +1,194 @@ +/* + * 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.ui.viewmodels.search + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.SearchMessagesResult +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.test.InstantTaskExecutorExtension +import io.getstream.chat.android.test.TestCoroutineExtension +import io.getstream.chat.android.test.asCall +import io.getstream.chat.android.test.observeAll +import io.getstream.chat.android.ui.viewmodel.search.SearchViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class SearchViewModelTest { + + @Test + fun `Given empty query When setting query Should clear results`() = runTest { + val viewModel = Fixture() + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + viewModel.setQuery("") + + val states = viewModel.state.observeAll() + val lastState = states.last() + assertEquals(0, lastState.results.size) + assertFalse(lastState.isLoading) + assertFalse(lastState.canLoadMore) + assertNull(lastState.next) + } + + @Test + fun `Given search query When searching Should call searchMessages without offset or cursor`() = runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:ch1")) + val searchResult = SearchMessagesResult(messages = messages, next = "cursor_page2") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = eq(null), + limit = any(), + next = eq(null), + sort = eq(null), + ) + } + + @Test + fun `Given search results with next cursor When loading more Should pass the cursor`() = runTest { + val chatClient: ChatClient = mock() + val firstPageMessages = listOf(randomMessage(cid = "messaging:ch1")) + val firstPageResult = SearchMessagesResult(messages = firstPageMessages, next = "cursor_page2") + val secondPageMessages = listOf(randomMessage(cid = "messaging:ch1")) + val secondPageResult = SearchMessagesResult(messages = secondPageMessages, next = null) + + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ).doReturn( + firstPageResult.asCall(), + secondPageResult.asCall(), + ) + + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + viewModel.loadMore() + + val captor = argumentCaptor() + verify(chatClient, times(2)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = captor.capture(), + sort = anyOrNull(), + ) + assertNull(captor.firstValue) + assertEquals("cursor_page2", captor.secondValue) + } + + @Test + fun `Given search results without next cursor When loading more Should not load more`() = runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:ch1")) + val searchResult = SearchMessagesResult(messages = messages, next = null) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + + val states = viewModel.state.observeAll() + val lastState = states.last() + assertFalse(lastState.canLoadMore) + + viewModel.loadMore() + + verify(chatClient, times(1)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) + } + + private class Fixture( + private val chatClient: ChatClient = mock(), + ) { + private val clientState: ClientState = mock() + private val repositoryFacade: RepositoryFacade = mock() + + init { + whenever(chatClient.clientState) doReturn clientState + whenever(chatClient.repositoryFacade) doReturn repositoryFacade + } + + fun givenCurrentUser(currentUser: User = User(id = "Jc")) = apply { + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + } + + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ) doReturn result.asCall() + } + + suspend fun givenRepositorySelectChannels(channels: List = emptyList()) = apply { + whenever(repositoryFacade.selectChannels(any>())) doReturn channels + } + + fun get(): SearchViewModel = SearchViewModel(chatClient = chatClient) + } + + companion object { + @JvmField + @RegisterExtension + val testCoroutines: TestCoroutineExtension = TestCoroutineExtension() + + @JvmField + @RegisterExtension + val instantExecutorExtension: InstantTaskExecutorExtension = InstantTaskExecutorExtension() + } +}