Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
f223b1c
Add ChatClient.markMessagesAsDelivered function to mark messages as d…
andremion Oct 22, 2025
6242795
Add delivery receipts support to user privacy settings
andremion Oct 23, 2025
e9b54ad
Introduce a initial DeliveryReceiptsManager to handle message deliver…
andremion Oct 23, 2025
02e4e2b
Introduce MessageReceiptDao and MessageReceiptEntity for handling mes…
andremion Oct 23, 2025
dc90cd8
Introduce MessageReceipt model and repository for handling message de…
andremion Oct 23, 2025
296ae95
Make MessageReceiptRepository `getAllByType` return a Flow and add a …
andremion Oct 24, 2025
3b5384d
Refactor DeliveryReceiptsManager to store receipts locally
andremion Oct 24, 2025
d503cf1
Introduced `MessageReceiptReporter`, a new class responsible for obse…
andremion Oct 24, 2025
3be042a
Prapare to move internal persistence to the client module
andremion Oct 27, 2025
1e1c964
Add ChatClientDatabase, DateConverter, and ChatClientRepository for m…
andremion Oct 27, 2025
5bae534
Refactor: Move message receipt logic to client
andremion Oct 27, 2025
6e0e715
Support `message.delivered` event
andremion Oct 28, 2025
245c894
Refactor: Add default empty implementations for `QueryChannelsListene…
andremion Oct 28, 2025
e561d09
Deprecate `hasUnread` in favor of `currentUserUnreadCount`
andremion Oct 28, 2025
3746f56
Add `userRead` and `deliveredReads` helper functions
andremion Oct 28, 2025
52c0bba
Introduce `markChannelsAsDelivered`, a new function to mark the last …
andremion Oct 28, 2025
0b2d1cc
Refactor MessageReceiptReporter to use a polling mechanism
andremion Oct 28, 2025
d508454
Moves the user ID update in the `switchUser` function to after the us…
andremion Oct 29, 2025
ebb75b8
Rename MessageReceiptReporter.init to start and add logging
andremion Oct 29, 2025
83cd287
Refactor MessageReceiptManagerTest to standardize verification method…
andremion Oct 29, 2025
469a2b6
Automatically mark messages as delivered when querying channels
andremion Oct 29, 2025
79a7024
Introduce `ChatClientRepository` to encapsulate internal repositories…
andremion Oct 29, 2025
b3d205a
Add MessageReceiptManager and MessageReceiptReporter to ChatClient
andremion Oct 29, 2025
45633af
Decoupled `MessageReceiptReporter` from `ChatClient` by passing `Chat…
andremion Oct 29, 2025
73f2034
Refactor ChatClientTest to simplify test setup
andremion Oct 29, 2025
b4cb0fa
Rename deliveredReads to deliveredReadsOf
andremion Oct 29, 2025
f57e0ab
feat: Add delivered status indicator for messages
andremion Oct 29, 2025
f2a0c8e
Fix flaky test
andremion Oct 29, 2025
3b59aba
Add more tests
andremion Oct 29, 2025
991cff0
Feat: Add `Channel.readsOf()` extension function
andremion Oct 30, 2025
e68f57f
Add pending status indicator snapshot test
andremion Oct 30, 2025
d072a12
Add unit test for MessageReceiptRepository instantiation
andremion Oct 30, 2025
ca74d26
Fix the message status indicator paddings
andremion Oct 30, 2025
8ee562f
Mark messages as delivered on push notification
andremion Oct 30, 2025
9a55a6d
sonar lint
andremion Oct 30, 2025
321b21b
Mark channel as delivered on query a single channel
andremion Oct 30, 2025
4c7d597
Provide a ChatClient lazily in ChatNotifications
andremion Oct 30, 2025
ed6eb51
Hide the message status indicator when a message is deleted
andremion Oct 30, 2025
92c8c55
Do not expose ChatClient.markMessagesAsDelivered
andremion Oct 30, 2025
59fe832
feat: Add delivery_events flag to Config
andremion Oct 31, 2025
3905140
Refactor: Improve message delivery receipts logic
andremion Oct 31, 2025
9a3add6
Add DELIVERY_EVENTS to ChannelCapabilities
andremion Oct 31, 2025
e9dc48a
Refactor: Make MessageReceiptManager functions suspend
andremion Oct 31, 2025
e809ce3
Refactor: Lazy initialize messageReceiptManager in MessageDeliveredPl…
andremion Oct 31, 2025
9c2874b
Fix: Fetch message from API when marking as delivered by ID
andremion Oct 31, 2025
b984fe6
Skip sending delivery receipts for shadowed messages and muted users
andremion Oct 31, 2025
a938667
Add deliveryEventsEnabled to channel configuration and mapping
andremion Oct 31, 2025
ead2502
Add message info option to message menu
andremion Nov 3, 2025
e1b8dfc
Extra PaneTitle and PaneRow components for reusing
andremion Nov 3, 2025
1368d52
Add message info component to display read and delivered status
andremion Nov 3, 2025
2589129
Stop using kluent assertions
andremion Nov 3, 2025
af695b0
Update read and delivered status checks to include equal comparison
andremion Nov 3, 2025
9c0f88a
Add user profile privacy settings screen
andremion Nov 3, 2025
c0207c4
Add privacy settings mapping to domain model
andremion Nov 3, 2025
058f11d
Fix message info component to display read and delivered timestamps c…
andremion Nov 4, 2025
6fcc75d
typo
andremion Nov 4, 2025
b07222f
CHANGELOG
andremion Nov 4, 2025
c2c968e
Change userRead function visibility to internal
andremion Nov 6, 2025
e390768
Use getCreatedAtOrDefault
andremion Nov 6, 2025
4d1bef6
Remove MessageReceipt's 'type' field
andremion Nov 7, 2025
13b7ede
Move MessageReceiptRepositoryImpl to the same file as MessageReceiptR…
andremion Nov 7, 2025
0b2aae6
Fix tests after rebased from develop
andremion Nov 7, 2025
3056ddb
Update User.mergePartially to conditionally merge based on update tim…
andremion Nov 11, 2025
64e9e05
Store UserMuteEntity properly
andremion Nov 11, 2025
edbdfc7
Refactor event handling to consolidate user updates
andremion Nov 11, 2025
d3d8110
Refactor `MessageReceiptManager` to fetch the current user from the r…
andremion Nov 11, 2025
fec9268
Refactor: Lazily inject RepositoryFacade into MessageReceiptManager
andremion Nov 11, 2025
39a4624
Expose `markMessageAsDelivered` and allow custom handling of incoming…
andremion Nov 11, 2025
07e39e4
Enhance UserRepositoryTests: Add tests for user insertion and selecti…
andremion Nov 11, 2025
128a0fc
improve test coverage
andremion Nov 12, 2025
716b933
Rename `onNewPushMessage` to `onNewMessage` to keep consistency betwe…
andremion Nov 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### ⬆️ Improved

