diff --git a/CHANGELOG.md b/CHANGELOG.md index fac2c68640f..bd1b97b33cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,12 @@ ### ⬆️ Improved ### ✅ Added +- Introduce `Channel.userRead` extension function to get the read status of a specific user in the channel. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) +- Introduce `Channel.readsOf` extension function to get the read statuses representing which users have read the given message in the channel. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) +- Introduce message delivery receipts. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ⚠️ Changed +- Deprecate `Channel.hasUnread` property in favor of `Channel.currentUserUnreadCount`. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ❌ Removed @@ -49,6 +53,7 @@ ### ⬆️ Improved ### ✅ Added +- Introduce message delivery receipts. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ⚠️ Changed @@ -60,6 +65,7 @@ ### ⬆️ Improved ### ✅ Added +- Introduce message delivery receipts. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ⚠️ Changed @@ -71,6 +77,7 @@ ### ⬆️ Improved ### ✅ Added +- Introduce message delivery receipts. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ⚠️ Changed @@ -3573,7 +3580,7 @@ The following items are breaking changes, since it was very important to improve - Added `ChatUI.channelNameFormatter` to allow customizing the channel's name format. [#3068](https://github.com/GetStream/stream-chat-android/pull/3068) - Added a customizable height attribute to SearchInputView [#3081](https://github.com/GetStream/stream-chat-android/pull/3081) - Added `ChatUI.dateFormatter` to allow customizing the way the dates are formatted. [#3085](https://github.com/GetStream/stream-chat-android/pull/3085) -- Added ways to show/hide the delivery status indicators for channels and messages. [#3102](https://github.com/GetStream/stream-chat-android/pull/3102) +- Added ways to show/hide the delivery receipts indicators for channels and messages. [#3102](https://github.com/GetStream/stream-chat-android/pull/3102) ### ⚠️ Changed - Disabled editing on Giphy messages given that it's breaking the UX and can override the GIF that was previously put in. [#3071](https://github.com/GetStream/stream-chat-android/pull/3071) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 33c0dd9d608..0a67d2e8c85 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -731,8 +731,8 @@ style: active: false ReturnCount: active: true - max: 2 - excludedFunctions: 'equals' + max: 10 + excludedFunctions: ['equals'] excludeLabeled: false excludeReturnFromLambda: true excludeGuardClauses: false diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt index af212bc8864..f8151d12dbf 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt @@ -27,6 +27,7 @@ import io.getstream.chat.android.client.events.ConnectedEvent import io.getstream.chat.android.client.events.MarkAllReadEvent import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -260,6 +261,26 @@ public fun randomMessageReadEvent( ) } +public fun randomMessageDeliveredEvent( + createdAt: Date = Date(), + user: User = randomUser(), + cid: String = randomCID(), + channelType: String = randomString(), + channelId: String = randomString(), + lastDeliveredAt: Date = randomDate(), + lastDeliveredMessageId: String = randomString(), +) = MessageDeliveredEvent( + type = EventType.MESSAGE_DELIVERED, + createdAt = createdAt, + rawCreatedAt = streamFormatter.format(createdAt), + user = user, + cid = cid, + channelType = channelType, + channelId = channelId, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, +) + public fun randomNotificationMarkReadEvent( createdAt: Date = Date(), user: User = randomUser(), diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index d538b9f6f5d..308a4d93123 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -1619,6 +1619,33 @@ public final class io/getstream/chat/android/client/events/MessageDeletedEvent : public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/client/events/MessageDeliveredEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Date; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lio/getstream/chat/android/models/User; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/util/Date; + public final fun component9 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)Lio/getstream/chat/android/client/events/MessageDeliveredEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/MessageDeliveredEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/MessageDeliveredEvent; + public fun equals (Ljava/lang/Object;)Z + public fun getChannelId ()Ljava/lang/String; + public fun getChannelType ()Ljava/lang/String; + public fun getCid ()Ljava/lang/String; + public fun getCreatedAt ()Ljava/util/Date; + public final fun getLastDeliveredAt ()Ljava/util/Date; + public final fun getLastDeliveredMessageId ()Ljava/lang/String; + public fun getRawCreatedAt ()Ljava/lang/String; + public fun getType ()Ljava/lang/String; + public fun getUser ()Lio/getstream/chat/android/models/User; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/client/events/MessageReadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/UserEvent { public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;)V public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -2631,12 +2658,15 @@ public final class io/getstream/chat/android/client/extensions/ChannelExtensionK public static final fun countUnreadMentionsForUser (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)I public static final fun currentUserUnreadCount (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)I public static synthetic fun currentUserUnreadCount$default (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;ILjava/lang/Object;)I + public static final fun deliveredReadsOf (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Ljava/util/List; public static final fun isAnonymousChannel (Lio/getstream/chat/android/models/Channel;)Z public static final fun isArchive (Lio/getstream/chat/android/models/Channel;)Z public static final fun isMutedFor (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)Z public static final fun isPinned (Lio/getstream/chat/android/models/Channel;)Z + public static final fun readsOf (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Ljava/util/List; public static final fun syncUnreadCountWithReads (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Lio/getstream/chat/android/models/Channel; public static synthetic fun syncUnreadCountWithReads$default (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/Channel; + public static final fun userRead (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Lio/getstream/chat/android/models/ChannelUserRead; } public final class io/getstream/chat/android/client/extensions/FlowExtensions { @@ -2982,6 +3012,24 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract fun createRepositoryFactory (Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/client/persistance/repository/factory/RepositoryFactory; } +public final class io/getstream/chat/android/client/persistence/db/ChatClientDatabase_Impl { + public static final field Companion Lio/getstream/chat/android/client/persistence/db/ChatClientDatabase$Companion; + public fun ()V + public fun clearAllTables ()V + public fun getAutoMigrations (Ljava/util/Map;)Ljava/util/List; + public fun getRequiredAutoMigrationSpecs ()Ljava/util/Set; + public fun messageReceiptDao ()Lio/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao; +} + +public final class io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao_Impl : io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao { + public fun (Landroidx/room/RoomDatabase;)V + public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteByMessageIds (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun getRequiredConverters ()Ljava/util/List; + public fun selectAllByType (Ljava/lang/String;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun upsert (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/BlockUserListener, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageForMeListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/DraftMessageListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/LiveLocationListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/PushPreferencesListener, io/getstream/chat/android/client/plugin/listeners/QueryBlockedUsersListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener, io/getstream/chat/android/client/plugin/listeners/UnblockUserListener { public fun getErrorHandler ()Lio/getstream/chat/android/client/errorhandler/ErrorHandler; public fun onAttachmentSendRequest (Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3061,12 +3109,6 @@ public abstract interface class io/getstream/chat/android/client/plugin/Plugin : public static synthetic fun onQueryChannelRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/api/models/QueryChannelRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryChannelResult (Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/api/models/QueryChannelRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryChannelResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/api/models/QueryChannelRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun onQueryChannelsPrecondition (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun onQueryChannelsPrecondition$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun onQueryChannelsRequest (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun onQueryChannelsRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun onQueryChannelsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun onQueryChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryDraftMessagesResult (Lio/getstream/result/Result;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryDraftMessagesResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryDraftMessagesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3203,9 +3245,12 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener } public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener { - public abstract fun onQueryChannelsPrecondition (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun onQueryChannelsRequest (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun onQueryChannelsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryChannelsPrecondition (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryChannelsPrecondition$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryChannelsRequest (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryChannelsRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryChannelsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryMembersListener { diff --git a/stream-chat-android-client/build.gradle.kts b/stream-chat-android-client/build.gradle.kts index 1001e82abaa..7bc51f16c50 100644 --- a/stream-chat-android-client/build.gradle.kts +++ b/stream-chat-android-client/build.gradle.kts @@ -97,6 +97,10 @@ dependencies { implementation(libs.okhttp.logging.interceptor) implementation(libs.ok2curl) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + // Unused dependencies: The following dependencies (appcompat, constraintlayout, livedata-ktx) are not used in the // `stream-chat-android-client` module. They are still declared here to prevent potential breaking changes for // integrations that might be relying on them transitively. Consider removing them in future major releases. @@ -111,8 +115,10 @@ dependencies { testImplementation(libs.stream.result) testImplementation(libs.androidx.test.junit) testImplementation(libs.androidx.lifecycle.runtime.testing) + testImplementation(libs.androidx.work.testing) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) + testImplementation(libs.turbine) testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.vintage.engine) 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 e0739366623..d6b389a4f03 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 @@ -122,12 +122,17 @@ import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateForm import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.persistance.repository.factory.RepositoryFactory import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory +import io.getstream.chat.android.client.persistence.db.ChatClientDatabase +import io.getstream.chat.android.client.persistence.repository.ChatClientRepository import io.getstream.chat.android.client.plugin.DependencyResolver +import io.getstream.chat.android.client.plugin.MessageDeliveredPluginFactory import io.getstream.chat.android.client.plugin.Plugin import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.plugin.factory.ThrottlingPluginFactory import io.getstream.chat.android.client.query.AddMembersParams import io.getstream.chat.android.client.query.CreateChannelParams +import io.getstream.chat.android.client.receipts.MessageReceiptManager +import io.getstream.chat.android.client.receipts.MessageReceiptReporter import io.getstream.chat.android.client.scope.ClientScope import io.getstream.chat.android.client.scope.UserScope import io.getstream.chat.android.client.setup.state.ClientState @@ -275,6 +280,8 @@ internal constructor( @InternalStreamChatApi public val audioPlayer: AudioPlayer, private val now: () -> Date = ::Date, + private val repository: ChatClientRepository, + private val messageReceiptReporter: MessageReceiptReporter, ) { private val logger by taggedLogger(TAG) private val waitConnection = MutableSharedFlow>() @@ -327,6 +334,16 @@ internal constructor( private var _repositoryFacade: RepositoryFacade? = null + internal val messageReceiptManager by lazy { + MessageReceiptManager( + now = now, + getCurrentUser = ::getCurrentUser, + repositoryFacade = repositoryFacade, + messageReceiptRepository = repository, + api = api, + ) + } + private var pushNotificationReceivedListener: PushNotificationReceivedListener = PushNotificationReceivedListener { _, _ -> } @@ -447,13 +464,13 @@ internal constructor( mutableClientState.setUser(user) } - is NewMessageEvent, - is NotificationReminderDueEvent, - -> { - // No other events should potentially show notifications + is NewMessageEvent -> { notifications.onChatEvent(event) + messageReceiptManager.markMessageAsDelivered(event.message) } + is NotificationReminderDueEvent -> notifications.onChatEvent(event) + is ConnectingEvent -> { logger.i { "[handleEvent] event: ConnectingEvent" } mutableClientState.setConnectionState(ConnectionState.Connecting) @@ -642,6 +659,7 @@ internal constructor( tokenManager.setTokenProvider(tokenProvider) appSettingsManager.loadAppSettings() warmUp() + messageReceiptReporter.start() logger.i { "[initializeClientWithUser] user.id: '${user.id}'completed" } } @@ -737,9 +755,11 @@ internal constructor( ): Call { return CoroutineCall(clientScope) { logger.d { "[switchUser] user.id: '${user.id}'" } - userScope.userId.value = user.id notifications.deleteDevice() // always delete device if switching users disconnectUserSuspend(flushPersistence = true) + // change userId only after disconnect, + // otherwise the userScope won't cancel coroutines related to the previous user. + userScope.userId.value = user.id onDisconnectionComplete() connectUserSuspend(user, tokenProvider, timeoutMilliseconds).also { logger.v { "[switchUser] completed('${user.id}')" } @@ -1506,6 +1526,8 @@ internal constructor( userCredentialStorage.clear() } + repository.clear() + _repositoryFacade = null attachmentsSender.cancelJobs() appSettingsManager.clear() @@ -4717,7 +4739,8 @@ internal constructor( appVersion = this.appVersion, ) - val appSettingsManager = AppSettingManager(module.api()) + val api = module.api() + val appSettingsManager = AppSettingManager(api) val audioPlayer: AudioPlayer = StreamMediaPlayer( mediaPlayer = NativeMediaPlayerImpl { @@ -4732,12 +4755,15 @@ internal constructor( isMarshmallowOrHigher = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M, ) + val database = ChatClientDatabase.build(appContext) + val repository = ChatClientRepository.from(database) + return ChatClient( - config, - module.api(), - module.dtoMapping, - module.notifications(), - tokenManager, + config = config, + api = api, + dtoMapping = module.dtoMapping, + notifications = module.notifications(), + tokenManager = tokenManager, userCredentialStorage = userCredentialStorage ?: SharedPreferencesCredentialStorage(appContext), userStateService = module.userStateService, clientDebugger = clientDebugger ?: StubChatClientDebugger, @@ -4755,6 +4781,12 @@ internal constructor( mutableClientState = MutableClientState(module.networkStateProvider), currentUserFetcher = module.currentUserFetcher, audioPlayer = audioPlayer, + repository = repository, + messageReceiptReporter = MessageReceiptReporter( + scope = userScope, + messageReceiptRepository = repository, + api = api, + ), ).apply { attachmentsSender = AttachmentsSender( context = appContext, @@ -4799,7 +4831,10 @@ internal constructor( * @see [Plugin] * @see [PluginFactory] */ - protected val pluginFactories: MutableList = mutableListOf(ThrottlingPluginFactory) + protected val pluginFactories: MutableList = mutableListOf( + ThrottlingPluginFactory, + MessageDeliveredPluginFactory, + ) /** * Create a [ChatClient] instance based on the current configuration diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt index d2d7469592d..5587e9db2c5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt @@ -338,6 +338,11 @@ internal interface ChatApi { messageId: String = "", ): Call + @CheckResult + fun markDelivered( + messages: List, + ): Call + @CheckResult fun markThreadRead( channelType: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt index 42f3fe1f161..0cfd094e889 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt @@ -57,6 +57,7 @@ import io.getstream.chat.android.client.api2.model.requests.FlagUserRequest import io.getstream.chat.android.client.api2.model.requests.GuestUserRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.InviteMembersRequest +import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest import io.getstream.chat.android.client.api2.model.requests.MarkReadRequest import io.getstream.chat.android.client.api2.model.requests.MarkUnreadRequest import io.getstream.chat.android.client.api2.model.requests.MuteChannelRequest @@ -960,6 +961,11 @@ constructor( ).toUnitCall() } + override fun markDelivered(messages: List): Call = + channelApi.markDelivered( + request = MarkDeliveredRequest.create(messages), + ).toUnitCall() + override fun markThreadRead(channelType: String, channelId: String, threadId: String): Call { return channelApi.markRead( channelType = channelType, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt index 378b1c7a08a..de5df53fe44 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt @@ -23,6 +23,7 @@ import io.getstream.chat.android.client.api2.model.requests.AcceptInviteRequest import io.getstream.chat.android.client.api2.model.requests.AddMembersRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.InviteMembersRequest +import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest import io.getstream.chat.android.client.api2.model.requests.MarkReadRequest import io.getstream.chat.android.client.api2.model.requests.MarkUnreadRequest import io.getstream.chat.android.client.api2.model.requests.PinnedMessagesRequest @@ -211,4 +212,9 @@ internal interface ChannelApi { @Path("id") channelId: String, @UrlQueryPayload @Query("payload") payload: PinnedMessagesRequest, ): RetrofitCall + + @POST("/channels/delivered") + fun markDelivered( + @Body request: MarkDeliveredRequest, + ): RetrofitCall } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 8898ced4226..31ba28582da 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -16,6 +16,7 @@ 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 @@ -23,6 +24,7 @@ import io.getstream.chat.android.client.api2.model.dto.AttachmentDto import io.getstream.chat.android.client.api2.model.dto.ChannelInfoDto import io.getstream.chat.android.client.api2.model.dto.CommandDto import io.getstream.chat.android.client.api2.model.dto.ConfigDto +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.DownstreamChannelDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelMuteDto @@ -309,6 +311,7 @@ internal class DomainMapping( image = image ?: "", role = role, invisible = invisible, + privacySettings = privacy_settings?.toDomain(), language = language ?: "", banned = banned, devices = devices.orEmpty().map { it.toDomain() }, @@ -525,6 +528,8 @@ internal class DomainMapping( lastRead = last_read, unreadMessages = unread_messages, lastReadMessageId = last_read_message_id, + lastDeliveredAt = last_delivered_at, + lastDeliveredMessageId = last_delivered_message_id, ) /** @@ -599,6 +604,7 @@ internal class DomainMapping( name = name ?: "", typingEventsEnabled = typing_events, readEventsEnabled = read_events, + deliveryEventsEnabled = delivery_events, connectEventsEnabled = connect_events, searchEnabled = search, isReactionsEnabled = reactions, @@ -675,6 +681,7 @@ internal class DomainMapping( */ internal fun PrivacySettingsDto.toDomain(): PrivacySettings = PrivacySettings( typingIndicators = typing_indicators?.toDomain(), + deliveryReceipts = delivery_receipts?.toDomain(), readReceipts = read_receipts?.toDomain(), ) @@ -685,6 +692,11 @@ internal class DomainMapping( enabled = enabled, ) + /** + * Transforms [DeliveryReceiptsDto] to [DeliveryReceipts]. + */ + internal fun DeliveryReceiptsDto.toDomain() = DeliveryReceipts(enabled = enabled) + /** * Transforms [ReadReceiptsDto] to [ReadReceipts]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index 1119cadf93a..9199590eae6 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -46,6 +46,7 @@ import io.getstream.chat.android.client.api2.model.dto.MemberAddedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberRemovedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.MessageDeletedEventDto +import io.getstream.chat.android.client.api2.model.dto.MessageDeliveredEventDto import io.getstream.chat.android.client.api2.model.dto.MessageReadEventDto import io.getstream.chat.android.client.api2.model.dto.MessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto @@ -112,6 +113,7 @@ import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent import io.getstream.chat.android.client.events.MemberUpdatedEvent import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -189,6 +191,7 @@ internal class EventMapping( is MemberRemovedEventDto -> toDomain() is MemberUpdatedEventDto -> toDomain() is MessageDeletedEventDto -> toDomain() + is MessageDeliveredEventDto -> toDomain() is MessageReadEventDto -> toDomain() is MessageUpdatedEventDto -> toDomain() is NotificationAddedToChannelEventDto -> toDomain() @@ -407,6 +410,23 @@ internal class EventMapping( ) } + /** + * Transforms [MessageDeliveredEventDto] to [MessageDeliveredEvent]. + */ + private fun MessageDeliveredEventDto.toDomain() = with(domainMapping) { + MessageDeliveredEvent( + type = type, + createdAt = created_at.date, + rawCreatedAt = created_at.rawDate, + user = user.toDomain(), + cid = cid, + channelType = channel_type, + channelId = channel_id, + lastDeliveredAt = last_delivered_at.date, + lastDeliveredMessageId = last_delivered_message_id, + ) + } + /** * Transforms [MessageReadEventDto] to [MessageReadEvent]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelUserReadDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelUserReadDtos.kt index 10e3f11205e..414d88460a5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelUserReadDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelUserReadDtos.kt @@ -32,4 +32,6 @@ internal data class DownstreamChannelUserRead( val last_read: Date, val unread_messages: Int, val last_read_message_id: String?, + val last_delivered_at: Date? = null, + val last_delivered_message_id: String? = null, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt index 4f2e3f956de..cdddec56cda 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt @@ -26,6 +26,7 @@ internal data class ConfigDto( val name: String?, val typing_events: Boolean, val read_events: Boolean, + val delivery_events: Boolean = true, val connect_events: Boolean, val search: Boolean, val reactions: Boolean, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index 3594637957c..5d69b19df32 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -146,6 +146,18 @@ internal data class MessageDeletedEventDto( val deleted_for_me: Boolean? = null, ) : ChatEventDto() +@JsonClass(generateAdapter = true) +internal data class MessageDeliveredEventDto( + val type: String, + val created_at: ExactDate, + val user: DownstreamUserDto, + val cid: String, + val channel_type: String, + val channel_id: String, + val last_delivered_at: ExactDate, + val last_delivered_message_id: String, +) : ChatEventDto() + @JsonClass(generateAdapter = true) internal data class MessageReadEventDto( val type: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/PrivacySettings.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/PrivacySettings.kt index 9ea1a31ad94..31b7b74da12 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/PrivacySettings.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/PrivacySettings.kt @@ -21,6 +21,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) internal data class PrivacySettingsDto( val typing_indicators: TypingIndicatorsDto? = null, + val delivery_receipts: DeliveryReceiptsDto? = null, val read_receipts: ReadReceiptsDto? = null, ) @@ -29,6 +30,11 @@ internal data class TypingIndicatorsDto( val enabled: Boolean, ) +@JsonClass(generateAdapter = true) +internal data class DeliveryReceiptsDto( + val enabled: Boolean, +) + @JsonClass(generateAdapter = true) internal data class ReadReceiptsDto( val enabled: Boolean, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkDeliveredRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkDeliveredRequest.kt new file mode 100644 index 00000000000..5c4e2512d11 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkDeliveredRequest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.api2.model.requests + +import com.squareup.moshi.JsonClass +import io.getstream.chat.android.models.Message + +@JsonClass(generateAdapter = true) +internal data class MarkDeliveredRequest( + val latest_delivered_messages: List, +) { + companion object { + fun create(messages: List) = MarkDeliveredRequest( + latest_delivered_messages = messages.map { info -> + DeliveredMessageDto( + cid = info.cid, + id = info.id, + ) + }, + ) + } +} + +@JsonClass(generateAdapter = true) +internal data class DeliveredMessageDto( + val cid: String, + val id: String, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt index a1220168352..a693ecf2658 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt @@ -52,6 +52,7 @@ import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent import io.getstream.chat.android.client.events.MemberUpdatedEvent import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -271,6 +272,7 @@ public class ChannelClient internal constructor( is MemberUpdatedEvent -> event.cid == cid is MessageDeletedEvent -> event.cid == cid is MessageReadEvent -> event.cid == cid + is MessageDeliveredEvent -> event.cid == cid is MessageUpdatedEvent -> event.cid == cid is NewMessageEvent -> event.cid == cid is NotificationAddedToChannelEvent -> event.cid == cid diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt index ae25818ccd1..6eef50a9a16 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt @@ -295,6 +295,21 @@ public data class MessageReadEvent( val thread: ThreadInfo? = null, ) : CidEvent(), UserEvent +/** + * Triggered when a message is marked as delivered + */ +public data class MessageDeliveredEvent( + override val type: String, + override val createdAt: Date, + override val rawCreatedAt: String, + override val user: User, + override val cid: String, + override val channelType: String, + override val channelId: String, + val lastDeliveredAt: Date, + val lastDeliveredMessageId: String, +) : CidEvent(), UserEvent + /** * Triggered when a message is updated */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt index 386ca4852a7..6d117c85915 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt @@ -17,11 +17,14 @@ package io.getstream.chat.android.client.extensions import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.extensions.internal.containsUserMention import io.getstream.chat.android.client.extensions.internal.wasCreatedAfter import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.models.UserId @@ -85,7 +88,7 @@ public fun Channel.getMembersExcludingCurrent( * @return Number of messages containing unread user mention. */ public fun Channel.countUnreadMentionsForUser(user: User): Int { - val lastMessageSeenDate = read.firstOrNull { read -> read.user.id == user.id }?.lastRead + val lastMessageSeenDate = userRead(user.id)?.lastRead val messagesToCheck = if (lastMessageSeenDate == null) { messages @@ -103,7 +106,7 @@ public fun Channel.countUnreadMentionsForUser(user: User): Int { */ public fun Channel.currentUserUnreadCount( currentUserId: UserId? = ChatClient.instance().getCurrentUser()?.id, -): Int = read.firstOrNull { it.user.id == currentUserId }?.unreadMessages ?: 0 +): Int = currentUserId?.let(::userRead)?.unreadMessages ?: 0 /** * Synchronizes the unread count of the channel with the read state of the current user. @@ -117,3 +120,46 @@ public fun Channel.syncUnreadCountWithReads( currentUserId: UserId? = ChatClient.instance().getCurrentUser()?.id, ): Channel = copy(unreadCount = currentUserUnreadCount(currentUserId)) + +/** + * Returns the user's read state for this channel. + * + * @param userId The ID of the user whose read state is to be retrieved. + * @return The [ChannelUserRead] object representing the user's read state, or null if not found. + */ +public fun Channel.userRead(userId: UserId): ChannelUserRead? = + read.firstOrNull { read -> read.user.id == userId } + +/** + * Returns a list of [ChannelUserRead] objects representing which ones have + * read the given [message]. + * + * A message is considered read by a user if: + * - The user is not the sender of the message + * - The user has read the message + * + * @param message The [Message] object for which to find read reads. + * @return A list of [ChannelUserRead] objects representing users who have read the message + */ +public fun Channel.readsOf(message: Message): List = + read.filter { read -> + read.user.id != message.user.id && + read.lastRead >= message.getCreatedAtOrThrow() + } + +/** + * Returns a list of [ChannelUserRead] objects representing which ones have + * delivered the given [message]. + * + * A message is considered delivered to a user if: + * - The user is not the sender of the message + * - The user has received (delivered) the message + * + * @param message The [Message] object for which to find delivered reads. + * @return A list of [ChannelUserRead] objects representing users who have delivered the message. + */ +public fun Channel.deliveredReadsOf(message: Message): List = + read.filter { read -> + read.user.id != message.user.id && + (read.lastDeliveredAt ?: NEVER) >= message.getCreatedAtOrThrow() + } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt index 6ac6e41be16..49482a506f4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.extensions.internal import io.getstream.chat.android.client.extensions.syncUnreadCountWithReads +import io.getstream.chat.android.client.extensions.userRead import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.core.internal.InternalStreamChatApi @@ -216,7 +217,7 @@ public fun Channel.removeMembership(currentUserId: String?): Channel = */ @InternalStreamChatApi public fun Channel.updateReads(newRead: ChannelUserRead, currentUserId: UserId): Channel { - val oldRead = read.firstOrNull { it.user.id == newRead.user.id } + val oldRead = userRead(newRead.user.id) return copy( read = if (oldRead != null) { read - oldRead + newRead diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt index dbc804c1987..4ed3d986a8b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt @@ -38,7 +38,12 @@ internal interface ChatNotifications { fun onSetUser(user: User) fun setDevice(device: Device) suspend fun deleteDevice() - fun onPushMessage(message: PushMessage, pushNotificationReceivedListener: PushNotificationReceivedListener) + fun onPushMessage( + pushMessage: PushMessage, + pushNotificationReceivedListener: PushNotificationReceivedListener = + PushNotificationReceivedListener { _, _ -> }, + ) + fun onChatEvent(event: ChatEvent) suspend fun onLogout() fun displayNotification(notification: ChatNotification) @@ -51,9 +56,11 @@ internal class ChatNotificationsImpl( private val notificationConfig: NotificationConfig, private val context: Context, private val scope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), + private val chatClientProvider: () -> ChatClient = { ChatClient.instance() }, ) : ChatNotifications { private val logger by taggedLogger("Chat:Notifications") + private val chatClient: ChatClient by lazy { chatClientProvider() } private val pushTokenUpdateHandler = PushTokenUpdateHandler() private val showedMessages = mutableSetOf() private val permissionManager: NotificationPermissionManager = @@ -95,15 +102,17 @@ internal class ChatNotificationsImpl( } override fun onPushMessage( - message: PushMessage, + pushMessage: PushMessage, pushNotificationReceivedListener: PushNotificationReceivedListener, ) { - logger.i { "[onReceivePushMessage] message: $message" } + logger.i { "[onPushMessage] message: $pushMessage" } + + pushNotificationReceivedListener.onPushNotificationReceived(pushMessage.channelType, pushMessage.channelId) - pushNotificationReceivedListener.onPushNotificationReceived(message.channelType, message.channelId) + scope.launch { chatClient.messageReceiptManager.markMessageAsDelivered(pushMessage.messageId) } - if (notificationConfig.shouldShowNotificationOnPush() && !handler.onPushMessage(message)) { - handlePushMessage(message) + if (notificationConfig.shouldShowNotificationOnPush() && !handler.onPushMessage(pushMessage)) { + handlePushMessage(pushMessage) } } @@ -193,6 +202,7 @@ internal class ChatNotificationsImpl( handler.showNotification(notification) } } + else -> handler.showNotification(notification) } } @@ -210,7 +220,7 @@ internal object NoOpChatNotifications : ChatNotifications { override fun setDevice(device: Device) = Unit override suspend fun deleteDevice() = Unit override fun onPushMessage( - message: PushMessage, + pushMessage: PushMessage, pushNotificationReceivedListener: PushNotificationReceivedListener, ) = Unit diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt index 04f0eeb4b3d..5b3eca70e78 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt @@ -48,6 +48,7 @@ import io.getstream.chat.android.client.api2.model.dto.MemberAddedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberRemovedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.MessageDeletedEventDto +import io.getstream.chat.android.client.api2.model.dto.MessageDeliveredEventDto import io.getstream.chat.android.client.api2.model.dto.MessageReadEventDto import io.getstream.chat.android.client.api2.model.dto.MessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto @@ -115,6 +116,7 @@ internal class EventDtoAdapter( private val messageDeletedEventAdapter = moshi.adapter(MessageDeletedEventDto::class.java) private val messageUpdatedEventAdapter = moshi.adapter(MessageUpdatedEventDto::class.java) private val messageReadEventAdapter = moshi.adapter(MessageReadEventDto::class.java) + private val messageDeliveredEventAdapter = moshi.adapter(MessageDeliveredEventDto::class.java) private val typingStartEventAdapter = moshi.adapter(TypingStartEventDto::class.java) private val typingStopEventAdapter = moshi.adapter(TypingStopEventDto::class.java) private val reactionNewEventAdapter = moshi.adapter(ReactionNewEventDto::class.java) @@ -196,6 +198,7 @@ internal class EventDtoAdapter( map.containsKey("cid") -> messageReadEventAdapter else -> markAllReadEventAdapter } + EventType.MESSAGE_DELIVERED -> messageDeliveredEventAdapter EventType.TYPING_START -> typingStartEventAdapter EventType.TYPING_STOP -> typingStopEventAdapter EventType.REACTION_NEW -> reactionNewEventAdapter diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/ChatClientDatabase.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/ChatClientDatabase.kt new file mode 100644 index 00000000000..a4c76e90a73 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/ChatClientDatabase.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.sqlite.db.SupportSQLiteDatabase +import io.getstream.chat.android.client.persistence.db.converter.DateConverter +import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity + +@Database( + entities = [MessageReceiptEntity::class], + version = 1, + exportSchema = false, +) +@TypeConverters( + DateConverter::class, +) +internal abstract class ChatClientDatabase : RoomDatabase() { + abstract fun messageReceiptDao(): MessageReceiptDao + + companion object { + fun build(context: Context) = Room.databaseBuilder( + context = context.applicationContext, + klass = ChatClientDatabase::class.java, + name = "stream_chat_client.db", + ) + .fallbackToDestructiveMigration() + .addCallback( + object : Callback() { + override fun onOpen(db: SupportSQLiteDatabase) { + db.execSQL("PRAGMA synchronous = NORMAL") + } + }, + ) + .build() + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/converter/DateConverter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/converter/DateConverter.kt new file mode 100644 index 00000000000..a1dbbe4b78a --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/converter/DateConverter.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.db.converter + +import androidx.room.TypeConverter +import java.util.Date + +internal class DateConverter { + @TypeConverter + fun fromDb(value: Long?): Date? = value?.let(::Date) + + @TypeConverter + fun toDb(date: Date?): Long? = date?.time +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt new file mode 100644 index 00000000000..1d21d68fdc8 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity + +@Dao +internal interface MessageReceiptDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(receipts: List) + + @Query("SELECT * FROM message_receipt WHERE type = :type ORDER BY createdAt ASC LIMIT :limit") + suspend fun selectAllByType(type: String, limit: Int): List + + @Query("DELETE FROM message_receipt WHERE messageId IN (:messageIds)") + suspend fun deleteByMessageIds(messageIds: List) + + @Query("DELETE FROM message_receipt") + suspend fun deleteAll() +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/entity/MessageReceiptEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/entity/MessageReceiptEntity.kt new file mode 100644 index 00000000000..e63f2a06254 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/entity/MessageReceiptEntity.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.Date + +@Entity(tableName = "message_receipt") +internal data class MessageReceiptEntity( + @PrimaryKey + val messageId: String, + val type: String, + val createdAt: Date, + val cid: String, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt new file mode 100644 index 00000000000..d5e37631be1 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.repository + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.persistence.db.ChatClientDatabase + +/** + * Repository that aggregates all internal repositories used by [ChatClient]. + */ +internal class ChatClientRepository( + private val messageReceiptRepository: MessageReceiptRepository, +) : MessageReceiptRepository by messageReceiptRepository { + + suspend fun clear() { + messageReceiptRepository.clearMessageReceipts() + } + + companion object { + fun from(database: ChatClientDatabase) = ChatClientRepository( + messageReceiptRepository = MessageReceiptRepository(database), + ) + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt new file mode 100644 index 00000000000..cbd013fad9b --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.repository + +import io.getstream.chat.android.client.persistence.db.ChatClientDatabase +import io.getstream.chat.android.client.receipts.MessageReceipt + +internal interface MessageReceiptRepository { + + companion object { + operator fun invoke(database: ChatClientDatabase): MessageReceiptRepository = + MessageReceiptRepositoryImpl( + dao = database.messageReceiptDao(), + ) + } + + suspend fun upsertMessageReceipts(receipts: List) + + suspend fun getAllMessageReceiptsByType(type: String, limit: Int): List + + suspend fun deleteMessageReceiptsByMessageIds(messageIds: List) + + suspend fun clearMessageReceipts() +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt new file mode 100644 index 00000000000..f5302815f84 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.repository + +import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity +import io.getstream.chat.android.client.receipts.MessageReceipt + +internal class MessageReceiptRepositoryImpl( + private val dao: MessageReceiptDao, +) : MessageReceiptRepository { + + override suspend fun upsertMessageReceipts(receipts: List) { + dao.upsert(receipts.map(MessageReceipt::toEntity)) + } + + override suspend fun getAllMessageReceiptsByType(type: String, limit: Int): List = + dao.selectAllByType(type, limit).map(MessageReceiptEntity::toModel) + + override suspend fun deleteMessageReceiptsByMessageIds(messageIds: List) { + dao.deleteByMessageIds(messageIds) + } + + override suspend fun clearMessageReceipts() { + dao.deleteAll() + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryMapper.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryMapper.kt new file mode 100644 index 00000000000..49b21581848 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryMapper.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.repository + +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity +import io.getstream.chat.android.client.receipts.MessageReceipt + +internal fun MessageReceipt.toEntity() = MessageReceiptEntity( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) + +internal fun MessageReceiptEntity.toModel() = MessageReceipt( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt new file mode 100644 index 00000000000..db1eb8ca6de --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.plugin + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.plugin.factory.PluginFactory +import io.getstream.chat.android.client.receipts.MessageReceiptManager +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.User +import io.getstream.result.Result +import io.getstream.result.onSuccessSuspend + +/** + * A plugin that marks messages as delivered when channels are queried. + */ +internal class MessageDeliveredPlugin( + chatClient: ChatClient = ChatClient.instance(), +) : Plugin { + private val messageReceiptManager: MessageReceiptManager by lazy { chatClient.messageReceiptManager } + + override suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) { + result.onSuccessSuspend { channels -> + messageReceiptManager.markChannelsAsDelivered(channels) + } + } + + override suspend fun onQueryChannelResult( + result: Result, + channelType: String, + channelId: String, + request: QueryChannelRequest, + ) { + result.onSuccessSuspend { channel -> + if (request.pagination() == null) { // only mark as delivered on initial load + messageReceiptManager.markChannelsAsDelivered(channels = listOf(channel)) + } + } + } +} + +internal object MessageDeliveredPluginFactory : PluginFactory { + override fun get(user: User): Plugin = MessageDeliveredPlugin() +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt index 7efef402730..5551fd067c3 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt @@ -18,7 +18,6 @@ package io.getstream.chat.android.client.plugin import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelRequest -import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.errorhandler.ErrorHandler import io.getstream.chat.android.client.events.ChatEvent @@ -274,20 +273,6 @@ public interface Plugin : /* No-Op */ } - override suspend fun onQueryChannelsPrecondition(request: QueryChannelsRequest): Result = - Result.Success(Unit) - - override suspend fun onQueryChannelsRequest(request: QueryChannelsRequest) { - /* No-Op */ - } - - override suspend fun onQueryChannelsResult( - result: Result>, - request: QueryChannelsRequest, - ) { - /* No-Op */ - } - override fun onTypingEventPrecondition( eventType: String, channelType: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt index b578cdff4c3..2ff5e7f7aef 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt @@ -35,19 +35,21 @@ public interface QueryChannelsListener { * * @return [Result.Success] if precondition passes otherwise [Result.Failure] */ - public suspend fun onQueryChannelsPrecondition( - request: QueryChannelsRequest, - ): Result + public suspend fun onQueryChannelsPrecondition(request: QueryChannelsRequest): Result = + Result.Success(Unit) /** * Runs side effect before the request is launched. * * @param request [QueryChannelsRequest] which is going to be used for the request. */ - public suspend fun onQueryChannelsRequest(request: QueryChannelsRequest) + public suspend fun onQueryChannelsRequest(request: QueryChannelsRequest) { /* No-Op */ } /** * Runs this function on the [Result] of this [QueryChannelsRequest]. */ - public suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) + public suspend fun onQueryChannelsResult( + result: Result>, + request: QueryChannelsRequest, + ) { /* No-Op */ } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceipt.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceipt.kt new file mode 100644 index 00000000000..a70d5226f14 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceipt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.receipts + +import java.util.Date + +internal data class MessageReceipt( + val messageId: String, + val type: String, + val createdAt: Date, + val cid: String, +) { + companion object { + const val TYPE_DELIVERY: String = "delivery" + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt new file mode 100644 index 00000000000..fc8824da9b3 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.receipts + +import io.getstream.chat.android.client.api.ChatApi +import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.extensions.getCreatedAtOrThrow +import io.getstream.chat.android.client.extensions.internal.NEVER +import io.getstream.chat.android.client.extensions.internal.lastMessage +import io.getstream.chat.android.client.extensions.userRead +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade +import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.User +import io.getstream.log.taggedLogger +import java.util.Date + +/** + * Manages message delivery receipts: creating and storing them in the repository + * for later reporting to the server. + */ +internal class MessageReceiptManager( + private val now: () -> Date, + private val getCurrentUser: () -> User?, + private val repositoryFacade: RepositoryFacade, + private val messageReceiptRepository: MessageReceiptRepository, + private val api: ChatApi, +) { + + private val logger by taggedLogger("Chat:MessageReceiptManager") + + /** + * Request to mark the given channels as delivered + * if delivery receipts are enabled for the current user. + * + * A channel can have a message marked as delivered only if: + * - Delivery events are enabled in the channel config + * + * A delivery message candidate is the last non-deleted message in the channel that: + * - It was not sent by the current user + * - Is not shadow banned + * - Was not sent by a muted user + * - Is not yet marked as read by the current user + * - Is not yet marked as delivered by the current user + */ + suspend fun markChannelsAsDelivered(channels: List) { + val currentUser = getCurrentUser() ?: run { + logger.w { "[markChannelsAsDelivered] Current user is null" } + return + } + + if (!currentUser.isDeliveryReceiptsEnabled) return + + val deliveredMessageCandidates = channels.mapNotNull { channel -> + channel.lastMessage?.takeIf { lastNonDeletedMessage -> + canMarkMessageAsDelivered(currentUser, channel, lastNonDeletedMessage) + } + } + markMessagesAsDelivered(messages = deliveredMessageCandidates) + } + + /** + * Request to mark the given message as delivered + * if delivery receipts are enabled for the current user. + * + * @see [markChannelsAsDelivered] for the conditions to mark a message as delivered. + */ + suspend fun markMessageAsDelivered(message: Message) { + val currentUser = getCurrentUser() ?: run { + logger.w { "[markMessageAsDelivered] Current user is null" } + return + } + + if (!currentUser.isDeliveryReceiptsEnabled) return + + val channel = retrieveChannel(message.cid) ?: run { + logger.w { "[markMessageAsDelivered] Channel ${message.cid} not found" } + return + } + + if (canMarkMessageAsDelivered(currentUser, channel, message)) { + markMessagesAsDelivered(messages = listOf(message)) + } + } + + /** + * Request to mark the message with the given id as delivered. + * + * @see [markChannelsAsDelivered] for the conditions to mark a message as delivered. + */ + suspend fun markMessageAsDelivered(messageId: String) { + val message = retrieveMessage(messageId) ?: run { + logger.w { "[markMessageAsDelivered] Message $messageId not found" } + return + } + + markMessageAsDelivered(message) + } + + private suspend fun retrieveChannel(cid: String): Channel? = + repositoryFacade.selectChannel(cid) ?: run { + val (channelType, channelId) = cid.cidToTypeAndId() + val request = QueryChannelRequest() + api.queryChannel(channelType, channelId, request) + .await().getOrNull() + } + + private suspend fun retrieveMessage(id: String): Message? = + repositoryFacade.selectMessage(id) ?: run { + api.getMessage(id) + .await().getOrNull() + } + + private suspend fun markMessagesAsDelivered(messages: List) { + if (messages.isEmpty()) { + logger.w { "[markMessagesAsDelivered] No receipts to send" } + return + } + + logger.d { "[markMessagesAsDelivered] Processing delivery receipts for ${messages.size} messages…" } + + val receipts = messages.map { message -> message.toDeliveryReceipt() } + messageReceiptRepository.upsertMessageReceipts(receipts) + + logger.d { "[markMessagesAsDelivered] ${messages.size} delivery receipts upserted" } + } + + private fun canMarkMessageAsDelivered( + currentUser: User, + channel: Channel, + message: Message, + ): Boolean { + // Check if delivery events are enabled for the channel + if (!channel.config.deliveryEventsEnabled) { + logger.w { "[canMarkMessageAsDelivered] Delivery events disabled for channel ${channel.cid}" } + return false + } + + // Do not send delivery receipts for messages sent by the current user + if (message.user.id == currentUser.id) { + logger.w { + "[canMarkMessageAsDelivered] Message ${message.id} was sent by the current user ${currentUser.id}" + } + return false + } + + // Do not send delivery receipts for shadowed messages + if (message.shadowed) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is shadowed" } + return false + } + + // Do not send delivery receipts for messages sent by muted users + if (currentUser.mutes.any { mute -> mute.target?.id == message.user.id }) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} was sent by a muted user ${message.user.id}" } + return false + } + + val userRead = channel.userRead(currentUser.id) ?: run { + logger.w { + "[canMarkMessageAsDelivered] No read state for user ${currentUser.id} in channel ${channel.cid}" + } + return false + } + + val createdAt = message.getCreatedAtOrThrow() + + // Check if the last message is already marked as read + if (createdAt <= userRead.lastRead) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is already marked as read" } + return false + } + + // Check if the last message is already marked as delivered + if (createdAt <= (userRead.lastDeliveredAt ?: NEVER)) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is already marked as delivered" } + return false + } + + return true + } + + private fun Message.toDeliveryReceipt() = MessageReceipt( + messageId = id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = now(), + cid = cid, + ) +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt new file mode 100644 index 00000000000..34904d779c7 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.receipts + +import io.getstream.chat.android.client.api.ChatApi +import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository +import io.getstream.chat.android.models.Message +import io.getstream.log.taggedLogger +import io.getstream.result.onSuccessSuspend +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Reports message delivery receipts to the server in batches of [MAX_BATCH_SIZE] + * every [REPORT_INTERVAL_IN_MS] milliseconds. + */ +internal class MessageReceiptReporter( + private val scope: CoroutineScope, + private val messageReceiptRepository: MessageReceiptRepository, + private val api: ChatApi, +) { + + private val logger by taggedLogger("Chat:MessageReceiptReporter") + + fun start() { + logger.d { "Starting reporter…" } + scope.launch { + try { + while (isActive) { + val messages = messageReceiptRepository.getAllMessageReceiptsByType( + type = MessageReceipt.TYPE_DELIVERY, + limit = MAX_BATCH_SIZE, + ).map { receipt -> + Message( + id = receipt.messageId, + cid = receipt.cid, + ) + } + + if (messages.isNotEmpty()) { + logger.d { "Reporting delivery receipts for ${messages.size} messages…" } + api.markDelivered(messages) + .execute() + .onSuccessSuspend { + logger.d { "Successfully reported delivery receipts for ${messages.size} messages" } + val deliveredMessageIds = messages.map(Message::id) + messageReceiptRepository.deleteMessageReceiptsByMessageIds(deliveredMessageIds) + } + .onError { error -> + logger.e { + "Failed to report delivery receipts for ${messages.size} messages: " + + error.message + } + } + } + + delay(REPORT_INTERVAL_IN_MS) + } + } finally { + logger.d { "Reporter is no longer active" } + } + } + } +} + +private const val REPORT_INTERVAL_IN_MS = 1000L +private const val MAX_BATCH_SIZE = 100 diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt index 7bf5589aeb5..121833c82c8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt @@ -1528,6 +1528,10 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { whenever(api.markRead(any(), any(), any())).thenReturn(result) } + fun givenMarkDeliveredResult(result: Call) = apply { + whenever(api.markDelivered(any())).thenReturn(result) + } + fun givenMarkUnreadResult(result: Call) = apply { whenever(api.markUnread(any(), any(), any())).thenReturn(result) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt index 9476f6c1f87..24a7103140f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt @@ -31,7 +31,6 @@ import io.getstream.chat.android.client.scope.UserTestScope import io.getstream.chat.android.client.setup.state.internal.MutableClientState import io.getstream.chat.android.client.socket.FakeChatSocket import io.getstream.chat.android.client.token.FakeTokenManager -import io.getstream.chat.android.client.token.TokenManager import io.getstream.chat.android.client.user.CredentialConfig import io.getstream.chat.android.client.user.storage.UserCredentialStorage import io.getstream.chat.android.client.utils.TokenUtils @@ -91,7 +90,6 @@ internal class ChatClientConnectionTests { private val mutableClientState: MutableClientState = MutableClientState(mock()) private val streamDateFormatter = StreamDateFormatter() private val config = mock() - private val tokenManager = mock() private val userCredentialStorage = mock() @BeforeEach @@ -133,6 +131,8 @@ internal class ChatClientConnectionTests { mutableClientState = mutableClientState, currentUserFetcher = mock(), audioPlayer = mock(), + repository = mock(), + messageReceiptReporter = mock(), ).apply { attachmentsSender = mock() } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt index c9e13ddc11a..5dc492b956f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt @@ -18,24 +18,20 @@ package io.getstream.chat.android.client import androidx.lifecycle.Lifecycle import androidx.lifecycle.testing.TestLifecycleOwner -import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.api.ChatClientConfig import io.getstream.chat.android.client.api2.mapping.DtoMapping import io.getstream.chat.android.client.clientstate.DisconnectCause import io.getstream.chat.android.client.clientstate.UserStateService -import io.getstream.chat.android.client.errorhandler.factory.ErrorHandlerFactory import io.getstream.chat.android.client.errors.ChatErrorCode import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.DisconnectedEvent import io.getstream.chat.android.client.events.UnknownEvent import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.client.network.NetworkStateProvider -import io.getstream.chat.android.client.notifications.ChatNotifications import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.parser.EventArguments import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateFormatter import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory -import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.scope.ClientTestScope import io.getstream.chat.android.client.scope.UserTestScope import io.getstream.chat.android.client.socket.FakeChatSocket @@ -55,7 +51,8 @@ import io.getstream.result.Error import io.getstream.result.Result import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest -import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -69,7 +66,7 @@ import java.util.Date internal class ChatClientTest { - companion object { + private companion object { @JvmField @RegisterExtension val testCoroutines = TestCoroutineExtension() @@ -86,43 +83,36 @@ internal class ChatClientTest { val eventF = UnknownEvent("f", createdAt, rawCreatedAt, null, emptyMap()) } - lateinit var lifecycleOwner: TestLifecycleOwner - lateinit var api: ChatApi - lateinit var client: ChatClient - lateinit var fakeChatSocket: FakeChatSocket - lateinit var result: MutableList - val token = randomString() - val userId = randomString() - val user = randomUser(id = userId) - val tokenUtils: TokenUtils = mock() - var pluginFactories: List = emptyList() - var errorHandlerFactories: List = emptyList() - private val streamDateFormatter = StreamDateFormatter() + private lateinit var lifecycleOwner: TestLifecycleOwner + private lateinit var client: ChatClient + private lateinit var fakeChatSocket: FakeChatSocket + private lateinit var result: MutableList + private val token = randomString() + private val userId = randomString() + private val user = randomUser(id = userId) + private val tokenUtils: TokenUtils = mock() @BeforeEach fun setUp() { val apiKey = "api-key" val wssUrl = "socket.url" val config = ChatClientConfig( - apiKey, - "hello.http", - "cdn.http", - wssUrl, - false, - Mother.chatLoggerConfig(), - false, - false, - NotificationConfig(), + apiKey = apiKey, + httpUrl = "hello.http", + cdnHttpUrl = "cdn.http", + wssUrl = wssUrl, + warmUp = false, + loggerConfig = Mother.chatLoggerConfig(), + distinctApiCalls = false, + debugRequests = false, + notificationConfig = NotificationConfig(), ) whenever(tokenUtils.getUserId(token)) doReturn userId lifecycleOwner = TestLifecycleOwner(coroutineDispatcher = testCoroutines.dispatcher) - api = mock() - val userStateService = UserStateService() val clientScope = ClientTestScope(testCoroutines.scope) val userScope = UserTestScope(clientScope) val lifecycleObserver = StreamLifecycleObserver(userScope, lifecycleOwner.lifecycle) val tokenManager = FakeTokenManager("") - val notifications = mock() val networkStateProvider: NetworkStateProvider = mock() whenever(networkStateProvider.isConnected()) doReturn true fakeChatSocket = FakeChatSocket( @@ -135,23 +125,25 @@ internal class ChatClientTest { ) client = ChatClient( config = config, - api = api, + api = mock(), dtoMapping = DtoMapping(NoOpMessageTransformer, NoOpUserTransformer), - notifications = notifications, + notifications = mock(), tokenManager = tokenManager, userCredentialStorage = mock(), - userStateService = userStateService, + userStateService = UserStateService(), tokenUtils = tokenUtils, clientScope = clientScope, userScope = userScope, retryPolicy = NoRetryPolicy(), appSettingsManager = mock(), chatSocket = fakeChatSocket, - pluginFactories = pluginFactories, + pluginFactories = emptyList(), mutableClientState = Mother.mockedClientState(), repositoryFactoryProvider = NoOpRepositoryFactory.Provider, currentUserFetcher = mock(), audioPlayer = mock(), + repository = mock(), + messageReceiptReporter = mock(), ).apply { attachmentsSender = mock() connectUser(user, token).enqueue() @@ -166,8 +158,8 @@ internal class ChatClientTest { val channelId = randomString() val channelClient = client.channel(channelType, channelId) - channelClient.channelType shouldBeEqualTo channelType - channelClient.channelId shouldBeEqualTo channelId + assertEquals(channelType, channelClient.channelType) + assertEquals(channelId, channelClient.channelId) } @Test @@ -176,23 +168,23 @@ internal class ChatClientTest { val channelClient = client.channel(cid) val (type, id) = cid.cidToTypeAndId() - channelClient.channelType shouldBeEqualTo type - channelClient.channelId shouldBeEqualTo id + assertEquals(type, channelClient.channelType) + assertEquals(id, channelClient.channelId) } @Test - fun `Simple subscribe for one event`() = runTest { + fun `Simple subscribe for one event`() { client.subscribe { result.add(it) } fakeChatSocket.mockEventReceived(eventB) - result shouldBeEqualTo listOf(eventB) + assertEquals(listOf(eventB), result) } @Test - fun `Simple subscribe for multiple events`() = runTest { + fun `Simple subscribe for multiple events`() { client.subscribe { result.add(it) } @@ -201,7 +193,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventB) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventA, eventB, eventC) + assertEquals(listOf(eventA, eventB, eventC), result) } @Test @@ -216,11 +208,11 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventE) fakeChatSocket.mockEventReceived(eventD) - result shouldBeEqualTo listOf(eventD, eventF, eventD) + assertEquals(listOf(eventD, eventF, eventD), result) } @Test - fun `Subscribe for Java Class event types`() = runTest { + fun `Subscribe for Java Class event types`() { client.subscribeFor(eventA::class.java, eventC::class.java) { result.add(it) } @@ -229,11 +221,11 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventB) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventA, eventC) + assertEquals(listOf(eventA, eventC), result) } @Test - fun `Subscribe for KClass event types`() = runTest { + fun `Subscribe for KClass event types`() { client.subscribeFor(eventA::class, eventC::class) { result.add(it) } @@ -242,11 +234,11 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventB) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventA, eventC) + assertEquals(listOf(eventA, eventC), result) } @Test - fun `Subscribe for event types with type parameter`() = runTest { + fun `Subscribe for event types with type parameter`() { client.subscribeFor { result.add(it) } @@ -255,11 +247,11 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventD) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventD) + assertEquals(listOf(eventD), result) } @Test - fun `Subscribe for single string event type`() = runTest { + fun `Subscribe for single string event type`() { client.subscribeForSingle("d") { result.add(it) } @@ -269,11 +261,11 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventD) fakeChatSocket.mockEventReceived(eventE) - result shouldBeEqualTo listOf(eventD) + assertEquals(listOf(eventD), result) } @Test - fun `Subscribe for single event, with event type as type parameter`() = runTest { + fun `Subscribe for single event, with event type as type parameter`() { client.subscribeForSingle { result.add(it) } @@ -282,11 +274,11 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventD) fakeChatSocket.mockEventReceived(eventE) - result shouldBeEqualTo listOf(eventD) + assertEquals(listOf(eventD), result) } @Test - fun `Unsubscribe from events`() = runTest { + fun `Unsubscribe from events`() { val disposable = client.subscribe { result.add(it) } @@ -298,11 +290,11 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventB) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventA) + assertEquals(listOf(eventA), result) } @Test - fun `Given connected user When handle event with updated user Should updated user value`() = runTest { + fun `Given connected user When handle event with updated user Should updated user value`() { val updateUser = user.copy( extraData = mutableMapOf(), name = "updateUserName", @@ -310,7 +302,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(Mother.randomUserPresenceChangedEvent(user = updateUser)) - client.getCurrentUser() shouldBeEqualTo updateUser + assertEquals(updateUser, client.getCurrentUser()) } @Test @@ -344,7 +336,7 @@ internal class ChatClientTest { fakeChatSocket.verifySocketFactory { verify(it, times(1)).createSocket(any()) } - client.clientState.connectionState.value shouldBeEqualTo ConnectionState.Offline + assertEquals(ConnectionState.Offline, client.clientState.connectionState.value) } @Test @@ -372,12 +364,15 @@ internal class ChatClientTest { val result = client.reconnectSocket().await() /* Then */ - result shouldBeEqualTo Result.Failure( - value = Error.GenericError(message = "Invalid user state NotSet without user being set!"), + assertEquals( + Result.Failure( + value = Error.GenericError(message = "Invalid user state NotSet without user being set!"), + ), + result, ) - client.getCurrentUser() shouldBeEqualTo null - client.clientState.user.value shouldBeEqualTo null - client.clientState.connectionState.value shouldBeEqualTo ConnectionState.Offline - client.clientState.initializationState.value shouldBeEqualTo InitializationState.NOT_INITIALIZED + assertNull(client.getCurrentUser()) + assertNull(client.clientState.user.value) + assertEquals(ConnectionState.Offline, client.clientState.connectionState.value) + assertEquals(InitializationState.NOT_INITIALIZED, client.clientState.initializationState.value) } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt index 7478655da81..537831f06c8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client +import io.getstream.chat.android.client.DependencyResolverTest.Companion.initializationStatesArguments import io.getstream.chat.android.client.api2.mapping.DtoMapping import io.getstream.chat.android.client.plugin.Plugin import io.getstream.chat.android.client.plugin.factory.PluginFactory @@ -178,6 +179,8 @@ public class DependencyResolverTest { mutableClientState = mutableClientState, currentUserFetcher = mock(), audioPlayer = mock(), + repository = mock(), + messageReceiptReporter = mock(), ).apply { this.plugins = this@Fixture.plugins } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt index 11c2ccf1f89..368cb2b149d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt @@ -691,6 +691,7 @@ private fun createConfigJsonString() = "name": "team", "typing_events": true, "read_events": true, + "delivery_events": true, "connect_events": true, "search": true, "reactions": true, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt index e776b73b57f..43a474b76a1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt @@ -126,6 +126,8 @@ internal class MockClientBuilder( mutableClientState = mutableClientState, currentUserFetcher = mock(), audioPlayer = streamPlayer, + repository = mock(), + messageReceiptReporter = mock(), ) client.attachmentsSender = attachmentSender diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index 88d6867f63b..72b9d1dba3d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -81,6 +81,8 @@ import io.getstream.chat.android.client.logger.ChatLoggerConfig import io.getstream.chat.android.client.logger.ChatLoggerHandler import io.getstream.chat.android.client.network.NetworkStateProvider import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateFormatter +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity +import io.getstream.chat.android.client.receipts.MessageReceipt import io.getstream.chat.android.client.setup.state.internal.MutableClientState import io.getstream.chat.android.client.socket.ChatSocketStateService import io.getstream.chat.android.client.socket.SocketFactory @@ -89,6 +91,7 @@ import io.getstream.chat.android.models.ConnectionState 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.PushMessage import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter @@ -102,6 +105,7 @@ import io.getstream.chat.android.randomExtraData import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomPendingMessageMetadata import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomStringOrNull import io.getstream.chat.android.randomUser import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -477,6 +481,7 @@ internal object Mother { name: String? = randomString(), typing_events: Boolean = randomBoolean(), read_events: Boolean = randomBoolean(), + delivery_events: Boolean = randomBoolean(), connect_events: Boolean = randomBoolean(), search: Boolean = randomBoolean(), reactions: Boolean = randomBoolean(), @@ -503,6 +508,7 @@ internal object Mother { name = name, typing_events = typing_events, read_events = read_events, + delivery_events = delivery_events, connect_events = connect_events, search = search, reactions = reactions, @@ -714,12 +720,16 @@ internal object Mother { user: DownstreamUserDto = randomDownstreamUserDto(), lastRead: Date = randomDate(), unreadMessages: Int = randomInt(), - lastReadMessageId: String? = randomString(), - ): DownstreamChannelUserRead = DownstreamChannelUserRead( + lastReadMessageId: String? = randomStringOrNull(), + lastDeliveredAt: Date? = randomDateOrNull(), + lastDeliveredMessageId: String? = randomStringOrNull(), + ) = DownstreamChannelUserRead( user = user, last_read = lastRead, unread_messages = unreadMessages, last_read_message_id = lastReadMessageId, + last_delivered_at = lastDeliveredAt, + last_delivered_message_id = lastDeliveredMessageId, ) fun randomAttachmentDto( @@ -1271,3 +1281,43 @@ internal object Mother { disabled_until = disabledUntil, ) } + +internal fun randomPushMessage( + messageId: String = randomString(), + channelId: String = randomString(), + channelType: String = randomString(), + getstream: Map = emptyMap(), + extraData: Map = emptyMap(), + metadata: Map = emptyMap(), +) = PushMessage( + messageId = messageId, + channelId = channelId, + channelType = channelType, + getstream = getstream, + extraData = extraData, + metadata = metadata, +) + +internal fun randomMessageReceipt( + messageId: String = randomString(), + type: String = randomString(), + createdAt: Date = randomDate(), + cid: String = randomCID(), +) = MessageReceipt( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) + +internal fun randomMessageReceiptEntity( + messageId: String = randomString(), + type: String = randomString(), + createdAt: Date = randomDate(), + cid: String = randomCID(), +) = MessageReceiptEntity( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt index 71ab28daa76..a160af4f4f8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt @@ -46,10 +46,12 @@ import io.getstream.chat.android.client.api2.model.requests.AcceptInviteRequest import io.getstream.chat.android.client.api2.model.requests.AddDeviceRequest import io.getstream.chat.android.client.api2.model.requests.BanUserRequest import io.getstream.chat.android.client.api2.model.requests.BlockUserRequest +import io.getstream.chat.android.client.api2.model.requests.DeliveredMessageDto import io.getstream.chat.android.client.api2.model.requests.FlagMessageRequest import io.getstream.chat.android.client.api2.model.requests.FlagUserRequest import io.getstream.chat.android.client.api2.model.requests.GuestUserRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest +import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest import io.getstream.chat.android.client.api2.model.requests.MarkReadRequest import io.getstream.chat.android.client.api2.model.requests.MarkUnreadRequest import io.getstream.chat.android.client.api2.model.requests.MuteChannelRequest @@ -147,6 +149,7 @@ import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMemberData import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomMessageList import io.getstream.chat.android.randomPollConfig import io.getstream.chat.android.randomReaction import io.getstream.chat.android.randomString @@ -1389,6 +1392,31 @@ internal class MoshiChatApiTest { verify(api, times(1)).markRead(channelType, channelId, expectedRequest) } + @ParameterizedTest + @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#markDeliveredInput") + fun testMarkDelivered(call: RetrofitCall, expected: KClass<*>) = runTest { + // given + val api = mock() + whenever(api.markDelivered(any())).doReturn(call) + val sut = Fixture() + .withChannelApi(api) + .get() + // when + val messages = randomMessageList(10) + val result = sut.markDelivered(messages).await() + // then + val expectedRequest = MarkDeliveredRequest( + latest_delivered_messages = messages.map { messageInfo -> + DeliveredMessageDto( + cid = messageInfo.cid, + id = messageInfo.id, + ) + }, + ) + result `should be instance of` expected + verify(api, times(1)).markDelivered(expectedRequest) + } + @ParameterizedTest @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#markThreadReadInput") fun testMarkThreadRead(call: RetrofitCall, expected: KClass<*>) = runTest { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index dfe14aaab4e..f528b392f14 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -304,6 +304,9 @@ internal object MoshiChatApiTestArguments { @JvmStatic fun markReadInput() = completableResponseArguments() + @JvmStatic + fun markDeliveredInput() = completableResponseArguments() + @JvmStatic fun markThreadReadInput() = completableResponseArguments() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index fa7ef216cf7..1109985764e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -113,9 +113,7 @@ import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomPendingMessageMetadata import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser -import org.amshove.kluent.`should be equal to` -import org.amshove.kluent.shouldBeEqualTo -import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -135,7 +133,7 @@ internal class DomainMappingTest { randomDownstreamMessageDto().toDomain() } - result `should be equal to` transformedMessage + assertEquals(transformedMessage, result) } @Test @@ -158,7 +156,7 @@ internal class DomainMappingTest { ).toDomain() } - result `should be equal to` transformedMessage + assertEquals(transformedMessage, result) } @Test @@ -187,7 +185,7 @@ internal class DomainMappingTest { draftMessageResponse.toDomain() } - result `should be equal to` expectedMappedDraftMessage + assertEquals(expectedMappedDraftMessage, result) } @Test @@ -199,7 +197,7 @@ internal class DomainMappingTest { metadata = downstreamPendingMessageDto.metadata.orEmpty(), ) val result = with(sut) { downstreamPendingMessageDto.toDomain() } - Assertions.assertEquals(expected, result) + assertEquals(expected, result) } @Test @@ -213,7 +211,7 @@ internal class DomainMappingTest { metadata = pendingMessageMetadata, ) val result = with(sut) { messageResponse.toDomain() } - Assertions.assertEquals(expected, result) + assertEquals(expected, result) } @Test @@ -229,7 +227,7 @@ internal class DomainMappingTest { randomDownstreamUserDto().toDomain() } - result `should be equal to` transformedUser + assertEquals(transformedUser, result) } @Test @@ -245,7 +243,7 @@ internal class DomainMappingTest { randomDownstreamChannelDto().toDomain() } - result `should be equal to` transformedChannel + assertEquals(transformedChannel, result) } @Test @@ -275,7 +273,7 @@ internal class DomainMappingTest { ) with(sut) { - response.toDomain() `should be equal to` expected + assertEquals(expected, response.toDomain()) } } @@ -298,7 +296,7 @@ internal class DomainMappingTest { deletedAt = null, emojiCode = downstreamReactionDto.emoji_code, ) - reaction shouldBeEqualTo expected + assertEquals(expected, reaction) } @Test @@ -315,7 +313,7 @@ internal class DomainMappingTest { updatedAt = downstreamMuteDto.updated_at, expires = downstreamMuteDto.expires, ) - mute shouldBeEqualTo expected + assertEquals(expected, mute) } @Test @@ -332,7 +330,7 @@ internal class DomainMappingTest { updatedAt = downstreamMuteDto.updated_at, expires = downstreamMuteDto.expires, ) - mute shouldBeEqualTo expected + assertEquals(expected, mute) } @Test @@ -350,7 +348,7 @@ internal class DomainMappingTest { firstReactionAt = downstreamReactionGroupDto.first_reaction_at, lastReactionAt = downstreamReactionGroupDto.last_reaction_at, ) - reactionGroup shouldBeEqualTo expected + assertEquals(expected, reactionGroup) } @Test @@ -377,7 +375,7 @@ internal class DomainMappingTest { archivedAt = downstreamMemberDto.archived_at, extraData = downstreamMemberDto.extraData, ) - member shouldBeEqualTo expected + assertEquals(expected, member) } @Test @@ -456,7 +454,7 @@ internal class DomainMappingTest { ), ), ) - poll shouldBeEqualTo expected + assertEquals(expected, poll) } @Test @@ -464,7 +462,7 @@ internal class DomainMappingTest { val value = "public" val sut = Fixture().get() val votingVisibility = with(sut) { value.toVotingVisibility() } - votingVisibility shouldBeEqualTo VotingVisibility.PUBLIC + assertEquals(VotingVisibility.PUBLIC, votingVisibility) } @Test @@ -472,7 +470,7 @@ internal class DomainMappingTest { val value = "anonymous" val sut = Fixture().get() val votingVisibility = with(sut) { value.toVotingVisibility() } - votingVisibility shouldBeEqualTo VotingVisibility.ANONYMOUS + assertEquals(VotingVisibility.ANONYMOUS, votingVisibility) } @Test @@ -498,9 +496,11 @@ internal class DomainMappingTest { unreadMessages = downstreamChannelUserRead.unread_messages, lastReadMessageId = downstreamChannelUserRead.last_read_message_id, lastReceivedEventDate = lastReceivedEventDate, + lastDeliveredAt = downstreamChannelUserRead.last_delivered_at, + lastDeliveredMessageId = downstreamChannelUserRead.last_delivered_message_id, ) - channelUserRead shouldBeEqualTo expected + assertEquals(expected, channelUserRead) } @Test @@ -530,7 +530,7 @@ internal class DomainMappingTest { originalWidth = attachmentDto.original_width, extraData = attachmentDto.extraData.toMutableMap(), ) - attachment shouldBeEqualTo expected + assertEquals(expected, attachment) } @Test @@ -547,7 +547,7 @@ internal class DomainMappingTest { shadow = bannedUserResponse.shadow, reason = bannedUserResponse.reason, ) - bannedUser shouldBeEqualTo expected + assertEquals(expected, bannedUser) } @Test @@ -563,7 +563,7 @@ internal class DomainMappingTest { memberCount = channelInfoDto.member_count, image = channelInfoDto.image, ) - channelInfo shouldBeEqualTo expected + assertEquals(expected, channelInfo) } @Test @@ -577,7 +577,7 @@ internal class DomainMappingTest { args = commandDto.args, set = commandDto.set, ) - command shouldBeEqualTo expected + assertEquals(expected, command) } @Test @@ -591,6 +591,7 @@ internal class DomainMappingTest { name = configDto.name ?: "", typingEventsEnabled = configDto.typing_events, readEventsEnabled = configDto.read_events, + deliveryEventsEnabled = configDto.delivery_events, connectEventsEnabled = configDto.connect_events, searchEnabled = configDto.search, isReactionsEnabled = configDto.reactions, @@ -612,7 +613,7 @@ internal class DomainMappingTest { sharedLocationsEnabled = configDto.shared_locations ?: false, markMessagesPending = configDto.mark_messages_pending, ) - config shouldBeEqualTo expected + assertEquals(expected, config) } @Test @@ -625,7 +626,7 @@ internal class DomainMappingTest { pushProvider = PushProvider.fromKey(deviceDto.id), providerName = deviceDto.push_provider_name, ) - device shouldBeEqualTo expected + assertEquals(expected, device) } @Test @@ -645,7 +646,7 @@ internal class DomainMappingTest { approvedAt = downstreamFlagDto.approved_at, rejectedAt = downstreamFlagDto.rejected_at, ) - flag shouldBeEqualTo expected + assertEquals(expected, flag) } @Test @@ -658,7 +659,7 @@ internal class DomainMappingTest { action = MessageModerationAction(downstreamModerationDetailsDto.action.orEmpty()), errorMsg = downstreamModerationDetailsDto.error_msg.orEmpty(), ) - moderationDetails shouldBeEqualTo expected + assertEquals(expected, moderationDetails) } @Test @@ -675,7 +676,7 @@ internal class DomainMappingTest { semanticFilterMatched = downstreamModerationDto.semantic_filter_matched, platformCircumvented = downstreamModerationDto.platform_circumvented ?: false, ) - moderation shouldBeEqualTo expected + assertEquals(expected, moderation) } @Test @@ -687,7 +688,7 @@ internal class DomainMappingTest { typingIndicators = TypingIndicators(enabled = privacySettingsDto.typing_indicators?.enabled == true), readReceipts = ReadReceipts(enabled = privacySettingsDto.read_receipts?.enabled == true), ) - privacySettings shouldBeEqualTo expected + assertEquals(expected, privacySettings) } @Test @@ -701,7 +702,7 @@ internal class DomainMappingTest { warningCode = searchWarningDto.warning_code, warningDescription = searchWarningDto.warning_description, ) - searchWarning shouldBeEqualTo expected + assertEquals(expected, searchWarning) } @Test @@ -762,7 +763,7 @@ internal class DomainMappingTest { }, extraData = downstreamThreadDto.extraData, ) - thread shouldBeEqualTo expected + assertEquals(expected, thread) } @Test @@ -786,7 +787,7 @@ internal class DomainMappingTest { updatedAt = downstreamThreadInfoDto.updated_at, extraData = downstreamThreadInfoDto.extraData, ) - threadInfo shouldBeEqualTo expected + assertEquals(expected, threadInfo) } @Test @@ -802,7 +803,7 @@ internal class DomainMappingTest { blockedAt = downstreamUserBlockDto.created_at, ), ) - blocklist shouldBeEqualTo expected + assertEquals(expected, blocklist) } @Test @@ -815,7 +816,7 @@ internal class DomainMappingTest { userId = blockUserResponse.blocked_user_id, blockedAt = blockUserResponse.created_at, ) - userBlock shouldBeEqualTo expected + assertEquals(expected, userBlock) } @Test @@ -832,7 +833,7 @@ internal class DomainMappingTest { createdAt = downstreamReminderDto.created_at, updatedAt = downstreamReminderDto.updated_at, ) - messageReminder shouldBeEqualTo expected + assertEquals(expected, messageReminder) } @Test @@ -844,7 +845,7 @@ internal class DomainMappingTest { reminders = input.reminders.map { with(sut) { it.toDomain() } }, next = input.next, ) - result shouldBeEqualTo expected + assertEquals(expected, result) } @Test @@ -884,7 +885,7 @@ internal class DomainMappingTest { ) }, ) - result shouldBeEqualTo expected + assertEquals(expected, result) } internal class Fixture { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index c73d672ac5d..ab8a6547c31 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -45,6 +45,7 @@ import io.getstream.chat.android.client.api2.model.dto.MemberAddedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberRemovedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.MessageDeletedEventDto +import io.getstream.chat.android.client.api2.model.dto.MessageDeliveredEventDto import io.getstream.chat.android.client.api2.model.dto.MessageReadEventDto import io.getstream.chat.android.client.api2.model.dto.MessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto @@ -111,6 +112,7 @@ import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent import io.getstream.chat.android.client.events.MemberUpdatedEvent import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -197,6 +199,7 @@ internal object EventMappingTestArguments { private val MEMBER = Mother.randomDownstreamMemberDto() private val HARD_DELETE = randomBoolean() private val FIRST_UNREAD_MESSAGE_ID = randomString() + private val LAST_DELIVERED_MESSAGE_ID = randomString() private val LAST_READ_MESSAGE_ID = randomString() private val UNREAD_MESSAGES = positiveRandomInt() private val TOTAL_UNREAD_COUNT = positiveRandomInt() @@ -420,6 +423,17 @@ internal object EventMappingTestArguments { deleted_for_me = DELETED_FOR_ME, ) + private val messageDeliveredDto = MessageDeliveredEventDto( + type = EventType.MESSAGE_DELIVERED, + created_at = EXACT_DATE, + user = USER, + cid = CID, + channel_type = CHANNEL_TYPE, + channel_id = CHANNEL_ID, + last_delivered_at = EXACT_DATE, + last_delivered_message_id = LAST_DELIVERED_MESSAGE_ID, + ) + private val messageReadDto = MessageReadEventDto( type = EventType.MESSAGE_READ, created_at = EXACT_DATE, @@ -1047,6 +1061,18 @@ internal object EventMappingTestArguments { deletedForMe = messageDeletedDto.deleted_for_me ?: false, ) + private val messageDelivered = MessageDeliveredEvent( + type = EventType.MESSAGE_DELIVERED, + createdAt = EXACT_DATE.date, + rawCreatedAt = EXACT_DATE.rawDate, + user = with(domainMapping) { USER.toDomain() }, + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + lastDeliveredAt = EXACT_DATE.date, + lastDeliveredMessageId = LAST_DELIVERED_MESSAGE_ID, + ) + private val messageRead = MessageReadEvent( type = messageReadDto.type, createdAt = messageReadDto.created_at.date, @@ -1541,6 +1567,7 @@ internal object EventMappingTestArguments { Arguments.of(memberRemovedDto, memberRemoved), Arguments.of(memberUpdatedDto, memberUpdated), Arguments.of(messageDeletedDto, messageDeleted), + Arguments.of(messageDeliveredDto, messageDelivered), Arguments.of(messageReadDto, messageRead), Arguments.of(messageUpdatedDto, messageUpdated), Arguments.of(notificationAddedToChannelDto, notificationAddedToChannel), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt index 5351d0f6c73..7ad1913d491 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt @@ -128,6 +128,8 @@ internal open class BaseChatClientTest { currentUserFetcher = currentUserFetcher, audioPlayer = mock(), now = { now }, + repository = mock(), + messageReceiptReporter = mock(), ) chatClient.attachmentsSender = attachmentsSender chatClient.plugins = plugins diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt index a8fa1e72336..bbd98146196 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt @@ -29,6 +29,7 @@ import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.network.NetworkStateProvider import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory +import io.getstream.chat.android.client.persistence.repository.ChatClientRepository import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.scope.ClientTestScope import io.getstream.chat.android.client.scope.UserTestScope @@ -123,6 +124,9 @@ internal class ChatClientDebuggerTest { isRetrying: Boolean, ): SendMessageDebugger = sendMessageDebugger } + val mockRepository = mock { + onBlocking { getAllMessageReceiptsByType(type = any(), limit = any()) } doReturn emptyList() + } client = ChatClient( config = config, api = api, @@ -143,6 +147,8 @@ internal class ChatClientDebuggerTest { repositoryFactoryProvider = NoOpRepositoryFactory.Provider, currentUserFetcher = mock(), audioPlayer = mock(), + repository = mockRepository, + messageReceiptReporter = mock(), ).apply { attachmentsSender = this@ChatClientDebuggerTest.attachmentsSender connectUser(user, token).enqueue() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionsTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionTest.kt similarity index 65% rename from stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionsTests.kt rename to stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionTest.kt index cab3d961813..2372cb96d39 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionsTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionTest.kt @@ -26,46 +26,48 @@ import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser -import org.amshove.kluent.shouldBeEqualTo import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import java.util.Date -internal class ChannelExtensionsTests { +internal class ChannelExtensionTest { @Test fun `isAnonymousChannel should return true for anonymous channel`() { val anonymousChannel = randomChannel(id = "!members-12345") - anonymousChannel.isAnonymousChannel() shouldBeEqualTo true + assertTrue(anonymousChannel.isAnonymousChannel()) } @Test fun `isAnonymousChannel should return false for non-anonymous channel`() { val channel = randomChannel(id = "messaging:12345") - channel.isAnonymousChannel() shouldBeEqualTo false + assertFalse(channel.isAnonymousChannel()) } @Test fun `isPinned should return true if channel is pinned`() { val pinnedChannel = randomChannel(membership = randomMember(pinnedAt = randomDate())) - pinnedChannel.isPinned() shouldBeEqualTo true + assertTrue(pinnedChannel.isPinned()) } @Test fun `isPinned should return false if channel is not pinned`() { val channel = randomChannel(membership = randomMember(pinnedAt = null)) - channel.isPinned() shouldBeEqualTo false + assertFalse(channel.isPinned()) } @Test fun `isArchive should return true if channel is archived`() { val archivedChannel = randomChannel(membership = randomMember(archivedAt = randomDate())) - archivedChannel.isArchive() shouldBeEqualTo true + assertTrue(archivedChannel.isArchive()) } @Test fun `isArchive should return false if channel is not archived`() { val channel = randomChannel(membership = randomMember(archivedAt = null)) - channel.isArchive() shouldBeEqualTo false + assertFalse(channel.isArchive()) } @Test @@ -73,7 +75,7 @@ internal class ChannelExtensionsTests { val channelId = randomCID() val channel = randomChannel(id = channelId) val mutedUser = randomUser(channelMutes = listOf(randomChannelMute(channel = channel))) - channel.isMutedFor(mutedUser) shouldBeEqualTo true + assertTrue(channel.isMutedFor(mutedUser)) } @Test @@ -81,7 +83,7 @@ internal class ChannelExtensionsTests { val channelId = randomCID() val channel = randomChannel(id = channelId) val user = randomUser() - channel.isMutedFor(user) shouldBeEqualTo false + assertFalse(channel.isMutedFor(user)) } @Test @@ -94,8 +96,8 @@ internal class ChannelExtensionsTests { ) val channel = randomChannel(members = members) val users = channel.getUsersExcludingCurrent(currentUser) - users.size shouldBeEqualTo 1 - users.first() shouldBeEqualTo otherUser + assertEquals(1, users.size) + assertEquals(otherUser, users.first()) } @Test @@ -115,7 +117,7 @@ internal class ChannelExtensionsTests { ), ) val channel = randomChannel(messages = messages) - channel.countUnreadMentionsForUser(user) shouldBeEqualTo 2 + assertEquals(2, channel.countUnreadMentionsForUser(user)) } @Test @@ -140,7 +142,7 @@ internal class ChannelExtensionsTests { ) val channelRead = randomChannelUserRead(user = user, lastRead = lastReadDate) val channel = randomChannel(messages = messages, read = listOf(channelRead)) - channel.countUnreadMentionsForUser(user) shouldBeEqualTo 1 + assertEquals(1, channel.countUnreadMentionsForUser(user)) } @Test @@ -149,7 +151,7 @@ internal class ChannelExtensionsTests { val unreadMessages = positiveRandomInt() val channelRead = randomChannelUserRead(user = randomUser(id = currentUserId), unreadMessages = unreadMessages) val channel = randomChannel(read = listOf(channelRead)) - channel.currentUserUnreadCount(currentUserId) shouldBeEqualTo unreadMessages + assertEquals(unreadMessages, channel.currentUserUnreadCount(currentUserId)) } @Test @@ -158,6 +160,44 @@ internal class ChannelExtensionsTests { val unreadMessages = positiveRandomInt() val channelRead = randomChannelUserRead(user = randomUser(id = currentUserId), unreadMessages = unreadMessages) val channel = randomChannel(read = listOf(channelRead)) - channel.syncUnreadCountWithReads(currentUserId).unreadCount shouldBeEqualTo unreadMessages + assertEquals(unreadMessages, channel.syncUnreadCountWithReads(currentUserId).unreadCount) + } + + @Test + fun `readsOf should return correct list of ChannelUserRead who have read the message`() { + val createdAt = randomDate() + val messageUser = randomUser() + val otherUser1 = randomUser() + val otherUser2 = randomUser() + val lastRead = Date(createdAt.time + 1000) // After message creation time + val read1 = randomChannelUserRead(user = otherUser1, lastRead = lastRead) + val read2 = randomChannelUserRead(user = otherUser2, lastRead = lastRead) + val read3 = randomChannelUserRead(user = messageUser, lastRead = lastRead) + val channel = randomChannel(read = listOf(read1, read2, read3)) + val message = randomMessage(user = messageUser, createdAt = createdAt) + + val actual = channel.readsOf(message) + + assertEquals(2, actual.size) + assertEquals(listOf(read1, read2), actual) + } + + @Test + fun `deliveredReadsOf should return correct list of ChannelUserRead who have delivered the message`() { + val createdAt = randomDate() + val messageUser = randomUser() + val otherUser1 = randomUser() + val otherUser2 = randomUser() + val lastDelivered = Date(createdAt.time + 1000) // After message creation time + val delivered1 = randomChannelUserRead(user = otherUser1, lastDeliveredAt = lastDelivered) + val delivered2 = randomChannelUserRead(user = otherUser2, lastDeliveredAt = lastDelivered) + val delivered3 = randomChannelUserRead(user = messageUser, lastDeliveredAt = lastDelivered) + val channel = randomChannel(read = listOf(delivered1, delivered2, delivered3)) + val message = randomMessage(user = messageUser, createdAt = createdAt) + + val actual = channel.deliveredReadsOf(message) + + assertEquals(2, actual.size) + assertEquals(listOf(delivered1, delivered2), actual) } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt new file mode 100644 index 00000000000..9d7e87a14c8 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.notifications + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.testing.WorkManagerTestInitHelper +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.notifications.handler.NotificationConfig +import io.getstream.chat.android.client.notifications.handler.NotificationHandler +import io.getstream.chat.android.client.randomPushMessage +import io.getstream.chat.android.client.receipts.MessageReceiptManager +import io.getstream.chat.android.models.PushMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class ChatNotificationsImplTest { + + @Test + fun `onPushMessage calls onPushNotificationReceived`() { + val pushMessage = randomPushMessage() + val mockListener = mock() + val fixture = Fixture() + val sut = fixture.get() + + sut.onPushMessage(pushMessage, mockListener) + + verify(mockListener).onPushNotificationReceived( + channelType = pushMessage.channelType, + channelId = pushMessage.channelId, + ) + } + + @Test + fun `onPushMessage marks message as delivered`() { + val pushMessage = randomPushMessage() + val fixture = Fixture() + val sut = fixture.get() + + sut.onPushMessage(pushMessage) + + fixture.verifyMarkMessageAsDeliveredCalled(messageId = pushMessage.messageId) + } + + @Test + fun `onPushMessage schedules work when shouldShowNotificationOnPush is true and handler does not handle message`() { + val pushMessage = randomPushMessage() + val fixture = Fixture() + val sut = fixture.get() + + sut.onPushMessage(pushMessage) + + val workInfos = getLoadNotificationDataWorkerInfos() + assertNotNull(workInfos.firstOrNull()) + } + + @Test + fun `onPushMessage does not schedule work when shouldShowNotificationOnPush is false`() { + val pushMessage = randomPushMessage() + val fixture = Fixture() + .givenNotificationConfig(config = NotificationConfig(shouldShowNotificationOnPush = { false })) + val sut = fixture.get() + + sut.onPushMessage(pushMessage) + + val workInfos = getLoadNotificationDataWorkerInfos() + assertNull(workInfos.firstOrNull()) + } + + @Test + fun `onPushMessage does not schedule work when handler handles message`() { + val pushMessage = randomPushMessage() + val fixture = Fixture() + .givenOnPushMessageHandled(pushMessage = pushMessage, handled = true) + val sut = fixture.get() + + sut.onPushMessage(pushMessage) + + val workInfos = getLoadNotificationDataWorkerInfos() + assertNull(workInfos.firstOrNull()) + } + + private class Fixture { + + private var mockNotificationHandler = mock() + private var notificationConfig = NotificationConfig() + private val mockMessageReceiptManager = mock() + + private val mockChatClient = mock { + on { messageReceiptManager } doReturn mockMessageReceiptManager + } + + fun givenOnPushMessageHandled(pushMessage: PushMessage, handled: Boolean) = apply { + whenever(mockNotificationHandler.onPushMessage(pushMessage)) doReturn handled + } + + fun givenNotificationConfig(config: NotificationConfig) = apply { + notificationConfig = config + } + + fun verifyMarkMessageAsDeliveredCalled(messageId: String) { + verifyBlocking(mockMessageReceiptManager) { markMessageAsDelivered(messageId) } + } + + fun get(): ChatNotificationsImpl { + val context = ApplicationProvider.getApplicationContext() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + + return ChatNotificationsImpl( + handler = mockNotificationHandler, + notificationConfig = notificationConfig, + context = context, + scope = CoroutineScope(UnconfinedTestDispatcher()), + chatClientProvider = { mockChatClient }, + ) + } + } +} + +private fun getLoadNotificationDataWorkerInfos(): List { + val workInfos = WorkManager + .getInstance(ApplicationProvider.getApplicationContext()) + .getWorkInfosByTag(LoadNotificationDataWorker::class.qualifiedName!!).get() + return workInfos +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt index e3a9ead14c0..4d20e0b168a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt @@ -38,6 +38,7 @@ internal object ChannelDtoTestData { "name" : "config1", "typing_events": true, "read_events": true, + "delivery_events": true, "connect_events": true, "search": false, "reactions": true, @@ -74,6 +75,7 @@ internal object ChannelDtoTestData { name = "config1", typing_events = true, read_events = true, + delivery_events = true, connect_events = true, search = false, reactions = true, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt index f3f1de7ee1f..88c985013e4 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.parser2.testdata +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.DownstreamMuteDto import io.getstream.chat.android.client.api2.model.dto.DownstreamPushPreferenceDto @@ -146,6 +147,9 @@ internal object UserDtoTestData { }, "read_receipts": { "enabled": false + }, + "delivery_receipts": { + "enabled": false } }, "language": "language", @@ -201,6 +205,9 @@ internal object UserDtoTestData { read_receipts = ReadReceiptsDto( enabled = false, ), + delivery_receipts = DeliveryReceiptsDto( + enabled = false, + ), ), language = "language", role = "owner", @@ -282,6 +289,9 @@ internal object UserDtoTestData { }, "read_receipts": { "enabled": false + }, + "delivery_receipts": { + "enabled": false } }, "language": "language", @@ -311,6 +321,9 @@ internal object UserDtoTestData { read_receipts = ReadReceiptsDto( enabled = false, ), + delivery_receipts = DeliveryReceiptsDto( + enabled = false, + ), ), banned = false, devices = listOf(DeviceDto(id = "deviceId", push_provider = "provider", push_provider_name = "provider_name")), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/db/converter/DateConverterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/db/converter/DateConverterTest.kt new file mode 100644 index 00000000000..e58d153f216 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/db/converter/DateConverterTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.db.converter + +import io.getstream.chat.android.randomLong +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import java.util.Date + +internal class DateConverterTest { + + private val sut = DateConverter() + + @Test + fun `fromDb with a valid non null Long`() { + val timestamp = randomLong() + val date = Date(timestamp) + + val actual = sut.fromDb(timestamp) + + assertEquals(date, actual) + } + + @Test + fun `toDb with a valid non null Date`() { + val timestamp = randomLong() + val date = Date(timestamp) + + val actual = sut.toDb(date) + + assertEquals(timestamp, actual) + } + + @Test + fun `fromDb with a null Long`() { + val actual = sut.fromDb(null) + + assertNull(actual) + } + + @Test + fun `toDb with a null Date`() { + val actual = sut.toDb(null) + + assertNull(actual) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt new file mode 100644 index 00000000000..208ae1bbe55 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.repository + +import io.getstream.chat.android.client.persistence.db.ChatClientDatabase +import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertNotNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyBlocking + +internal class ChatClientRepositoryTest { + + @Test + fun `should instantiate from database`() { + val mockDatabase = mock { + on { messageReceiptDao() } doReturn mock() + } + + val actual = ChatClientRepository.from(mockDatabase) + + assertNotNull(actual) + } + + @Test + fun `should clear repositories`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + sut.clear() + + fixture.verifyRepositoriesCleared() + } + + private class Fixture { + private val mockMessageReceiptRepository = mock() + + fun verifyRepositoriesCleared() { + verifyBlocking(mockMessageReceiptRepository) { clearMessageReceipts() } + } + + fun get() = ChatClientRepository( + messageReceiptRepository = mockMessageReceiptRepository, + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt new file mode 100644 index 00000000000..225156c41ba --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.persistence.repository + +import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity +import io.getstream.chat.android.client.randomMessageReceipt +import io.getstream.chat.android.client.randomMessageReceiptEntity +import io.getstream.chat.android.client.receipts.MessageReceipt +import io.getstream.chat.android.randomInt +import io.getstream.chat.android.randomString +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.jupiter.api.Assertions +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +internal class MessageReceiptRepositoryImplTest { + + @Test + fun `upsert message receipts`() = runTest { + val receipt = randomMessageReceipt() + val fixture = Fixture() + val sut = fixture.get() + + sut.upsertMessageReceipts(receipts = listOf(receipt)) + + val expectedReceipts = listOf( + MessageReceiptEntity( + messageId = receipt.messageId, + type = receipt.type, + createdAt = receipt.createdAt, + cid = receipt.cid, + ), + ) + fixture.verifyUpsertCalled(expectedReceipts) + } + + @Test + fun `get message receipts by type`() = runTest { + val type = randomString() + val limit = randomInt() + val receipt = randomMessageReceiptEntity() + val fixture = Fixture() + .givenMessageReceiptsByType( + type = type, + limit = limit, + receipts = listOf(receipt), + ) + val sut = fixture.get() + + val actual = sut.getAllMessageReceiptsByType(type, limit) + + val expected = listOf( + MessageReceipt( + messageId = receipt.messageId, + type = receipt.type, + createdAt = receipt.createdAt, + cid = receipt.cid, + ), + ) + Assertions.assertEquals(expected, actual) + } + + @Test + fun `delete message receipts by message IDs`() = runTest { + val messageIds = listOf(randomString()) + val fixture = Fixture() + val sut = fixture.get() + + sut.deleteMessageReceiptsByMessageIds(messageIds) + + fixture.verifyDeleteByMessageIdsCalled(messageIds) + } + + @Test + fun `clear message receipts`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + sut.clearMessageReceipts() + + fixture.verifyDeleteAllCalled() + } + + private class Fixture { + + private val mockDao = mock { + onBlocking { upsert(any()) } doReturn Unit + onBlocking { deleteByMessageIds(any()) } doReturn Unit + } + + fun givenMessageReceiptsByType(type: String, limit: Int, receipts: List) = apply { + wheneverBlocking { mockDao.selectAllByType(type, limit) } doReturn receipts + } + + fun verifyUpsertCalled(receipts: List) { + verifyBlocking(mockDao) { upsert(receipts) } + } + + fun verifyDeleteByMessageIdsCalled(messageIds: List) { + verifyBlocking(mockDao) { deleteByMessageIds(messageIds) } + } + + fun verifyDeleteAllCalled() { + verifyBlocking(mockDao) { deleteAll() } + } + + fun get() = MessageReceiptRepositoryImpl(dao = mockDao) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginFactoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginFactoryTest.kt new file mode 100644 index 00000000000..14805bd7992 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginFactoryTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.plugin + +import io.getstream.chat.android.client.ChatClient +import org.junit.Test +import org.junit.jupiter.api.assertInstanceOf +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +internal class MessageDeliveredPluginFactoryTest { + + @Test + fun `factory should create MessageDeliveredPlugin`() { + val mockChatClient = mock { on { messageReceiptManager } doReturn mock() } + object : ChatClient.ChatClientBuilder() { + override fun internalBuild(): ChatClient = mockChatClient + }.build() + + val actual = MessageDeliveredPluginFactory.get(mock()) + + assertInstanceOf(actual) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt new file mode 100644 index 00000000000..0fede51a012 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.plugin + +import io.getstream.chat.android.client.receipts.MessageReceiptManager +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.randomChannel +import io.getstream.result.Result +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyBlocking +import org.mockito.verification.VerificationMode + +internal class MessageDeliveredPluginTest { + + @Test + fun `on query channels with successful result, should mark channels as delivered`() = runTest { + val channels = listOf(randomChannel()) + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelsResult(result = Result.Success(channels), request = mock()) + + fixture.verifyMarkChannelsAsDeliveredCalled(channels = channels) + } + + @Test + fun `on query channels with failure result, should not mark channels as delivered`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelsResult(result = Result.Failure(mock()), request = mock()) + + fixture.verifyMarkChannelsAsDeliveredCalled(never()) + } + + @Test + fun `on query channel with successful result and null pagination, should mark channel as delivered`() = runTest { + val channel = randomChannel() + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelResult( + result = Result.Success(channel), + channelType = channel.type, + channelId = channel.id, + request = mock { on { pagination() } doReturn null }, + ) + + fixture.verifyMarkChannelsAsDeliveredCalled(channels = listOf(channel)) + } + + @Test + fun `on query channel with successful result and non-null pagination, should not mark channel as delivered`() = + runTest { + val channel = randomChannel() + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelResult( + result = Result.Success(channel), + channelType = channel.type, + channelId = channel.id, + request = mock { on { pagination() } doReturn mock() }, + ) + + fixture.verifyMarkChannelsAsDeliveredCalled(never()) + } + + @Test + fun `on query channel with failure result, should not mark channel as delivered`() = runTest { + val channel = randomChannel() + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelResult( + result = Result.Failure(mock()), + channelType = channel.type, + channelId = channel.id, + request = mock(), + ) + + fixture.verifyMarkChannelsAsDeliveredCalled(never()) + } + + private class Fixture { + private val mockMessageReceiptManager = mock() + + fun verifyMarkChannelsAsDeliveredCalled( + mode: VerificationMode = times(1), + channels: List? = null, + ) { + verifyBlocking(mockMessageReceiptManager, mode) { + markChannelsAsDelivered(channels ?: any()) + } + } + + fun get() = MessageDeliveredPlugin( + chatClient = mock { on { messageReceiptManager } doReturn mockMessageReceiptManager }, + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt new file mode 100644 index 00000000000..0c0a351df86 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -0,0 +1,426 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.receipts + +import io.getstream.chat.android.DeliveryReceipts +import io.getstream.chat.android.PrivacySettings +import io.getstream.chat.android.client.api.ChatApi +import io.getstream.chat.android.client.extensions.internal.NEVER +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade +import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomChannelUserRead +import io.getstream.chat.android.randomConfig +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomMute +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.test.asCall +import io.getstream.result.Error +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking +import org.mockito.verification.VerificationMode +import java.util.Date + +internal class MessageReceiptManagerTest { + + @Test + fun `store message delivery receipt when channel is found from repository`() = runTest { + val message = DeliverableMessage + val fixture = Fixture() + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + val receipts = listOf( + MessageReceipt( + messageId = message.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = message.cid, + ), + ) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) + } + + @Test + fun `fetch channel from API when channel is not found from repository`() = runTest { + val message = DeliverableMessage + val fixture = Fixture() + .givenChannelNotFoundFromRepository() + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + val receipts = listOf( + MessageReceipt( + messageId = message.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = message.cid, + ), + ) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) + } + + @Test + fun `fetch message from API when message is not found from repository`() = runTest { + val message = DeliverableMessage + val fixture = Fixture() + .givenMessageNotFoundFromRepository() + val sut = fixture.get() + + sut.markMessageAsDelivered(messageId = message.id) + + val receipts = listOf( + MessageReceipt( + messageId = message.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = message.cid, + ), + ) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) + } + + @Test + fun `should skip storing message delivery receipt when message is not found`() = runTest { + val message = DeliverableMessage + val fixture = Fixture() + .givenMessageNotFoundFromRepository() + .givenMessageNotFoundFromApi() + val sut = fixture.get() + + sut.markMessageAsDelivered(messageId = message.id) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing message delivery receipt when channel is not found`() = runTest { + val message = DeliverableMessage + val fixture = Fixture() + .givenChannelNotFoundFromRepository() + .givenChannelNotFoundFromApi() + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing message delivery receipt when current user is null`() = runTest { + val message = DeliverableMessage + val fixture = Fixture().givenCurrentUser(user = null) + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should store message delivery receipt when current user privacy settings are undefined`() = runTest { + val currentUser = CurrentUser.copy(privacySettings = null) + val message = DeliverableMessage + val fixture = Fixture().givenCurrentUser(currentUser) + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + val receipts = listOf( + MessageReceipt( + messageId = message.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = message.cid, + ), + ) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) + } + + @Test + fun `should skip storing message delivery receipt when delivery receipts are disabled`() = runTest { + val currentUser = CurrentUser.copy( + privacySettings = PrivacySettings( + deliveryReceipts = DeliveryReceipts(enabled = false), + ), + ) + val message = DeliverableMessage + val fixture = Fixture().givenCurrentUser(currentUser) + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing message delivery receipt from the current user`() = runTest { + val message = DeliverableMessage.copy(user = CurrentUser) + val fixture = Fixture() + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing message delivery receipt from shadow banned messages`() = runTest { + val message = DeliverableMessage.copy(shadowed = true) + val fixture = Fixture() + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing message delivery receipt from muted users`() = runTest { + val message = DeliverableMessage + val currentUser = CurrentUser.copy( + mutes = listOf(randomMute(user = CurrentUser, target = message.user)), + ) + val fixture = Fixture() + .givenCurrentUser(currentUser) + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `store channel delivery receipts success`() = runTest { + val message = DeliverableMessage + val channel = DeliverableChannel + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + val receipts = listOf( + MessageReceipt( + messageId = message.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = message.cid, + ), + ) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) + } + + @Test + fun `should skip storing channel delivery receipts when delivery events are disabled`() = runTest { + val channel = DeliverableChannel.copy(config = randomConfig(deliveryEventsEnabled = false)) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing channel delivery receipts when current user is null`() = runTest { + val channel = DeliverableChannel + val fixture = Fixture().givenCurrentUser(user = null) + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing channel delivery receipts when user read is not found`() = runTest { + val channel = DeliverableChannel.copy(read = listOf(randomChannelUserRead())) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing channel delivery receipts when last message is not found`() = runTest { + val channel = DeliverableChannel.copy(messages = emptyList()) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing channel delivery receipts when last non-deleted message is not found`() = runTest { + val channel = DeliverableChannel.copy(messages = listOf(randomMessage(deletedAt = Now))) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing channel delivery receipts when last message is already read`() = runTest { + val channel = DeliverableChannel.copy( + read = listOf( + randomChannelUserRead( + user = CurrentUser, + lastRead = Now, + lastDeliveredAt = null, + ), + ), + ) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing channel delivery receipts when last message is already delivered`() = runTest { + val channel = DeliverableChannel.copy( + read = listOf( + randomChannelUserRead( + user = CurrentUser, + lastRead = NEVER, + lastDeliveredAt = Now, + ), + ), + ) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing channel delivery receipts with empty list`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = emptyList()) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + private class Fixture { + private var getCurrentUser: () -> User? = { CurrentUser } + private val mockRepositoryFacade = mock { + onBlocking { selectChannel(DeliverableChannel.cid) } doReturn DeliverableChannel + onBlocking { selectMessage(DeliverableMessage.id) } doReturn DeliverableMessage + } + private val mockMessageReceiptRepository = mock() + private val mockChatApi = mock { + on { + queryChannel( + channelType = any(), + channelId = any(), + query = any(), + ) + } doReturn DeliverableChannel.asCall() + on { getMessage(messageId = DeliverableMessage.id) } doReturn DeliverableMessage.asCall() + } + + fun givenCurrentUser(user: User?) = apply { + getCurrentUser = { user } + } + + fun givenChannelNotFoundFromRepository() = apply { + wheneverBlocking { mockRepositoryFacade.selectChannel(cid = any()) } doReturn null + } + + fun givenMessageNotFoundFromRepository() = apply { + wheneverBlocking { mockRepositoryFacade.selectMessage(messageId = any()) } doReturn null + } + + fun givenChannelNotFoundFromApi() = apply { + wheneverBlocking { + mockChatApi.queryChannel( + channelType = any(), + channelId = any(), + query = any(), + ) + } doReturn mock().asCall() + } + + fun givenMessageNotFoundFromApi() = apply { + wheneverBlocking { mockChatApi.getMessage(messageId = any()) } doReturn mock().asCall() + } + + fun verifyUpsertMessageReceiptsCalled( + mode: VerificationMode = times(1), + receipts: List? = null, + ) { + verifyBlocking(mockMessageReceiptRepository, mode) { + upsertMessageReceipts(receipts ?: any()) + } + } + + fun get() = MessageReceiptManager( + now = { Now }, + getCurrentUser = getCurrentUser, + repositoryFacade = mockRepositoryFacade, + messageReceiptRepository = mockMessageReceiptRepository, + api = mockChatApi, + ) + } +} + +private val Now = Date() + +private val CurrentUser = randomUser( + privacySettings = PrivacySettings( + deliveryReceipts = DeliveryReceipts(enabled = true), + ), +) + +private val DeliverableMessage = randomMessage( + createdAt = Now, + deletedAt = null, + deletedForMe = false, +) + +private val DeliverableChannel = randomChannel( + messages = listOf(DeliverableMessage), + read = listOf( + randomChannelUserRead( + user = CurrentUser, + lastRead = NEVER, + lastDeliveredAt = null, + ), + ), +) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt new file mode 100644 index 00000000000..bcd128af276 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.receipts + +import io.getstream.chat.android.client.api.ChatApi +import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository +import io.getstream.chat.android.client.randomMessageReceipt +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.test.asCall +import io.getstream.result.Error +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking +import org.mockito.verification.VerificationMode + +@OptIn(ExperimentalCoroutinesApi::class) +internal class MessageReceiptReporterTest { + + @Test + fun `should fetch and send delivery receipts successfully`() = runTest { + val receipts = listOf( + randomMessageReceipt(), + randomMessageReceipt(), + ) + val messages = receipts.map { receipt -> + Message( + id = receipt.messageId, + cid = receipt.cid, + ) + } + val fixture = Fixture() + .givenMessageReceipts(receipts) + .givenMarkDelivered(messages) + val sut = fixture.get(backgroundScope) + + sut.start() + advanceTimeBy(100) // Allow initial execution + + fixture.verifyMarkDeliveredCalled(messages = messages) + val messageIds = messages.map(Message::id) + fixture.verifyDeleteByMessageIdsCalled(messageIds = messageIds) + } + + @Test + fun `should not delete receipts when marking messages as delivered fails`() = runTest { + val fixture = Fixture() + .givenMessageReceipts(listOf(randomMessageReceipt())) + // Simulate an error when marking messages as delivered + .givenMarkDelivered(error = mock()) + val sut = fixture.get(backgroundScope) + + sut.start() + advanceTimeBy(100) // Allow initial execution + + fixture.verifyDeleteByMessageIdsCalled(never()) + + // Keep processing in the next time window + fixture.givenMarkDelivered() + advanceTimeBy(1000) + + fixture.verifyMarkDeliveredCalled(times(2)) + fixture.verifyDeleteByMessageIdsCalled() + } + + @Test + fun `should handle empty receipt list`() = runTest { + val fixture = Fixture() + .givenMessageReceipts(receipts = emptyList()) + val sut = fixture.get(backgroundScope) + + sut.start() + advanceTimeBy(100) // Allow initial execution + + fixture.verifyMarkDeliveredCalled(never()) + fixture.verifyDeleteByMessageIdsCalled(never()) + } + + @Test + fun `should execute periodically with correct delay`() = runTest { + val fixture = Fixture() + .givenMessageReceipts(listOf(randomMessageReceipt())) + .givenMarkDelivered() + val sut = fixture.get(backgroundScope) + + sut.start() + advanceTimeBy(100) // Allow initial execution + + advanceTimeBy(1000) // Advance to the second interval + + advanceTimeBy(1000) // Advance to the third interval + + advanceTimeBy(1000) // Advance to the fourth interval + + fixture.verifyMarkDeliveredCalled(times(4)) + } + + @Test + fun `should stop execution when coroutine scope is cancelled`() = runTest { + val fixture = Fixture() + .givenMessageReceipts(listOf(randomMessageReceipt())) + .givenMarkDelivered() + val sut = fixture.get(backgroundScope) + + sut.start() + advanceTimeBy(100) // Allow initial execution + + backgroundScope.cancel() + + advanceTimeBy(1000) // Try to advance time after cancellation + + fixture.verifyMarkDeliveredCalled(times(1)) + } + + private class Fixture { + private val mockMessageReceiptRepository = mock() + private val mockApi = mock() + + fun givenMessageReceipts(receipts: List) = apply { + wheneverBlocking { + mockMessageReceiptRepository.getAllMessageReceiptsByType( + type = MessageReceipt.TYPE_DELIVERY, + limit = 100, + ) + } doReturn receipts + } + + fun givenMarkDelivered(messages: List? = null, error: Error? = null) = apply { + whenever(mockApi.markDelivered(messages ?: any())) doReturn + (error?.asCall() ?: Unit.asCall()) + } + + fun verifyMarkDeliveredCalled( + mode: VerificationMode = times(1), + messages: List? = null, + ) { + verify(mockApi, mode).markDelivered(messages ?: any()) + } + + fun verifyDeleteByMessageIdsCalled( + mode: VerificationMode = times(1), + messageIds: List? = null, + ) { + verifyBlocking(mockMessageReceiptRepository, mode) { + deleteMessageReceiptsByMessageIds(messageIds ?: any()) + } + } + + fun get(scope: CoroutineScope) = MessageReceiptReporter( + scope = scope, + messageReceiptRepository = mockMessageReceiptRepository, + api = mockApi, + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/observable/SubscriptionImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/observable/SubscriptionImplTest.kt index 111a994ac7d..c9a1a2bac8a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/observable/SubscriptionImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/observable/SubscriptionImplTest.kt @@ -18,8 +18,8 @@ package io.getstream.chat.android.client.utils.observable import io.getstream.chat.android.client.ChatEventListener import io.getstream.chat.android.client.events.ChatEvent -import org.amshove.kluent.internal.assertEquals import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.assertThrows import org.mockito.kotlin.any @@ -27,7 +27,8 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.util.concurrent.CountDownLatch +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit internal class SubscriptionImplTest { @@ -143,33 +144,40 @@ internal class SubscriptionImplTest { @Test fun `onNext should not call listener if disposed concurrently`() { - val latch = CountDownLatch(1) + val gate = CompletableFuture() // blocks the filter + val filterEntered = CompletableFuture() // signals we are inside the filter + val mockListener = mock>() - val subscription = SubscriptionImpl(filter = { - latch.await() // Introduce a pause in the filter - true - }, listener = mockListener) + val subscription = SubscriptionImpl( + filter = { + filterEntered.complete(Unit) // tell the test we are here + gate.get() // wait until the test lets us go + true + }, + listener = mockListener, + ) val event = mock() val exceptions = mutableListOf() - val onNextThread = Thread { + val onNextFuture = CompletableFuture.runAsync { try { subscription.onNext(event) } catch (e: Throwable) { exceptions.add(e) } } - val disposerThread = Thread { - subscription.dispose() - latch.countDown() // Release the latch to allow the filter to continue - } - onNextThread.start() - disposerThread.start() - onNextThread.join() - disposerThread.join() - assertEquals("Expected no exceptions", 0, exceptions.size) + // Wait until the filter is entered (ensures onNext is truly paused) + filterEntered.get(1, TimeUnit.SECONDS) + + subscription.dispose() // Dispose from the test thread – this is the concurrent part + + gate.complete(Unit) // Unblock the filter so onNext can finish its execution + + // Verify the outcome (no exception, listener never called) + onNextFuture.get(1, TimeUnit.SECONDS) + assertEquals(0, exceptions.size) verify(mockListener, never()).onEvent(event) } } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/DeleteMessageForMeComponentFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/DeleteMessageForMeComponentFactory.kt index 92dbf040526..de890cda054 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/DeleteMessageForMeComponentFactory.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/DeleteMessageForMeComponentFactory.kt @@ -45,7 +45,9 @@ import io.getstream.chat.android.ui.common.utils.canDeleteMessage /** * Factory for creating components related to deleting messages for the current user. */ -class DeleteMessageForMeComponentFactory : ChatComponentFactory { +class DeleteMessageForMeComponentFactory( + private val delegate: ChatComponentFactory = MessageInfoComponentFactory(), +) : ChatComponentFactory by delegate { /** * Creates a message menu with option for deleting messages for the current user. @@ -117,7 +119,7 @@ class DeleteMessageForMeComponentFactory : ChatComponentFactory { ) } - super.MessageMenu( + delegate.MessageMenu( modifier = modifier, message = message, messageOptions = allOptions, diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt new file mode 100644 index 00000000000..69fb3705ced --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.sample.ui.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.extensions.deliveredReadsOf +import io.getstream.chat.android.client.extensions.readsOf +import io.getstream.chat.android.compose.sample.R +import io.getstream.chat.android.compose.state.DateFormatType +import io.getstream.chat.android.compose.state.messageoptions.MessageOptionItemState +import io.getstream.chat.android.compose.ui.components.Timestamp +import io.getstream.chat.android.compose.ui.components.avatar.Avatar +import io.getstream.chat.android.compose.ui.theme.ChatComponentFactory +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelUserRead +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.User +import io.getstream.chat.android.state.extensions.watchChannelAsState +import io.getstream.chat.android.ui.common.state.messages.CustomAction +import io.getstream.chat.android.ui.common.state.messages.MessageAction +import io.getstream.chat.android.ui.common.utils.extensions.initials +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import java.util.Calendar +import java.util.Date +import kotlin.time.Duration.Companion.hours + +/** + * Factory for creating components related to message info. + */ +class MessageInfoComponentFactory : ChatComponentFactory { + + /** + * Creates a message menu with option for message info. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Suppress("LongMethod") + @Composable + override fun MessageMenu( + modifier: Modifier, + message: Message, + messageOptions: List, + ownCapabilities: Set, + onMessageAction: (MessageAction) -> Unit, + onShowMore: () -> Unit, + onDismiss: () -> Unit, + ) { + var showMessageInfoDialog by remember { mutableStateOf(false) } + + val allOptions = listOf( + MessageOptionItemState( + title = R.string.message_option_message_info, + titleColor = ChatTheme.colors.textHighEmphasis, + iconPainter = rememberVectorPainter(Icons.Outlined.Info), + iconColor = ChatTheme.colors.textLowEmphasis, + action = CustomAction(message, mapOf("message_info" to true)), + ), + ) + messageOptions + + val extendedOnMessageAction: (MessageAction) -> Unit = { action -> + when { + action is CustomAction && action.extraProperties.contains("message_info") -> + showMessageInfoDialog = true + + else -> onMessageAction(action) + } + } + + var dismissed by remember { mutableStateOf(false) } + + if (showMessageInfoDialog) { + ModalBottomSheet( + onDismissRequest = { + showMessageInfoDialog = false + onDismiss() + dismissed = true // Mark as dismissed to avoid animating the menu again + }, + containerColor = ChatTheme.colors.appBackground, + ) { + val coroutineScope = rememberCoroutineScope() + val state by readsOf(message, coroutineScope).collectAsState(null) + state?.let { + val (reads, deliveredReads) = it + MessageInfoContent( + reads = reads, + deliveredReads = deliveredReads, + ) + } + } + } else if (!dismissed) { + super.MessageMenu( + modifier = modifier, + message = message, + messageOptions = allOptions, + ownCapabilities = ownCapabilities, + onMessageAction = extendedOnMessageAction, + onShowMore = onShowMore, + onDismiss = onDismiss, + ) + } + } + + @Composable + private fun readsOf( + message: Message, + coroutineScope: CoroutineScope, + ): Flow, List>> = ChatClient.instance() + .watchChannelAsState( + cid = message.cid, + messageLimit = 0, + coroutineScope = coroutineScope, + ).filterNotNull() + .flatMapLatest { it.reads } + .map { + val channel = Channel(read = it) + + val reads = channel.readsOf(message) + .sortedByDescending(ChannelUserRead::lastRead) + + val deliveredReads = channel.deliveredReadsOf(message) + .sortedByDescending { it.lastDeliveredAt ?: Date(0) } - reads + + reads to deliveredReads + } +} + +@Composable +private fun MessageInfoContent( + reads: List, + deliveredReads: List, +) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + // Read by section + section( + items = reads, + labelResId = R.string.message_info_read_by, + skipTopPadding = true, + getDate = ChannelUserRead::lastRead, + ) + // Delivered to section + section( + items = deliveredReads, + labelResId = R.string.message_info_delivered_to, + getDate = ChannelUserRead::lastDeliveredAt, + ) + } +} + +private fun LazyListScope.section( + items: List, + @StringRes labelResId: Int, + skipTopPadding: Boolean = false, + getDate: (ChannelUserRead) -> Date?, +) { + if (items.isNotEmpty()) { + item { + if (skipTopPadding) { + PaneTitle( + text = stringResource(labelResId, items.size), + padding = PaddingValues( + start = 16.dp, + bottom = 8.dp, + end = 16.dp, + ), + ) + } else { + PaneTitle(text = stringResource(labelResId, items.size)) + } + } + itemsIndexed( + items = items, + key = { _, item -> item.user.id }, + ) { index, item -> + PaneRow( + index = index, + lastIndex = items.lastIndex, + ) { + ReadItem( + userRead = item, + getDate = getDate, + ) + } + } + } +} + +@Composable +private fun ReadItem( + userRead: ChannelUserRead, + getDate: (ChannelUserRead) -> Date?, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + modifier = Modifier.size(48.dp), + imageUrl = userRead.user.image, + initials = userRead.user.initials, + contentDescription = userRead.user.name, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = userRead.user.name.takeIf(String::isNotBlank) ?: userRead.user.id, + style = ChatTheme.typography.bodyBold, + color = ChatTheme.colors.textHighEmphasis, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Timestamp( + date = getDate(userRead), + formatType = DateFormatType.RELATIVE, + ) + } + } +} + +@Suppress("MagicNumber") +@Preview +@Composable +private fun MessageInfoScreenPreview() { + val sentDate = Calendar.getInstance().apply { + set(2025, Calendar.AUGUST, 15, 8, 15) + }.time + val user1 = User(id = "jane", name = "Jane Doe") + val user2 = User(id = "bob", name = "Bob Smith") + val user3 = User(id = "alice", name = "Alice Johnson") + val reads = listOf( + ChannelUserRead( + user = user1, + lastReceivedEventDate = Date(), + unreadMessages = 0, + lastRead = sentDate.apply { time += 2.hours.inWholeMilliseconds }, + lastReadMessageId = null, + ), + ChannelUserRead( + user = user2, + lastReceivedEventDate = Date(), + unreadMessages = 0, + lastRead = sentDate.apply { time += 3.hours.inWholeMilliseconds }, + lastReadMessageId = null, + ), + ) + val deliveredReads = listOf( + ChannelUserRead( + user = user3, + lastReceivedEventDate = Date(), + unreadMessages = 0, + lastRead = Date(), + lastReadMessageId = null, + lastDeliveredAt = sentDate.apply { time += 1.hours.inWholeMilliseconds }, + lastDeliveredMessageId = null, + ), + ) + ChatTheme { + MessageInfoContent( + deliveredReads = deliveredReads, + reads = reads, + ) + } +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/Pane.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/Pane.kt new file mode 100644 index 00000000000..4d937355e56 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/Pane.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.sample.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.ui.theme.ChatTheme + +@Composable +internal fun PaneTitle( + text: String, + padding: PaddingValues = PaddingValues( + top = 24.dp, + start = 16.dp, + bottom = 8.dp, + end = 16.dp, + ), +) { + Text( + modifier = Modifier.padding(padding), + text = text, + style = ChatTheme.typography.footnote, + color = ChatTheme.colors.textLowEmphasis, + ) +} + +@Composable +internal fun PaneRow( + index: Int, + lastIndex: Int, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .run { + val shape = when (index) { + 0 -> if (lastIndex == 0) { + // Single item in the list + RoundedCornerShape(12.dp) + } else { + // Top item in the list + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + } + // Bottom item in the list + lastIndex -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + // Middle item in the list + else -> RectangleShape + } + background( + color = ChatTheme.colors.barsBackground, + shape = shape, + ) + } + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + content = content, + ) +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfilePrivacySettingsScreen.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfilePrivacySettingsScreen.kt new file mode 100644 index 00000000000..04409a4f1d2 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfilePrivacySettingsScreen.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.sample.ui.profile + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatTheme + +@Suppress("LongMethod") +@Composable +fun UserProfilePrivacySettingsScreen( + privacySettings: PrivacySettings?, + onSaveClick: (settings: PrivacySettings) -> Unit, +) { + var typingIndicators by remember(privacySettings) { + mutableStateOf(privacySettings?.typingIndicators ?: TypingIndicators()) + } + var deliveryReceipts by remember(privacySettings) { + mutableStateOf(privacySettings?.deliveryReceipts ?: DeliveryReceipts()) + } + var readReceipts by remember(privacySettings) { + mutableStateOf(privacySettings?.readReceipts ?: ReadReceipts()) + } + + Column { + SwitchItem( + label = "Typing Indicators", + checked = typingIndicators.enabled, + onCheckedChange = { checked -> + typingIndicators = typingIndicators.copy(enabled = checked) + }, + ) + SwitchItem( + label = "Delivery Receipts", + checked = deliveryReceipts.enabled, + onCheckedChange = { checked -> + deliveryReceipts = deliveryReceipts.copy(enabled = checked) + }, + ) + SwitchItem( + label = "Read Receipts", + checked = readReceipts.enabled, + onCheckedChange = { checked -> + readReceipts = readReceipts.copy(enabled = checked) + }, + ) + Button( + onClick = { + onSaveClick( + PrivacySettings( + typingIndicators = typingIndicators, + deliveryReceipts = deliveryReceipts, + readReceipts = readReceipts, + ), + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = ChatTheme.colors.primaryAccent, + ), + shape = RoundedCornerShape(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.stream_compose_ic_checkmark), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "Save Settings", + style = ChatTheme.typography.bodyBold.copy( + color = Color.White, + fontSize = 16.sp, + ), + ) + } + } + } +} + +@Composable +private fun SwitchItem( + label: String, + checked: Boolean, + onCheckedChange: (checked: Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = null, + indication = ripple(), + onClick = { onCheckedChange(!checked) }, + ) + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = ChatTheme.typography.title3, + color = ChatTheme.colors.textHighEmphasis, + ) + Switch( + checked = checked, + onCheckedChange = null, + ) + } +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt index 2393a4f47b6..ecdd5d58856 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -81,6 +80,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import io.getstream.chat.android.compose.sample.R +import io.getstream.chat.android.compose.sample.ui.component.PaneRow +import io.getstream.chat.android.compose.sample.ui.component.PaneTitle import io.getstream.chat.android.compose.ui.components.BackButton import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.avatar.UserAvatar @@ -151,6 +152,9 @@ fun UserProfileScreen( onUpdateProfilePictureClick = { modalSheet = ModalSheet.UpdateProfilePicture }, + onUpdatePrivacySettingsClick = { + modalSheet = ModalSheet.UpdatePrivacySettings + }, ) } when (modalSheet) { @@ -201,6 +205,21 @@ fun UserProfileScreen( ) } + ModalSheet.UpdatePrivacySettings -> ModalBottomSheet( + onDismissRequest = { modalSheet = null }, + containerColor = ChatTheme.colors.appBackground, + ) { + state.user?.let { user -> + UserProfilePrivacySettingsScreen( + privacySettings = user.privacySettings, + onSaveClick = { settings -> + modalSheet = null + viewModel.updatePrivacySettings(settings) + }, + ) + } + } + null -> Unit } @@ -217,6 +236,7 @@ fun UserProfileScreen( is UserProfileViewEvent.RemoveProfilePictureError, -> snackbarHostState.showSnackbar(message = event.error.message, actionLabel = "Dismiss") + is UserProfileViewEvent.UpdatePushPreferencesError -> { snackbarHostState.showSnackbar(message = event.error.message, actionLabel = "Dismiss") } @@ -283,9 +303,9 @@ private enum class ModalSheet { UnreadCounts, PushPreferences, UpdateProfilePicture, + UpdatePrivacySettings, } -@OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @Composable private fun UserProfileScreenContent( @@ -294,6 +314,7 @@ private fun UserProfileScreenContent( onUnreadCountsClick: () -> Unit = {}, onPushPreferencesClick: () -> Unit = {}, onUpdateProfilePictureClick: () -> Unit = {}, + onUpdatePrivacySettingsClick: () -> Unit = {}, ) { when (val user = state.user) { null -> { @@ -358,60 +379,56 @@ private fun UserProfileScreenContent( } Divider() } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = null, - indication = ripple(), - onClick = onUnreadCountsClick, - ) - .padding(start = 16.dp) - .minimumInteractiveComponentSize(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Unread Counts", - style = ChatTheme.typography.title3, - color = ChatTheme.colors.textHighEmphasis, - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = ChatTheme.colors.textLowEmphasis, - ) - } + NavigationItem( + label = "Unread Counts", + onClick = onUnreadCountsClick, + ) Divider() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = null, - indication = ripple(), - onClick = onPushPreferencesClick, - ) - .padding(start = 16.dp) - .minimumInteractiveComponentSize(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Push Preferences", - style = ChatTheme.typography.title3, - color = ChatTheme.colors.textHighEmphasis, - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = ChatTheme.colors.textLowEmphasis, - ) - } + NavigationItem( + label = "Push Preferences", + onClick = onPushPreferencesClick, + ) + Divider() + NavigationItem( + label = "Privacy Settings", + onClick = onUpdatePrivacySettingsClick, + ) } } } } +@Composable +private fun NavigationItem( + label: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = null, + indication = ripple(), + onClick = onClick, + ) + .padding(start = 16.dp) + .minimumInteractiveComponentSize(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = ChatTheme.typography.title3, + color = ChatTheme.colors.textHighEmphasis, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = ChatTheme.colors.textLowEmphasis, + ) + } +} + @Composable private fun UserProfilePicture( modifier: Modifier, @@ -752,58 +769,6 @@ private fun LazyListScope.unreadChannelsByTeam( } } -@Composable -private fun PaneTitle( - text: String, - padding: PaddingValues = PaddingValues( - top = 24.dp, - start = 16.dp, - bottom = 8.dp, - end = 16.dp, - ), -) { - Text( - modifier = Modifier.padding(padding), - text = text, - style = ChatTheme.typography.footnote, - color = ChatTheme.colors.textLowEmphasis, - ) -} - -@Composable -private fun PaneRow( - index: Int, - lastIndex: Int, - content: @Composable RowScope.() -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .run { - val shape = when (index) { - 0 -> if (lastIndex == 0) { - // Single item in the list - RoundedCornerShape(12.dp) - } else { - // Top item in the list - RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) - } - // Bottom item in the list - lastIndex -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) - // Middle item in the list - else -> RectangleShape - } - background( - color = ChatTheme.colors.barsBackground, - shape = shape, - ) - } - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - content = content, - ) -} - @Composable private fun CountText( count: Int, diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileViewModel.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileViewModel.kt index 4f544a93a5b..9e582584d79 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileViewModel.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileViewModel.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.compose.sample.ui.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import io.getstream.chat.android.PrivacySettings import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.utils.ProgressCallback import io.getstream.chat.android.models.PushPreferenceLevel @@ -100,6 +101,14 @@ class UserProfileViewModel( } } + fun updatePrivacySettings(settings: PrivacySettings) { + viewModelScope.launch { + val user = state.value.user!! + chatClient.updateUser(user = user.copy(privacySettings = settings)) + .await() + } + } + fun loadUnreadCounts() { _state.update { currentState -> currentState.copy(unreadCounts = null) } viewModelScope.launch { diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index e1722ecd422..8a379a32c59 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -122,9 +122,14 @@ Delete Message For Me + Message Info Failed to load more media attachments Failed to load more files attachments + + READ BY (%d) + DELIVERED TO (%d) + 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 344e7798bfb..b15fe63f504 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1262,6 +1262,8 @@ public final class io/getstream/chat/android/compose/ui/channels/list/Composable public static field lambda-4 Lkotlin/jvm/functions/Function2; public static field lambda-5 Lkotlin/jvm/functions/Function2; public static field lambda-6 Lkotlin/jvm/functions/Function2; + public static field lambda-7 Lkotlin/jvm/functions/Function2; + public static field lambda-8 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; @@ -1269,6 +1271,8 @@ public final class io/getstream/chat/android/compose/ui/channels/list/Composable public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-7$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-8$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelListKt { @@ -1613,7 +1617,7 @@ public final class io/getstream/chat/android/compose/ui/components/channels/Comp public final class io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIconKt { public static final fun MessageReadStatusIcon (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V - public static final fun MessageReadStatusIcon (Lio/getstream/chat/android/models/Message;ZLandroidx/compose/ui/Modifier;ILkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun MessageReadStatusIcon (Lio/getstream/chat/android/models/Message;ZLandroidx/compose/ui/Modifier;ZILkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/components/channels/UnreadCountIndicatorKt { @@ -2908,6 +2912,7 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC public abstract fun MessageFooterContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageFooterOnlyVisibleToYouContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageFooterStatusIndicator (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Message;ZILandroidx/compose/runtime/Composer;I)V + public abstract fun MessageFooterStatusIndicator (Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageFooterUploadingContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageGiphyContent (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageItemCenterContent (Landroidx/compose/foundation/layout/ColumnScope;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V @@ -3089,6 +3094,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFacto public static fun MessageFooterContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public static fun MessageFooterOnlyVisibleToYouContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public static fun MessageFooterStatusIndicator (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Message;ZILandroidx/compose/runtime/Composer;I)V + public static fun MessageFooterStatusIndicator (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams;Landroidx/compose/runtime/Composer;I)V public static fun MessageFooterUploadingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public static fun MessageGiphyContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public static fun MessageItemCenterContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/ColumnScope;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V @@ -3522,6 +3528,21 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageDateSeparat public final fun defaultTheme (Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Landroidx/compose/runtime/Composer;II)Lio/getstream/chat/android/compose/ui/theme/MessageDateSeparatorTheme; } +public final class io/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams { + public static final field $stable I + public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; + public final fun component2 ()Landroidx/compose/ui/Modifier; + public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;)Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessageItem ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; + public final fun getModifier ()Landroidx/compose/ui/Modifier; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/compose/ui/theme/MessageOptionsTheme { public static final field $stable I public static final field Companion Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme$Companion; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt index a3a3b7d76c6..cdc5e73d9a7 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("TooManyFunctions") + package io.getstream.chat.android.compose.ui.channels.list import androidx.compose.foundation.ExperimentalFoundationApi @@ -46,6 +48,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.getstream.chat.android.client.extensions.currentUserUnreadCount +import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.ui.components.Timestamp @@ -54,10 +57,12 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.getLastMessage import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.DraftMessage +import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.User import io.getstream.chat.android.previewdata.PreviewChannelData import io.getstream.chat.android.previewdata.PreviewChannelUserRead import io.getstream.chat.android.previewdata.PreviewUserData +import java.util.Date /** * The basic channel item, that shows the channel in a list and exposes single and long click actions. @@ -308,7 +313,7 @@ internal fun RowScope.DefaultChannelItemTrailingContent( Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { if (isLastMessageFromCurrentUser) { ChatTheme.componentFactory.ChannelItemReadStatusIndicator( @@ -376,6 +381,26 @@ internal fun ChannelItemUnreadMessages() { ) } +@Preview(showBackground = true) +@Composable +private fun ChannelItemLastMessagePendingStatusPreview() { + ChatTheme { + ChannelItemLastMessagePendingStatus() + } +} + +@Composable +internal fun ChannelItemLastMessagePendingStatus() { + ChannelItem( + currentUser = PreviewUserData.user1, + channel = PreviewChannelData.channelWithMessages.copy( + messages = PreviewChannelData.channelWithMessages.messages.map { message -> + message.copy(user = PreviewUserData.user1, syncStatus = SyncStatus.SYNC_NEEDED) + }, + ), + ) +} + @Preview(showBackground = true) @Composable private fun ChannelItemLastMessageSentStatusPreview() { @@ -396,6 +421,32 @@ internal fun ChannelItemLastMessageSentStatus() { ) } +@Preview(showBackground = true) +@Composable +private fun ChannelItemLastMessageDeliveredStatusPreview() { + ChatTheme { + ChannelItemLastMessageDeliveredStatus() + } +} + +@Composable +internal fun ChannelItemLastMessageDeliveredStatus() { + ChannelItem( + currentUser = PreviewUserData.user1, + channel = PreviewChannelData.channelWithMessages.copy( + messages = PreviewChannelData.channelWithMessages.messages.map { message -> + message.copy(user = PreviewUserData.user1) + }, + read = listOf( + PreviewChannelUserRead.channelUserRead2.copy( + lastRead = NEVER, + lastDeliveredAt = Date(), + ), + ), + ), + ) +} + @Preview(showBackground = true) @Composable private fun ChannelItemLastMessageSeenStatusPreview() { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt index 0bcae9f7a04..78ccad5cc69 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.getstream.chat.android.client.extensions.deliveredReadsOf import io.getstream.chat.android.client.extensions.getCreatedAtOrThrow import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.theme.ChatTheme @@ -59,11 +60,13 @@ public fun MessageReadStatusIcon( val readStatuses = channel.getReadStatuses(userToIgnore = currentUser) val readCount = readStatuses.count { it.time >= message.getCreatedAtOrThrow().time } val isMessageRead = readCount != 0 + val isMessageDelivered = channel.deliveredReadsOf(message).isNotEmpty() MessageReadStatusIcon( modifier = modifier, message = message, isMessageRead = isMessageRead, + isMessageDelivered = isMessageDelivered, readCount = readCount, ) } @@ -73,6 +76,7 @@ public fun MessageReadStatusIcon( * * @param message The message with sync status to check. * @param isMessageRead If the message is read by any member. + * @param isMessageDelivered If the message is delivered to any member. * @param modifier Modifier for styling. */ @Composable @@ -80,19 +84,28 @@ public fun MessageReadStatusIcon( message: Message, isMessageRead: Boolean, modifier: Modifier = Modifier, + isMessageDelivered: Boolean = false, readCount: Int = 0, isReadIcon: @Composable () -> Unit = { IsReadCount(modifier = modifier, readCount = readCount) }, isPendingIcon: @Composable () -> Unit = { IsPendingIcon(modifier = modifier) }, isSentIcon: @Composable () -> Unit = { IsSentIcon(modifier = modifier) }, + isDeliveredIcon: @Composable () -> Unit = { IsDeliveredIcon(modifier = modifier) }, ) { val syncStatus = message.syncStatus - when { - isMessageRead -> isReadIcon() - syncStatus == SyncStatus.SYNC_NEEDED || - syncStatus == SyncStatus.AWAITING_ATTACHMENTS -> isPendingIcon() + when (syncStatus) { + SyncStatus.IN_PROGRESS, + SyncStatus.SYNC_NEEDED, + SyncStatus.AWAITING_ATTACHMENTS, + -> isPendingIcon() - syncStatus == SyncStatus.COMPLETED -> isSentIcon() + SyncStatus.COMPLETED -> when { + isMessageRead -> isReadIcon() + isMessageDelivered -> isDeliveredIcon() + else -> isSentIcon() + } + + SyncStatus.FAILED_PERMANENTLY -> Unit } } @@ -110,7 +123,7 @@ private fun IsReadCount( Row( modifier = modifier .semantics { contentDescription = description } - .padding(horizontal = 2.dp), + .padding(start = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { @@ -151,6 +164,18 @@ private fun IsSentIcon(modifier: Modifier) { ) } +@Composable +private fun IsDeliveredIcon(modifier: Modifier) { + Icon( + modifier = modifier.testTag("Stream_MessageReadStatus_isDelivered"), + painter = painterResource(id = R.drawable.stream_compose_message_seen), + contentDescription = stringResource( + R.string.stream_ui_message_list_semantics_message_status_delivered, + ), + tint = ChatTheme.colors.textLowEmphasis, + ) +} + /** * Preview of [MessageReadStatusIcon] for a seen message. * diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt index b6a8f861d63..e9f068a1e6f 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt @@ -39,10 +39,12 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.DateFormatType import io.getstream.chat.android.compose.ui.components.Timestamp import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.MessageFooterStatusIndicatorParams import io.getstream.chat.android.compose.ui.util.clickable import io.getstream.chat.android.core.utils.date.truncateFuture import io.getstream.chat.android.models.Message import io.getstream.chat.android.ui.common.state.messages.list.MessageItemState +import io.getstream.chat.android.ui.common.utils.extensions.shouldShowMessageStatusIndicator /** * Default message footer, which contains either [MessageThreadFooter] or the default footer, which @@ -101,12 +103,12 @@ public fun MessageFooter( maxLines = 1, color = ChatTheme.colors.textLowEmphasis, ) - } else { + } else if (message.shouldShowMessageStatusIndicator()) { ChatTheme.componentFactory.MessageFooterStatusIndicator( - modifier = Modifier.padding(end = 4.dp), - message = message, - isMessageRead = messageItem.isMessageRead, - readCount = messageItem.messageReadBy.size, + params = MessageFooterStatusIndicatorParams( + modifier = Modifier.padding(end = 4.dp), + messageItem = messageItem, + ), ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index ab679a90218..f43022ab311 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -1373,6 +1373,18 @@ public interface ChatComponentFactory { /** * The default read status indicator in the message footer, weather the message is sent, pending or read. */ + @Deprecated( + message = "Use the new version of MessageFooterStatusIndicator that takes MessageFooterStatusIndicatorParams.", + replaceWith = ReplaceWith( + "MessageFooterStatusIndicator(\n" + + " params = MessageFooterStatusIndicatorParams(\n" + + " modifier = modifier,\n" + + " messageItem = messageItem,\n" + + " ),\n" + + ")", + ), + level = DeprecationLevel.WARNING, + ) @Composable public fun MessageFooterStatusIndicator( modifier: Modifier, @@ -1388,6 +1400,28 @@ public interface ChatComponentFactory { ) } + @Composable + public fun MessageFooterStatusIndicator( + params: MessageFooterStatusIndicatorParams, + ) { + if (params.messageItem.isMessageDelivered) { + MessageReadStatusIcon( + modifier = params.modifier, + message = params.messageItem.message, + isMessageRead = params.messageItem.isMessageRead, + isMessageDelivered = params.messageItem.isMessageDelivered, + readCount = params.messageItem.messageReadBy.size, + ) + } else { + MessageFooterStatusIndicator( + modifier = params.modifier, + message = params.messageItem.message, + isMessageRead = params.messageItem.isMessageRead, + readCount = params.messageItem.messageReadBy.size, + ) + } + } + /** * The default message composer that contains * the message input, attachments, commands, recording actions, integrations, and the send button. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index c845dc3ce70..d63cdc1fc9c 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.getstream.chat.android.compose.state.reactionoptions.ReactionOptionItemState import io.getstream.chat.android.models.Message +import io.getstream.chat.android.ui.common.state.messages.list.MessageItemState /** * Parameters for the [ChatComponentFactory.MessageReactionList] component. @@ -59,3 +60,14 @@ public data class ChannelMediaAttachmentsPreviewBottomBarParams( val leadingContent: @Composable () -> Unit = {}, val trailingContent: @Composable () -> Unit = {}, ) + +/** + * Parameters for the [ChatComponentFactory.MessageFooterStatusIndicator] component. + * + * @param messageItem The message item state. + * @param modifier Modifier for styling. + */ +public data class MessageFooterStatusIndicatorParams( + val messageItem: MessageItemState, + val modifier: Modifier = Modifier, +) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt index 0a93c3c95c7..76056fe3394 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt @@ -20,6 +20,8 @@ import app.cash.paparazzi.DeviceConfig import app.cash.paparazzi.Paparazzi import io.getstream.chat.android.compose.ui.PaparazziComposeTest import io.getstream.chat.android.compose.ui.channels.list.ChannelItemDraftMessage +import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessageDeliveredStatus +import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessagePendingStatus import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessageSeenStatus import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessageSentStatus import io.getstream.chat.android.compose.ui.channels.list.ChannelItemMuted @@ -54,6 +56,13 @@ internal class ChannelItemTest : PaparazziComposeTest { } } + @Test + fun `last message pending status`() { + snapshotWithDarkMode { + ChannelItemLastMessagePendingStatus() + } + } + @Test fun `last message sent status`() { snapshotWithDarkMode { @@ -61,6 +70,13 @@ internal class ChannelItemTest : PaparazziComposeTest { } } + @Test + fun `last message delivered status`() { + snapshotWithDarkMode { + ChannelItemLastMessageDeliveredStatus() + } + } + @Test fun `last message seen status`() { snapshotWithDarkMode { diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_delivered_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_delivered_status.png new file mode 100644 index 00000000000..610bc498f6e Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_delivered_status.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_pending_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_pending_status.png new file mode 100644 index 00000000000..7800d70b03e Binary files /dev/null and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_pending_status.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_seen_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_seen_status.png index 4ceea7e8f61..3005fcdcd40 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_seen_status.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_seen_status.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_sent_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_sent_status.png index 2451b4566ce..46979a02fcf 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_sent_status.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_sent_status.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages.png index f9aa25c39ce..9c816cda219 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png index 8e158b4d67c..ffd31ca7350 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png differ diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 0a309e3930c..7f9d23caac9 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1,12 +1,27 @@ +public final class io/getstream/chat/android/DeliveryReceipts { + public fun ()V + public fun (Z)V + public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun copy (Z)Lio/getstream/chat/android/DeliveryReceipts; + public static synthetic fun copy$default (Lio/getstream/chat/android/DeliveryReceipts;ZILjava/lang/Object;)Lio/getstream/chat/android/DeliveryReceipts; + public fun equals (Ljava/lang/Object;)Z + public final fun getEnabled ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/PrivacySettings { public fun ()V - public fun (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/ReadReceipts;)V - public synthetic fun (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/ReadReceipts;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/DeliveryReceipts;Lio/getstream/chat/android/ReadReceipts;)V + public synthetic fun (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/DeliveryReceipts;Lio/getstream/chat/android/ReadReceipts;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/TypingIndicators; - public final fun component2 ()Lio/getstream/chat/android/ReadReceipts; - public final fun copy (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/ReadReceipts;)Lio/getstream/chat/android/PrivacySettings; - public static synthetic fun copy$default (Lio/getstream/chat/android/PrivacySettings;Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/ReadReceipts;ILjava/lang/Object;)Lio/getstream/chat/android/PrivacySettings; + public final fun component2 ()Lio/getstream/chat/android/DeliveryReceipts; + public final fun component3 ()Lio/getstream/chat/android/ReadReceipts; + public final fun copy (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/DeliveryReceipts;Lio/getstream/chat/android/ReadReceipts;)Lio/getstream/chat/android/PrivacySettings; + public static synthetic fun copy$default (Lio/getstream/chat/android/PrivacySettings;Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/DeliveryReceipts;Lio/getstream/chat/android/ReadReceipts;ILjava/lang/Object;)Lio/getstream/chat/android/PrivacySettings; public fun equals (Ljava/lang/Object;)Z + public final fun getDeliveryReceipts ()Lio/getstream/chat/android/DeliveryReceipts; public final fun getReadReceipts ()Lio/getstream/chat/android/ReadReceipts; public final fun getTypingIndicators ()Lio/getstream/chat/android/TypingIndicators; public fun hashCode ()I @@ -484,6 +499,7 @@ public final class io/getstream/chat/android/models/ChannelCapabilities { public static final field DELETE_ANY_MESSAGE Ljava/lang/String; public static final field DELETE_CHANNEL Ljava/lang/String; public static final field DELETE_OWN_MESSAGE Ljava/lang/String; + public static final field DELIVERY_EVENTS Ljava/lang/String; public static final field FLAG_MESSAGE Ljava/lang/String; public static final field FREEZE_CHANNEL Ljava/lang/String; public static final field INSTANCE Lio/getstream/chat/android/models/ChannelCapabilities; @@ -628,15 +644,20 @@ public abstract interface class io/getstream/chat/android/models/ChannelTransfor } public final class io/getstream/chat/android/models/ChannelUserRead : io/getstream/chat/android/models/UserEntity { - public fun (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;)V + public fun (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)V + public synthetic fun (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/User; public final fun component2 ()Ljava/util/Date; public final fun component3 ()I public final fun component4 ()Ljava/util/Date; public final fun component5 ()Ljava/lang/String; - public final fun copy (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;)Lio/getstream/chat/android/models/ChannelUserRead; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/ChannelUserRead;Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/ChannelUserRead; + public final fun component6 ()Ljava/util/Date; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)Lio/getstream/chat/android/models/ChannelUserRead; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/ChannelUserRead;Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/ChannelUserRead; public fun equals (Ljava/lang/Object;)Z + public final fun getLastDeliveredAt ()Ljava/util/Date; + public final fun getLastDeliveredMessageId ()Ljava/lang/String; public final fun getLastRead ()Ljava/util/Date; public final fun getLastReadMessageId ()Ljava/lang/String; public final fun getLastReceivedEventDate ()Ljava/util/Date; @@ -666,8 +687,8 @@ public final class io/getstream/chat/android/models/Command { public final class io/getstream/chat/android/models/Config { public fun ()V - public fun (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZ)V - public synthetic fun (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZ)V + public synthetic fun (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/Date; public final fun component10 ()Z public final fun component11 ()Z @@ -676,16 +697,17 @@ public final class io/getstream/chat/android/models/Config { public final fun component14 ()Z public final fun component15 ()Z public final fun component16 ()Z - public final fun component17 ()Ljava/lang/String; - public final fun component18 ()I - public final fun component19 ()Ljava/lang/String; + public final fun component17 ()Z + public final fun component18 ()Ljava/lang/String; + public final fun component19 ()I public final fun component2 ()Ljava/util/Date; public final fun component20 ()Ljava/lang/String; public final fun component21 ()Ljava/lang/String; - public final fun component22 ()Ljava/util/List; - public final fun component23 ()Z + public final fun component22 ()Ljava/lang/String; + public final fun component23 ()Ljava/util/List; public final fun component24 ()Z public final fun component25 ()Z + public final fun component26 ()Z public final fun component3 ()Ljava/lang/String; public final fun component4 ()Z public final fun component5 ()Z @@ -693,8 +715,8 @@ public final class io/getstream/chat/android/models/Config { public final fun component7 ()Z public final fun component8 ()Z public final fun component9 ()Z - public final fun copy (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZ)Lio/getstream/chat/android/models/Config; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/Config;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZILjava/lang/Object;)Lio/getstream/chat/android/models/Config; + public final fun copy (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZ)Lio/getstream/chat/android/models/Config; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/Config;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZILjava/lang/Object;)Lio/getstream/chat/android/models/Config; public fun equals (Ljava/lang/Object;)Z public final fun getAutomod ()Ljava/lang/String; public final fun getAutomodBehavior ()Ljava/lang/String; @@ -703,6 +725,7 @@ public final class io/getstream/chat/android/models/Config { public final fun getConnectEventsEnabled ()Z public final fun getCreatedAt ()Ljava/util/Date; public final fun getCustomEventsEnabled ()Z + public final fun getDeliveryEventsEnabled ()Z public final fun getMarkMessagesPending ()Z public final fun getMaxMessageLength ()I public final fun getMessageRemindersEnabled ()Z @@ -902,6 +925,7 @@ public final class io/getstream/chat/android/models/EventType { public static final field MEMBER_REMOVED Ljava/lang/String; public static final field MEMBER_UPDATED Ljava/lang/String; public static final field MESSAGE_DELETED Ljava/lang/String; + public static final field MESSAGE_DELIVERED Ljava/lang/String; public static final field MESSAGE_NEW Ljava/lang/String; public static final field MESSAGE_READ Ljava/lang/String; public static final field MESSAGE_UPDATED Ljava/lang/String; @@ -2346,6 +2370,7 @@ public final class io/getstream/chat/android/models/User : io/getstream/chat/and public final fun getUpdatedAt ()Ljava/util/Date; public fun hashCode ()I public final fun isBanned ()Z + public final fun isDeliveryReceiptsEnabled ()Z public final fun isInvisible ()Z public final fun isReadReceiptsEnabled ()Z public final fun isTypingIndicatorsEnabled ()Z diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/PrivacySettings.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/PrivacySettings.kt index e881069aaff..a12752441d3 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/PrivacySettings.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/PrivacySettings.kt @@ -22,11 +22,13 @@ import androidx.compose.runtime.Immutable * Represents the privacy settings of a user. * * @param typingIndicators Typing indicators settings. + * @param deliveryReceipts Delivery receipts settings. * @param readReceipts Read receipts settings. */ @Immutable public data class PrivacySettings( public val typingIndicators: TypingIndicators? = null, + public val deliveryReceipts: DeliveryReceipts? = null, public val readReceipts: ReadReceipts? = null, ) @@ -41,6 +43,17 @@ public data class TypingIndicators( val enabled: Boolean = true, ) +/** + * Represents the delivery receipts settings. + * If false, the user delivery events will not be sent to other users, along with the user's delivery state. + * + * @param enabled Whether delivery receipts are enabled or not. + */ +@Immutable +public data class DeliveryReceipts( + val enabled: Boolean = true, +) + /** * Represents the read receipts settings. * If false, the user read events will not be sent to other users, along with the user's read state. diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Channel.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Channel.kt index 0f9490860c1..4b3c27ce495 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Channel.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Channel.kt @@ -132,6 +132,14 @@ public data class Channel( /** * Whether a channel contains unread messages or not. */ + @Deprecated( + message = "Use the extension property Channel.currentUserUnreadCount instead and check if it's greater than 0", + replaceWith = ReplaceWith( + expression = "currentUserUnreadCount", + imports = ["io.getstream.chat.android.client.extensions.currentUserUnreadCount"], + ), + level = DeprecationLevel.WARNING, + ) val hasUnread: Boolean get() = unreadCount > 0 diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt index b0a2690f7a6..e097ce44245 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt @@ -66,6 +66,9 @@ public object ChannelCapabilities { /** Ability to receive read events. */ public const val READ_EVENTS: String = "read-events" + /** Ability to receive delivery events. */ + public const val DELIVERY_EVENTS: String = "delivery-events" + /** Ability to use message search. */ public const val SEARCH_MESSAGES: String = "search-messages" diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelUserRead.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelUserRead.kt index a47d8ba8b16..dcd3b38399e 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelUserRead.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelUserRead.kt @@ -20,13 +20,17 @@ import androidx.compose.runtime.Immutable import java.util.Date /** - * Information about how many messages are unread in the channel by a given user. + * Represents a user's last read and delivered status in a channel. + * Contains information about how many messages have not been read, + * the last read information, and the last delivered information. * * @property user The user which has read some of the messages and may have some unread messages. * @property lastReceivedEventDate The time of the event that updated this [ChannelUserRead] object. * @property lastRead The time of the last read message. * @property unreadMessages How many messages are unread. * @property lastReadMessageId The ID of the last read message. + * @property lastDeliveredAt The time of the last delivered message. + * @property lastDeliveredMessageId The ID of the last delivered message. */ @Immutable public data class ChannelUserRead( @@ -35,4 +39,6 @@ public data class ChannelUserRead( val unreadMessages: Int, val lastRead: Date, val lastReadMessageId: String?, + val lastDeliveredAt: Date? = null, + val lastDeliveredMessageId: String? = null, ) : UserEntity diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Config.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Config.kt index a58fa5609e4..1db1db59a4d 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Config.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Config.kt @@ -50,6 +50,11 @@ public data class Config( */ val readEventsEnabled: Boolean = true, + /** + * Determines if events are fired for message deliveries. Enabled by default. + */ + val deliveryEventsEnabled: Boolean = true, + /** * Determines if events are fired for connecting and disconnecting to a chat. Enabled by default. */ diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt index 50493d47378..8451d1d0ed1 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt @@ -40,6 +40,7 @@ public object EventType { public const val MESSAGE_UPDATED: String = "message.updated" public const val MESSAGE_DELETED: String = "message.deleted" public const val MESSAGE_READ: String = "message.read" + public const val MESSAGE_DELIVERED: String = "message.delivered" public const val REACTION_NEW: String = "reaction.new" public const val REACTION_DELETED: String = "reaction.deleted" public const val REACTION_UPDATED: String = "reaction.updated" diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt index 12063a84f2f..704e2005675 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt @@ -99,6 +99,11 @@ public data class User( */ val isReadReceiptsEnabled: Boolean get() = privacySettings?.readReceipts?.enabled ?: true + /** + * Determines if the user has delivery receipts enabled. + */ + val isDeliveryReceiptsEnabled: Boolean get() = privacySettings?.deliveryReceipts?.enabled ?: true + override fun getComparableField(fieldName: String): Comparable<*>? { return when (fieldName) { "id" -> id diff --git a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt index 65c607760f7..ebf4da7cd8b 100644 --- a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt +++ b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt @@ -95,6 +95,7 @@ public fun randomString(size: Int = 20): String = buildString(capacity = size) { append(charPool.random()) } } +public fun randomStringOrNull(): String? = randomString().takeIf { randomBoolean() } public fun randomCID(): String = "${randomString()}:${randomString()}" public fun randomFile(extension: String = randomString(3)): File { @@ -473,13 +474,17 @@ public fun randomChannelUserRead( lastReceivedEventDate: Date = randomDate(), unreadMessages: Int = positiveRandomInt(), lastRead: Date = randomDate(), - lastReadMessageId: String? = randomString(), + lastReadMessageId: String? = randomStringOrNull(), + lastDeliveredAt: Date? = randomDateOrNull(), + lastDeliveredMessageId: String? = randomStringOrNull(), ): ChannelUserRead = ChannelUserRead( user = user, lastReceivedEventDate = lastReceivedEventDate, unreadMessages = unreadMessages, lastRead = lastRead, lastReadMessageId = lastReadMessageId, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, ) public suspend fun suspendableRandomMessageList( @@ -521,6 +526,7 @@ public fun randomConfig( name: String = randomString(), typingEventsEnabled: Boolean = randomBoolean(), readEventsEnabled: Boolean = randomBoolean(), + deliveryEventsEnabled: Boolean = randomBoolean(), connectEventsEnabled: Boolean = randomBoolean(), searchEnabled: Boolean = randomBoolean(), isReactionsEnabled: Boolean = randomBoolean(), @@ -543,6 +549,7 @@ public fun randomConfig( name = name, typingEventsEnabled = typingEventsEnabled, readEventsEnabled = readEventsEnabled, + deliveryEventsEnabled = deliveryEventsEnabled, connectEventsEnabled = connectEventsEnabled, searchEnabled = searchEnabled, isReactionsEnabled = isReactionsEnabled, diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt index ba6e9104dfb..94908b33bf0 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt @@ -87,7 +87,7 @@ import io.getstream.chat.android.offline.repository.domain.user.internal.UserEnt ThreadOrderEntity::class, DraftMessageEntity::class, ], - version = 95, + version = 96, exportSchema = false, ) @TypeConverters( diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadEntity.kt index 5777364e35e..7141fe0422c 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadEntity.kt @@ -29,4 +29,6 @@ internal data class ChannelUserReadEntity( val unreadMessages: Int, val lastRead: Date, val lastReadMessageId: String?, + val lastDeliveredAt: Date?, + val lastDeliveredMessageId: String?, ) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadMapper.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadMapper.kt index a47d2319c18..f4b06646daa 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadMapper.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadMapper.kt @@ -20,7 +20,23 @@ import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.User internal fun ChannelUserRead.toEntity(): ChannelUserReadEntity = - ChannelUserReadEntity(getUserId(), lastReceivedEventDate, unreadMessages, lastRead, lastReadMessageId) + ChannelUserReadEntity( + userId = getUserId(), + lastReceivedEventDate = lastReceivedEventDate, + unreadMessages = unreadMessages, + lastRead = lastRead, + lastReadMessageId = lastReadMessageId, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, + ) internal suspend fun ChannelUserReadEntity.toModel(getUser: suspend (userId: String) -> User): ChannelUserRead = - ChannelUserRead(getUser(userId), lastReceivedEventDate, unreadMessages, lastRead, lastReadMessageId) + ChannelUserRead( + user = getUser(userId), + lastReceivedEventDate = lastReceivedEventDate, + unreadMessages = unreadMessages, + lastRead = lastRead, + lastReadMessageId = lastReadMessageId, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, + ) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigEntity.kt index 8849d5bd985..83d9a2250b2 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigEntity.kt @@ -33,6 +33,7 @@ internal data class ChannelConfigInnerEntity( val name: String, val isTypingEvents: Boolean, val isReadEvents: Boolean, + val deliveryEventsEnabled: Boolean, val isConnectEvents: Boolean, val isSearch: Boolean, val isReactionsEnabled: Boolean, diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigMapper.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigMapper.kt index 52efc1b5fae..d4f9d2ddef1 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigMapper.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigMapper.kt @@ -29,6 +29,7 @@ internal fun ChannelConfig.toEntity(): ChannelConfigEntity = ChannelConfigEntity name = name, isTypingEvents = typingEventsEnabled, isReadEvents = readEventsEnabled, + deliveryEventsEnabled = deliveryEventsEnabled, isConnectEvents = connectEventsEnabled, isSearch = searchEnabled, isReactionsEnabled = isReactionsEnabled, @@ -59,6 +60,7 @@ internal fun ChannelConfigEntity.toModel(): ChannelConfig = ChannelConfig( name = name, typingEventsEnabled = isTypingEvents, readEventsEnabled = isReadEvents, + deliveryEventsEnabled = deliveryEventsEnabled, connectEventsEnabled = isConnectEvents, searchEnabled = isSearch, isReactionsEnabled = isReactionsEnabled, diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsEntity.kt index 50df597dace..ae6e4e343a3 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsEntity.kt @@ -22,6 +22,7 @@ import com.squareup.moshi.JsonClass internal data class PrivacySettingsEntity( val typingIndicators: TypingIndicatorsEntity? = null, val readReceipts: ReadReceiptsEntity? = null, + val deliveryReceipts: DeliveryReceiptsEntity? = null, ) @JsonClass(generateAdapter = true) @@ -33,3 +34,8 @@ internal data class TypingIndicatorsEntity( internal data class ReadReceiptsEntity( val enabled: Boolean, ) + +@JsonClass(generateAdapter = true) +internal data class DeliveryReceiptsEntity( + val enabled: Boolean, +) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt index 778ed4e63bc..7f2ec9853eb 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.offline.repository.domain.user.internal +import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings import io.getstream.chat.android.ReadReceipts import io.getstream.chat.android.TypingIndicators @@ -32,6 +33,11 @@ internal fun PrivacySettings.toEntity(): PrivacySettingsEntity { enabled = it.enabled, ) }, + deliveryReceipts = deliveryReceipts?.let { + DeliveryReceiptsEntity( + enabled = it.enabled, + ) + }, ) } @@ -47,5 +53,10 @@ internal fun PrivacySettingsEntity.toModel(): PrivacySettings { enabled = it.enabled, ) }, + deliveryReceipts = deliveryReceipts?.let { + DeliveryReceipts( + enabled = it.enabled, + ) + }, ) } diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ChannelConfigRepositoryTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ChannelConfigRepositoryTest.kt index a286eb0965b..a189cf9318e 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ChannelConfigRepositoryTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ChannelConfigRepositoryTest.kt @@ -136,6 +136,7 @@ internal class ChannelConfigRepositoryTest { isMutes = randomBoolean(), isReactionsEnabled = randomBoolean(), isReadEvents = randomBoolean(), + deliveryEventsEnabled = randomBoolean(), isSearch = randomBoolean(), isThreadEnabled = randomBoolean(), isTypingEvents = randomBoolean(), diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/MapConverterTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/MapConverterTest.kt index d1bb2a52ecf..c9f9d024497 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/MapConverterTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/MapConverterTest.kt @@ -39,7 +39,17 @@ internal class MapConverterTest { @Test fun testEncoding() { val converter = MapConverter() - val readMap = mutableMapOf(data.user1.id to ChannelUserReadEntity(data.user1.id, Date(), 0, Date(), null)) + val readMap = mutableMapOf( + data.user1.id to ChannelUserReadEntity( + userId = data.user1.id, + lastReceivedEventDate = Date(), + unreadMessages = 0, + lastRead = Date(), + lastReadMessageId = null, + lastDeliveredAt = null, + lastDeliveredMessageId = null, + ), + ) val output = converter.readMapToString(readMap) val converted = converter.stringToReadMap(output) converted shouldBeEqualTo readMap diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/PrivacySettingsConverterTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/PrivacySettingsConverterTest.kt index 3c67f6eb2a6..356b6db6d63 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/PrivacySettingsConverterTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/PrivacySettingsConverterTest.kt @@ -17,18 +17,20 @@ package io.getstream.chat.android.offline.repository.database.converter import io.getstream.chat.android.offline.repository.database.converter.internal.PrivacySettingsConverter +import io.getstream.chat.android.offline.repository.domain.user.internal.DeliveryReceiptsEntity import io.getstream.chat.android.offline.repository.domain.user.internal.PrivacySettingsEntity import io.getstream.chat.android.offline.repository.domain.user.internal.ReadReceiptsEntity import io.getstream.chat.android.offline.repository.domain.user.internal.TypingIndicatorsEntity -import org.amshove.kluent.shouldBeEqualTo import org.intellij.lang.annotations.Language import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull internal class PrivacySettingsConverterTest { @Test fun testNullEncoding() { val converter = PrivacySettingsConverter() - converter.privacySettingsToString(null) shouldBeEqualTo null + assertNull(converter.privacySettingsToString(null)) } @Test @@ -41,13 +43,16 @@ internal class PrivacySettingsConverterTest { readReceipts = ReadReceiptsEntity( enabled = false, ), + deliveryReceipts = DeliveryReceiptsEntity( + enabled = false, + ), ) @Language("JSON") - val encoded = """ - {"typingIndicators":{"enabled":false},"readReceipts":{"enabled":false}} + val expected = """ + {"typingIndicators":{"enabled":false},"readReceipts":{"enabled":false},"deliveryReceipts":{"enabled":false}} """.trimIndent() - converter.privacySettingsToString(settings) shouldBeEqualTo encoded + assertEquals(expected, converter.privacySettingsToString(settings)) } @Test @@ -56,16 +61,21 @@ internal class PrivacySettingsConverterTest { @Language("JSON") val encoded = """ - {"typingIndicators":{"enabled":false},"readReceipts":{"enabled":false}} + {"typingIndicators":{"enabled":false},"readReceipts":{"enabled":false},"deliveryReceipts":{"enabled":false}} """.trimIndent() - val decoded = converter.stringToPrivacySettings(encoded) - decoded shouldBeEqualTo PrivacySettingsEntity( + val actual = converter.stringToPrivacySettings(encoded) + + val expected = PrivacySettingsEntity( typingIndicators = TypingIndicatorsEntity( enabled = false, ), readReceipts = ReadReceiptsEntity( enabled = false, ), + deliveryReceipts = DeliveryReceiptsEntity( + enabled = false, + ), ) + assertEquals(expected, actual) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtils.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtils.kt index 803cd9d379d..5a3778ee265 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtils.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtils.kt @@ -19,9 +19,11 @@ package io.getstream.chat.android.state.event.handler.internal.utils import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.ConnectedEvent import io.getstream.chat.android.client.events.MarkAllReadEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent +import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.models.ChannelUserRead internal val ChatEvent.realType get() = when (this) { @@ -38,6 +40,18 @@ internal fun MessageReadEvent.toChannelUserRead() = ChannelUserRead( // TODO: Backend should send us the last read message id lastReadMessageId = null, ) + +internal fun MessageDeliveredEvent.toChannelUserRead() = ChannelUserRead( + user = user, + lastReceivedEventDate = createdAt, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, + // The following fields are not applicable for delivered events + lastRead = NEVER, + unreadMessages = 0, + lastReadMessageId = null, +) + internal fun NotificationMarkReadEvent.toChannelUserRead() = ChannelUserRead( user = user, lastReceivedEventDate = createdAt, diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt index 7fac4503c69..b74eb4dd63f 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt @@ -50,6 +50,7 @@ import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent import io.getstream.chat.android.client.events.MemberUpdatedEvent import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -638,6 +639,8 @@ internal class ChannelLogic( channelStateLogic.updateRead(event.toChannelUserRead()) } + is MessageDeliveredEvent -> channelStateLogic.updateDelivered(event.toChannelUserRead()) + is NotificationMarkReadEvent -> if (event.thread == null) { channelStateLogic.updateRead(event.toChannelUserRead()) } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt index 3661cd71500..65dc7b9150d 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt @@ -205,6 +205,15 @@ internal class ChannelStateLogic( */ fun updateRead(read: ChannelUserRead) = updateReads(listOf(read)) + /** + * Updates the delivered information of this channel. + * + * @param read the information about the delivered message. + */ + fun updateDelivered(read: ChannelUserRead) { + mutableState.upsertDelivered(read) + } + /** * Updates the list of typing users. * The method is responsible for adding/removing typing users, sorting the list and updating both diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt index b1da72f6ec2..b7e5b4d5ec6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt @@ -557,6 +557,23 @@ internal class ChannelMutableState( } } + /** + * Upsert the delivered status for a specific user's read. + */ + fun upsertDelivered(read: ChannelUserRead) { + val updatedRead = rawReads.value[read.user.id]?.copy( + // Update only relevant fields + user = read.user, + lastReceivedEventDate = read.lastReceivedEventDate, + lastDeliveredAt = read.lastDeliveredAt, + lastDeliveredMessageId = read.lastDeliveredMessageId, + ) ?: read + + _rawReads?.apply { + value = value + mapOf(read.user.id to updatedRead) + } + } + /** * Marks channel as read locally if different conditions are met: * 1. Channel has read events enabled @@ -574,7 +591,7 @@ internal class ChannelMutableState( else -> currentUserRead .takeIf { it.lastReadMessageId != lastMessage.id } - ?. let { + ?.let { upsertReads( listOf( it.copy( diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtilsTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtilsTest.kt new file mode 100644 index 00000000000..be043c07476 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtilsTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.event.handler.internal.utils + +import io.getstream.chat.android.client.extensions.internal.NEVER +import io.getstream.chat.android.client.test.randomMessageDeliveredEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ChatEventUtilsTest { + + @Test + fun `MessageDeliveredEvent toChannelUserRead should create correct ChannelUserRead`() { + val event = randomMessageDeliveredEvent() + + val actual = event.toChannelUserRead() + + assertEquals(event.user, actual.user) + assertEquals(event.createdAt, actual.lastReceivedEventDate) + assertEquals(event.lastDeliveredAt, actual.lastDeliveredAt) + assertEquals(event.lastDeliveredMessageId, actual.lastDeliveredMessageId) + assertEquals(NEVER, actual.lastRead) + assertEquals(0, actual.unreadMessages) + assertEquals(null, actual.lastReadMessageId) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt index fd54574b282..0271727763e 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt @@ -526,6 +526,15 @@ internal class ChannelStateLogicTest { verify(mutableState, times(2)).setMuted(false) } + @Test + fun `Given updateDelivered is called, Then mutable state is upserted`() { + val read = randomChannelUserRead() + + channelStateLogic.updateDelivered(read) + + verify(mutableState).upsertDelivered(read) + } + companion object { @JvmField diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableStateTests.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableStateTests.kt index f0859310c74..f4870a212c7 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableStateTests.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableStateTests.kt @@ -18,14 +18,18 @@ package io.getstream.chat.android.state.plugin.state.channel.internal import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomChannelUserRead import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser import io.getstream.chat.android.test.TestCoroutineExtension import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -559,6 +563,50 @@ internal class ChannelMutableStateTests { userMessages.map { it.id } shouldBeEqualTo listOf("msg1", "msg2", "msg3") } + @Test + fun `updateDelivered should update relevant delivered info of user's read`() = runTest { + val user = randomUser() + + // Initial read state for the user + val initialRead = randomChannelUserRead(user) + channelState.upsertReads(listOf(initialRead)) + + // Pre-condition check + var userRead = channelState.reads.value.find { it.user.id == user.id } + assertEquals(initialRead, userRead) + + // Update delivered info + val delivered = randomChannelUserRead(user.copy(name = randomString())) + channelState.upsertDelivered(delivered) + + // Post-condition check + userRead = channelState.reads.value.find { it.user.id == user.id } + assertNotNull(userRead) + assertEquals(delivered.user, userRead!!.user) + assertEquals(delivered.lastReceivedEventDate, userRead.lastReceivedEventDate) + assertEquals(delivered.lastDeliveredAt, userRead.lastDeliveredAt) + assertEquals(delivered.lastDeliveredMessageId, userRead.lastDeliveredMessageId) + assertEquals(initialRead.unreadMessages, userRead.unreadMessages) + assertEquals(initialRead.lastRead, userRead.lastRead) + assertEquals(initialRead.lastReadMessageId, userRead.lastReadMessageId) + } + + @Test + fun `updateDelivered should not create new read if user read does not exist`() = runTest { + val user = randomUser() + + // Ensure no initial read state for the user + channelState.upsertReads(emptyList()) + + // Update delivered info + val delivered = randomChannelUserRead(user) + channelState.upsertDelivered(delivered) + + // Post-condition check + val userRead = channelState.reads.value.find { it.user.id == user.id } + assertEquals(delivered, userRead) + } + private fun ChannelMutableState.assertPinnedMessagesSizeEqualsTo(size: Int) { require(pinnedMessages.value.size == size) { "pinnedMessages should have $size items, but was ${pinnedMessages.value.size}" 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 f5db60e8287..3b643fb4f7c 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 @@ -2433,13 +2433,14 @@ public final class io/getstream/chat/android/ui/common/state/messages/list/Messa public final class io/getstream/chat/android/ui/common/state/messages/list/MessageItemState : io/getstream/chat/android/ui/common/state/messages/list/HasMessageListItemState { public static final field $stable I - public fun (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;)V - public synthetic fun (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;)V + public synthetic fun (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Message; - public final fun component10 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState; - public final fun component11 ()Ljava/util/List; - public final fun component12 ()Z - public final fun component13 ()Ljava/util/Set; + public final fun component10 ()Lio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility; + public final fun component11 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState; + public final fun component12 ()Ljava/util/List; + public final fun component13 ()Z + public final fun component14 ()Ljava/util/Set; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Z public final fun component4 ()Z @@ -2447,9 +2448,9 @@ public final class io/getstream/chat/android/ui/common/state/messages/list/Messa public final fun component6 ()Lio/getstream/chat/android/models/User; public final fun component7 ()Ljava/util/List; public final fun component8 ()Z - public final fun component9 ()Lio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility; - public final fun copy (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; + public final fun component9 ()Z + public final fun copy (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; public fun equals (Ljava/lang/Object;)Z public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; public final fun getDeletedMessageVisibility ()Lio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility; @@ -2463,6 +2464,7 @@ public final class io/getstream/chat/android/ui/common/state/messages/list/Messa public final fun getShowOriginalText ()Z public fun hashCode ()I public final fun isInThread ()Z + public final fun isMessageDelivered ()Z public final fun isMessageRead ()Z public final fun isMine ()Z public fun toString ()Ljava/lang/String; diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt index 2aa6f08e79f..2f9001df1d5 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt @@ -23,6 +23,7 @@ import io.getstream.chat.android.client.audio.audioHash import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.errors.extractCause import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.extensions.deliveredReadsOf import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.getCreatedAtOrNull import io.getstream.chat.android.client.extensions.internal.wasCreatedAfter @@ -967,6 +968,8 @@ public class MessageListController( .filter { it.second >= index } .map { it.first } + val isMessageDelivered = channel?.deliveredReadsOf(message)?.isEmpty() == false + val isMessageFocused = message.id == focusedMessage?.id if (isMessageFocused) removeMessageFocus(message.id) @@ -979,6 +982,7 @@ public class MessageListController( isMine = user.id == currentUser?.id, isInThread = isInThread, isMessageRead = isMessageRead, + isMessageDelivered = isMessageDelivered, deletedMessageVisibility = deletedMessageVisibility, showMessageFooter = shouldShowFooter, messageReadBy = messageReadBy, diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListItemState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListItemState.kt index 5248362200c..f20c5b37ff6 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListItemState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListItemState.kt @@ -64,6 +64,7 @@ public sealed class HasMessageListItemState : MessageListItemState() { * @param currentUser The currently logged in user. * @param groupPosition The [MessagePosition] of the item inside a group. * @param isMessageRead Whether the message has been read or not. + * @param isMessageDelivered Whether the message has been delivered or not. * @param deletedMessageVisibility The [DeletedMessageVisibility] which determines the visibility of deleted messages in * the UI. * @param focusState The current [MessageFocusState] of the message, used to focus the message in the ui. @@ -81,6 +82,7 @@ public data class MessageItemState( public val currentUser: User? = null, public val groupPosition: List = listOf(MessagePosition.NONE), public val isMessageRead: Boolean = false, + public val isMessageDelivered: Boolean = false, public val deletedMessageVisibility: DeletedMessageVisibility = DeletedMessageVisibility.ALWAYS_HIDDEN, public val focusState: MessageFocusState? = null, public val messageReadBy: List = emptyList(), diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Message.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Message.kt index a6e315e88d2..2d569ff1b8b 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Message.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Message.kt @@ -17,10 +17,13 @@ package io.getstream.chat.android.ui.common.utils.extensions import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.utils.message.isDeleted +import io.getstream.chat.android.client.utils.message.isEphemeral import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageModerationAction import io.getstream.chat.android.models.ModerationAction +import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.User /** @@ -32,6 +35,10 @@ public fun Message.isMine(chatClient: ChatClient): Boolean = chatClient.clientSt @InternalStreamChatApi public fun Message.isMine(currentUser: User?): Boolean = currentUser?.id == user.id +@InternalStreamChatApi +public fun Message.shouldShowMessageStatusIndicator(): Boolean = + !isEphemeral() && !isDeleted() && syncStatus != SyncStatus.FAILED_PERMANENTLY + /** * @return if the message failed at moderation or not. */ diff --git a/stream-chat-android-ui-common/src/main/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml index 02dbbfb4b50..45783b065ce 100644 --- a/stream-chat-android-ui-common/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-common/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ Message read by %d members Message is pending Message is sent + Message is delivered %d attachments Image attachment Video attachment 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 58b31c21121..9b5118f08f7 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 @@ -343,40 +343,41 @@ public final class io/getstream/chat/android/ui/feature/channels/list/ChannelLis } public final class io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle : io/getstream/chat/android/ui/helper/ViewStyle { - public fun (Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIF)V + public fun (Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIF)V public final fun component1 ()Landroid/graphics/drawable/Drawable; public final fun component10 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component11 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component12 ()Landroid/graphics/drawable/Drawable; public final fun component13 ()Landroid/graphics/drawable/Drawable; public final fun component14 ()Landroid/graphics/drawable/Drawable; - public final fun component15 ()I - public final fun component16 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun component17 ()I - public final fun component18 ()Landroid/graphics/drawable/Drawable; + public final fun component15 ()Landroid/graphics/drawable/Drawable; + public final fun component16 ()I + public final fun component17 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component18 ()I public final fun component19 ()Landroid/graphics/drawable/Drawable; public final fun component2 ()Landroid/graphics/drawable/Drawable; - public final fun component20 ()I + public final fun component20 ()Landroid/graphics/drawable/Drawable; public final fun component21 ()I public final fun component22 ()I - public final fun component23 ()Ljava/lang/Integer; - public final fun component24 ()Z + public final fun component23 ()I + public final fun component24 ()Ljava/lang/Integer; public final fun component25 ()Z - public final fun component26 ()I + public final fun component26 ()Z public final fun component27 ()I public final fun component28 ()I public final fun component29 ()I public final fun component3 ()Z public final fun component30 ()I - public final fun component31 ()F + public final fun component31 ()I + public final fun component32 ()F public final fun component4 ()Z public final fun component5 ()Z public final fun component6 ()I public final fun component7 ()I public final fun component8 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component9 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun copy (Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIF)Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIFILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle; + public final fun copy (Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIF)Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIFILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle; public fun equals (Ljava/lang/Object;)Z public final fun getBackgroundColor ()I public final fun getBackgroundLayoutColor ()I @@ -387,6 +388,7 @@ public final class io/getstream/chat/android/ui/feature/channels/list/ChannelLis public final fun getEdgeEffectColor ()Ljava/lang/Integer; public final fun getEmptyStateView ()I public final fun getForegroundLayoutColor ()I + public final fun getIndicatorDeliveredIcon ()Landroid/graphics/drawable/Drawable; public final fun getIndicatorPendingSyncIcon ()Landroid/graphics/drawable/Drawable; public final fun getIndicatorReadIcon ()Landroid/graphics/drawable/Drawable; public final fun getIndicatorSentIcon ()Landroid/graphics/drawable/Drawable; @@ -2071,7 +2073,7 @@ public final class io/getstream/chat/android/ui/feature/messages/list/GiphyViewH } public final class io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle : io/getstream/chat/android/ui/helper/ViewStyle { - public fun (Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;)V + public fun (Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;)V public final fun component1 ()Ljava/lang/Integer; public final fun component10 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component11 ()Lio/getstream/chat/android/ui/font/TextStyle; @@ -2091,46 +2093,48 @@ public final class io/getstream/chat/android/ui/feature/messages/list/MessageLis public final fun component24 ()Landroid/graphics/drawable/Drawable; public final fun component25 ()Landroid/graphics/drawable/Drawable; public final fun component26 ()Landroid/graphics/drawable/Drawable; - public final fun component27 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun component28 ()I - public final fun component29 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component27 ()Landroid/graphics/drawable/Drawable; + public final fun component28 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component29 ()I public final fun component3 ()Ljava/lang/Integer; - public final fun component30 ()Ljava/lang/Integer; - public final fun component31 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun component32 ()Ljava/lang/Integer; - public final fun component33 ()I - public final fun component34 ()F - public final fun component35 ()I - public final fun component36 ()F - public final fun component37 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component30 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component31 ()Ljava/lang/Integer; + public final fun component32 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component33 ()Ljava/lang/Integer; + public final fun component34 ()I + public final fun component35 ()F + public final fun component36 ()I + public final fun component37 ()F public final fun component38 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component39 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component4 ()Ljava/lang/Integer; - public final fun component40 ()Landroid/graphics/drawable/Drawable; - public final fun component41 ()I + public final fun component40 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component41 ()Landroid/graphics/drawable/Drawable; public final fun component42 ()I public final fun component43 ()I - public final fun component44 ()F + public final fun component44 ()I public final fun component45 ()F - public final fun component46 ()Z - public final fun component47 ()Landroid/graphics/drawable/Drawable; + public final fun component46 ()F + public final fun component47 ()Z public final fun component48 ()Landroid/graphics/drawable/Drawable; - public final fun component49 ()I + public final fun component49 ()Landroid/graphics/drawable/Drawable; public final fun component5 ()I public final fun component50 ()I public final fun component51 ()I - public final fun component52 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component52 ()I + public final fun component53 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component6 ()I public final fun component7 ()I public final fun component8 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component9 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun copy (Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;)Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;IILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle; + public final fun copy (Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;)Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;IILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle; public fun equals (Ljava/lang/Object;)Z public final fun getDateSeparatorBackgroundColor ()I public final fun getEditReactionsViewStyle ()Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle; public final fun getIconBannedMessage ()Landroid/graphics/drawable/Drawable; public final fun getIconFailedMessage ()Landroid/graphics/drawable/Drawable; + public final fun getIconIndicatorDelivered ()Landroid/graphics/drawable/Drawable; public final fun getIconIndicatorPendingSync ()Landroid/graphics/drawable/Drawable; public final fun getIconIndicatorRead ()Landroid/graphics/drawable/Drawable; public final fun getIconIndicatorSent ()Landroid/graphics/drawable/Drawable; @@ -2878,8 +2882,8 @@ public final class io/getstream/chat/android/ui/feature/messages/list/adapter/Me } public final class io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem : io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem { - public fun (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZ)V - public synthetic fun (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZZ)V + public synthetic fun (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Message; public final fun component2 ()Ljava/util/List; public final fun component3 ()Z @@ -2888,8 +2892,9 @@ public final class io/getstream/chat/android/ui/feature/messages/list/adapter/Me public final fun component6 ()Z public final fun component7 ()Z public final fun component8 ()Z - public final fun copy (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZ)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem;Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem; + public final fun component9 ()Z + public final fun copy (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZZ)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem;Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZZILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem; public fun equals (Ljava/lang/Object;)Z public final fun getMessage ()Lio/getstream/chat/android/models/Message; public final fun getMessageReadBy ()Ljava/util/List; @@ -2897,6 +2902,7 @@ public final class io/getstream/chat/android/ui/feature/messages/list/adapter/Me public final fun getShowMessageFooter ()Z public final fun getShowOriginalText ()Z public fun hashCode ()I + public final fun isMessageDelivered ()Z public final fun isMessageRead ()Z public final fun isMine ()Z public final fun isTheirs ()Z diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle.kt index 5bf394b6366..716fba51519 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle.kt @@ -50,6 +50,7 @@ import io.getstream.chat.android.ui.utils.extensions.use * @property lastMessageText Appearance for last message text, displayed in [ChannelViewHolder]. * @property lastMessageDateText Appearance for last message date text displayed in [ChannelViewHolder]. * @property indicatorSentIcon Icon for indicating message sent status in [ChannelViewHolder]. Default value is [R.drawable.stream_ui_ic_check_single]. + * @property indicatorDeliveredIcon Icon for indicating message delivered status in [ChannelViewHolder]. Default value is [R.drawable.stream_ui_ic_check_double_grey]. * @property indicatorReadIcon Icon for indicating message read status in [ChannelViewHolder]. Default value is [R.drawable.stream_ui_ic_check_double]. * @property indicatorPendingSyncIcon Icon for indicating sync pending status in [ChannelViewHolder]. Default value is [R.drawable.stream_ui_ic_clock]. * @property foregroundLayoutColor Foreground color for [ChannelViewHolder]. Default value is [R.color.stream_ui_white_snow]. @@ -83,6 +84,7 @@ public data class ChannelListViewStyle( public val lastMessageText: TextStyle, public val lastMessageDateText: TextStyle, public val indicatorSentIcon: Drawable, + public val indicatorDeliveredIcon: Drawable, public val indicatorReadIcon: Drawable, public val indicatorPendingSyncIcon: Drawable, @ColorInt public val foregroundLayoutColor: Int, @@ -232,6 +234,9 @@ public data class ChannelListViewStyle( val indicatorSentIcon = a.getDrawable(R.styleable.ChannelListView_streamUiIndicatorSentIcon) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_single)!! + val indicatorDeliveredIcon = a.getDrawable(R.styleable.ChannelListView_streamUiIndicatorDeliveredIcon) + ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_double_grey)!! + val indicatorReadIcon = a.getDrawable(R.styleable.ChannelListView_streamUiIndicatorReadIcon) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_double)!! @@ -339,6 +344,7 @@ public data class ChannelListViewStyle( lastMessageText = lastMessageText, lastMessageDateText = lastMessageDateText, indicatorSentIcon = indicatorSentIcon, + indicatorDeliveredIcon = indicatorDeliveredIcon, indicatorReadIcon = indicatorReadIcon, indicatorPendingSyncIcon = indicatorPendingSyncIcon, foregroundLayoutColor = foregroundLayoutColor, diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/adapter/viewholder/internal/ChannelViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/adapter/viewholder/internal/ChannelViewHolder.kt index 89b823ce93a..cf59e8c8035 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/adapter/viewholder/internal/ChannelViewHolder.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/adapter/viewholder/internal/ChannelViewHolder.kt @@ -25,6 +25,7 @@ import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import io.getstream.chat.android.client.extensions.currentUserUnreadCount +import io.getstream.chat.android.client.extensions.deliveredReadsOf import io.getstream.chat.android.client.extensions.isAnonymousChannel import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.DraftMessage @@ -356,8 +357,15 @@ internal class ChannelViewHolder @JvmOverloads constructor( style.indicatorPendingSyncIcon } else { val lastMessageWasRead = readCount > 0 + val lastMessageWasDelivered = channel.deliveredReadsOf(lastMessage).isNotEmpty() - if (lastMessageWasRead) style.indicatorReadIcon else style.indicatorSentIcon + if (lastMessageWasRead) { + style.indicatorReadIcon + } else if (lastMessageWasDelivered) { + style.indicatorDeliveredIcon + } else { + style.indicatorSentIcon + } } if (readCount > 1 && style.readCountEnabled) { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle.kt index 934d7a92eaf..c4738b46e8a 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle.kt @@ -70,6 +70,7 @@ import io.getstream.chat.android.ui.utils.extensions.getDrawableCompat * @property reactionsViewStyle Style for [ViewReactionsView]. * @property editReactionsViewStyle Style for [EditReactionsView]. * @property iconIndicatorSent Icon for message's sent status. Default value is [R.drawable.stream_ui_ic_check_single]. + * @property iconIndicatorDelivered Icon for message's delivered status. Default value is [R.drawable.stream_ui_ic_check_double_grey]. * @property iconIndicatorRead Icon for message's read status. Default value is [R.drawable.stream_ui_ic_check_double]. * @property iconIndicatorPendingSync Icon for message's pending status. Default value is [R.drawable.stream_ui_ic_clock]. * @property iconOnlyVisibleToYou Icon for message's pending status. Default value is [R.drawable.stream_ui_ic_icon_eye_off]. @@ -119,6 +120,7 @@ public data class MessageListItemStyle( public val reactionsViewStyle: ViewReactionsViewStyle, public val editReactionsViewStyle: EditReactionsViewStyle, public val iconIndicatorSent: Drawable, + public val iconIndicatorDelivered: Drawable, public val iconIndicatorRead: Drawable, public val iconIndicatorPendingSync: Drawable, public val iconOnlyVisibleToYou: Drawable, @@ -519,6 +521,9 @@ public data class MessageListItemStyle( val iconIndicatorSent = attributes.getDrawable( R.styleable.MessageListView_streamUiIconIndicatorSent, ) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_single)!! + val iconIndicatorDelivered = attributes.getDrawable( + R.styleable.MessageListView_streamUiIconIndicatorDelivered, + ) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_double_grey)!! val iconIndicatorRead = attributes.getDrawable( R.styleable.MessageListView_streamUiIconIndicatorRead, ) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_double)!! @@ -769,6 +774,7 @@ public data class MessageListItemStyle( reactionsViewStyle = reactionsViewStyle, editReactionsViewStyle = editReactionsViewStyle, iconIndicatorSent = iconIndicatorSent, + iconIndicatorDelivered = iconIndicatorDelivered, iconIndicatorRead = iconIndicatorRead, iconIndicatorPendingSync = iconIndicatorPendingSync, iconOnlyVisibleToYou = iconOnlyVisibleToYou, diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem.kt index 1441ef5cd04..95ad09c6e7b 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem.kt @@ -75,6 +75,7 @@ public sealed class MessageListItem { * @property messageReadBy The list of users that already read the message. * @property isThreadMode True if the message is in a thread mode, otherwise false. * @property isMessageRead True if the message has been read or not. + * @property isMessageDelivered Whether the message has been delivered or not. * @property showMessageFooter True if the message footer should be displayed, otherwise false. * @property isTheirs True if the message is sent by another user, otherwise false. * @property showMessageFooter True if the message footer should be displayed, otherwise false. @@ -88,6 +89,7 @@ public sealed class MessageListItem { val messageReadBy: List = listOf(), val isThreadMode: Boolean = false, val isMessageRead: Boolean = true, + val isMessageDelivered: Boolean = false, val showMessageFooter: Boolean = false, val showOriginalText: Boolean = false, ) : MessageListItem() { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt index 0718f3afc1e..ff44dbe8b92 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt @@ -31,6 +31,7 @@ import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.common.helper.DateFormatter import io.getstream.chat.android.ui.common.state.messages.list.DeletedMessageVisibility +import io.getstream.chat.android.ui.common.utils.extensions.shouldShowMessageStatusIndicator import io.getstream.chat.android.ui.feature.messages.list.MessageListItemStyle import io.getstream.chat.android.ui.feature.messages.list.MessageListViewStyle import io.getstream.chat.android.ui.feature.messages.list.adapter.MessageListItem @@ -389,10 +390,13 @@ internal class FootnoteDecorator( SyncStatus.SYNC_NEEDED, SyncStatus.AWAITING_ATTACHMENTS, -> itemStyle.iconIndicatorPendingSync - SyncStatus.COMPLETED -> when (data.isMessageRead) { - true -> itemStyle.iconIndicatorRead + + SyncStatus.COMPLETED -> when { + data.isMessageRead -> itemStyle.iconIndicatorRead + data.isMessageDelivered -> itemStyle.iconIndicatorDelivered else -> itemStyle.iconIndicatorSent } + else -> null } if (statusIndicator != null) { @@ -421,13 +425,8 @@ internal class FootnoteDecorator( } private fun shouldHideReadRelatedInfo(data: MessageListItem.MessageItem): Boolean { - val status = data.message.syncStatus val isNotBottomPosition = data.isNotBottomPosition() val isTheirs = data.isTheirs - val isEphemeral = data.message.isEphemeral() - val isDeleted = data.message.isDeleted() - val isFailedPermanently = status == SyncStatus.FAILED_PERMANENTLY - - return isNotBottomPosition || isTheirs || isEphemeral || isDeleted || isFailedPermanently + return isNotBottomPosition || isTheirs || !data.message.shouldShowMessageStatusIndicator() } } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/extensions/MessageListItem.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/extensions/MessageListItem.kt index 74e5baa883a..d8adf5764a9 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/extensions/MessageListItem.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/extensions/MessageListItem.kt @@ -48,6 +48,7 @@ public fun MessageListItemCommon.toUiMessageListItem(): MessageListItem { messageReadBy = messageReadBy, isThreadMode = isInThread, isMessageRead = isMessageRead, + isMessageDelivered = isMessageDelivered, showMessageFooter = showMessageFooter, showOriginalText = showOriginalText, ) diff --git a/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_check_double_grey.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_check_double_grey.xml new file mode 100644 index 00000000000..5ab6ebd9f76 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_check_double_grey.xml @@ -0,0 +1,44 @@ + + + + + + + + diff --git a/stream-chat-android-ui-components/src/main/res/values/attrs_channel_list_view.xml b/stream-chat-android-ui-components/src/main/res/values/attrs_channel_list_view.xml index 33c3ce3f70e..39dab2ed1d2 100644 --- a/stream-chat-android-ui-components/src/main/res/values/attrs_channel_list_view.xml +++ b/stream-chat-android-ui-components/src/main/res/values/attrs_channel_list_view.xml @@ -87,7 +87,10 @@ + + + diff --git a/stream-chat-android-ui-components/src/main/res/values/attrs_message_list_view.xml b/stream-chat-android-ui-components/src/main/res/values/attrs_message_list_view.xml index c5a3ae4997e..57dc33a4383 100644 --- a/stream-chat-android-ui-components/src/main/res/values/attrs_message_list_view.xml +++ b/stream-chat-android-ui-components/src/main/res/values/attrs_message_list_view.xml @@ -313,6 +313,7 @@ + diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/Mother.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/Mother.kt index 0e7ba6a4e53..663af903939 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/Mother.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/Mother.kt @@ -250,6 +250,7 @@ public fun randomMessageListItemStyle( reactionsViewStyle: ViewReactionsViewStyle = randomViewReactionsViewStyle(), editReactionsViewStyle: EditReactionsViewStyle = randomEditReactionsViewStyle(), iconIndicatorSent: Drawable = mock(), + iconIndicatorDelivered: Drawable = mock(), iconIndicatorRead: Drawable = mock(), iconIndicatorPendingSync: Drawable = mock(), iconOnlyVisibleToYou: Drawable = mock(), @@ -301,6 +302,7 @@ public fun randomMessageListItemStyle( reactionsViewStyle = reactionsViewStyle, editReactionsViewStyle = editReactionsViewStyle, iconIndicatorSent = iconIndicatorSent, + iconIndicatorDelivered = iconIndicatorDelivered, iconIndicatorRead = iconIndicatorRead, iconIndicatorPendingSync = iconIndicatorPendingSync, iconOnlyVisibleToYou = iconOnlyVisibleToYou,