### ✅ Added
- Introduce message delivery receipts. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979)

### ⚠️ Changed

Expand All @@ -16,8 +17,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 `ChatClient.markMessageAsDelivered` to mark a message as delivered for the current user. [#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

Expand Down Expand Up @@ -3612,7 +3617,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
66 changes: 56 additions & 10 deletions stream-chat-android-client/api/stream-chat-android-client.api

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions stream-chat-android-client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,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.
Expand All @@ -105,8 +109,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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -275,6 +280,9 @@ internal constructor(
@InternalStreamChatApi
public val audioPlayer: AudioPlayer,
private val now: () -> Date = ::Date,
private val repository: ChatClientRepository,
private val messageReceiptReporter: MessageReceiptReporter,
internal val messageReceiptManager: MessageReceiptManager,
) {
private val logger by taggedLogger(TAG)
private val waitConnection = MutableSharedFlow<Result<ConnectionData>>()
Expand Down Expand Up @@ -447,13 +455,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)
Expand Down Expand Up @@ -642,6 +650,7 @@ internal constructor(
tokenManager.setTokenProvider(tokenProvider)
appSettingsManager.loadAppSettings()
warmUp()
messageReceiptReporter.start()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also have a corresponding stop() method? Or do we rely on cancelling the scope to cancel the running job?

logger.i { "[initializeClientWithUser] user.id: '${user.id}'completed" }
}

Expand Down Expand Up @@ -737,9 +746,11 @@ internal constructor(
): Call<ConnectionData> {
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}')" }
Expand Down Expand Up @@ -1506,6 +1517,8 @@ internal constructor(
userCredentialStorage.clear()
}

repository.clear()

_repositoryFacade = null
attachmentsSender.cancelJobs()
appSettingsManager.clear()
Expand Down Expand Up @@ -2870,6 +2883,33 @@ internal constructor(
}
}

/**
* Request to mark the message with the given id as delivered if:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to document that this method might attempt to internally call getMessage/getChannel, just for transparency sake. Or at least it would be good to document this in the Docs page as well. I think that it is an important information, that integrators should know before using this method.

*
* - Delivery receipts are enabled for the current user.
* - Delivery events are enabled in the channel config.
*
* and if all of the following conditions are met for the message:
*
* - Not sent by the current user.
* - Not shadow banned.
* - Not sent by a muted user.
* - Not yet marked as read by the current user.
* - Not yet marked as delivered by the current user.
*
* @param messageId The ID of the message to mark as delivered.
*/
@CheckResult
public fun markMessageAsDelivered(messageId: String): Call<Unit> =
CoroutineCall(userScope) {
messageReceiptManager.markMessageAsDelivered(messageId)
Result.Success(Unit)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we maybe return Result.Error from this method, if the message cannot be marked as delivered (ex. if some of the preconditions are not satisfied)?

}.doOnStart(userScope) {
logger.d { "[markMessageAsDelivered] #doOnStart; messageId: $messageId" }
}.doOnResult(userScope) {
logger.v { "[markMessageAsDelivered] #doOnResult; completed($messageId)" }
}

/**
* Marks the given message as read.
*
Expand Down Expand Up @@ -4717,7 +4757,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 {
Expand All @@ -4732,12 +4773,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,
Expand All @@ -4755,6 +4799,18 @@ internal constructor(
mutableClientState = MutableClientState(module.networkStateProvider),
currentUserFetcher = module.currentUserFetcher,
audioPlayer = audioPlayer,
repository = repository,
messageReceiptReporter = MessageReceiptReporter(
scope = userScope,
messageReceiptRepository = repository,
api = api,
),
messageReceiptManager = MessageReceiptManager(
now = ::Date,
getRepositoryFacade = { instance().repositoryFacade },
messageReceiptRepository = repository,
api = api,
),
).apply {
attachmentsSender = AttachmentsSender(
context = appContext,
Expand Down Expand Up @@ -4799,7 +4855,10 @@ internal constructor(
* @see [Plugin]
* @see [PluginFactory]
*/
protected val pluginFactories: MutableList<PluginFactory> = mutableListOf(ThrottlingPluginFactory)
protected val pluginFactories: MutableList<PluginFactory> = mutableListOf(
ThrottlingPluginFactory,
MessageDeliveredPluginFactory,
)

/**
* Create a [ChatClient] instance based on the current configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ internal interface ChatApi {
messageId: String = "",
): Call<Unit>

@CheckResult
fun markDelivered(
messages: List<Message>,
): Call<Unit>

@CheckResult
fun markThreadRead(
channelType: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -960,6 +961,11 @@ constructor(
).toUnitCall()
}

override fun markDelivered(messages: List<Message>): Call<Unit> =
channelApi.markDelivered(
request = MarkDeliveredRequest.create(messages),
).toUnitCall()

override fun markThreadRead(channelType: String, channelId: String, threadId: String): Call<Unit> {
return channelApi.markRead(
channelType = channelType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,4 +212,9 @@ internal interface ChannelApi {
@Path("id") channelId: String,
@UrlQueryPayload @Query("payload") payload: PinnedMessagesRequest,
): RetrofitCall<MessagesResponse>

@POST("/channels/delivered")
fun markDelivered(
@Body request: MarkDeliveredRequest,
): RetrofitCall<CompletableResponse>
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@

package io.getstream.chat.android.client.api2.mapping

import io.getstream.chat.android.DeliveryReceipts
import io.getstream.chat.android.PrivacySettings
import io.getstream.chat.android.ReadReceipts
import io.getstream.chat.android.TypingIndicators
import io.getstream.chat.android.client.api2.model.dto.AttachmentDto
import io.getstream.chat.android.client.api2.model.dto.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
Expand Down Expand Up @@ -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() },
Expand Down Expand Up @@ -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,
)

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
)

Expand All @@ -685,6 +692,11 @@ internal class DomainMapping(
enabled = enabled,
)

/**
* Transforms [DeliveryReceiptsDto] to [DeliveryReceipts].
*/
internal fun DeliveryReceiptsDto.toDomain() = DeliveryReceipts(enabled = enabled)

/**
* Transforms [ReadReceiptsDto] to [ReadReceipts].
*/
Expand Down
Loading
Loading