Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import io.getstream.chat.android.client.events.DraftMessageUpdatedEvent
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.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
Expand Down Expand Up @@ -442,6 +443,26 @@ public fun randomMemberAddedEvent(
)
}

public fun randomMemberUpdatedEvent(
createdAt: Date = Date(),
user: User = randomUser(),
cid: String = randomCID(),
channelType: String = randomString(),
channelId: String = randomString(),
member: Member = randomMember(),
): MemberUpdatedEvent {
return MemberUpdatedEvent(
type = EventType.MEMBER_UPDATED,
createdAt = createdAt,
rawCreatedAt = streamFormatter.format(createdAt),
user = user,
cid = cid,
channelType = channelType,
channelId = channelId,
member = member,
)
}

public fun randomNotificationAddedToChannelEvent(
createdAt: Date = Date(),
cid: String = randomCID(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import io.getstream.chat.android.client.api2.model.dto.DownstreamDraftDto
import io.getstream.chat.android.client.api2.model.dto.DownstreamFlagDto
import io.getstream.chat.android.client.api2.model.dto.DownstreamLocationDto
import io.getstream.chat.android.client.api2.model.dto.DownstreamMemberDto
import io.getstream.chat.android.client.api2.model.dto.DownstreamMemberInfoDto
import io.getstream.chat.android.client.api2.model.dto.DownstreamMessageDto
import io.getstream.chat.android.client.api2.model.dto.DownstreamModerationDetailsDto
import io.getstream.chat.android.client.api2.model.dto.DownstreamModerationDto
Expand Down Expand Up @@ -85,6 +86,7 @@ import io.getstream.chat.android.models.FileUploadConfig
import io.getstream.chat.android.models.Flag
import io.getstream.chat.android.models.Location
import io.getstream.chat.android.models.Member
import io.getstream.chat.android.models.MemberInfo
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.MessageModerationAction
import io.getstream.chat.android.models.MessageModerationDetails
Expand Down Expand Up @@ -229,6 +231,7 @@ internal class DomainMapping(
/**
* Transforms [DownstreamMessageDto] to [Message].
*/
@Suppress("DEPRECATION")
internal fun DownstreamMessageDto.toDomain(fallbackChannelInfo: ChannelInfo? = null): Message =
(channel?.toDomain() ?: fallbackChannelInfo).let { channelInfo: ChannelInfo? ->
Message(
Expand Down Expand Up @@ -280,6 +283,7 @@ internal class DomainMapping(
reminder = reminder?.toDomain(),
sharedLocation = shared_location?.toDomain(),
channelRole = member?.channel_role,
member = member?.toDomain(),
deletedForMe = deleted_for_me ?: false,
extraData = extraData.toMutableMap(),
).let(messageTransformer::transform)
Expand Down Expand Up @@ -467,6 +471,22 @@ internal class DomainMapping(
extraData = custom.mapNotNull { (key, value) -> value?.let { key to it } }.toMap(),
).let(userTransformer::transform)

/**
* Transforms [DownstreamMemberInfoDto] to [MemberInfo].
*/
internal fun DownstreamMemberInfoDto.toDomain(): MemberInfo =
MemberInfo(
channelRole = channel_role,
notificationsMuted = notifications_muted ?: false,
extraData = memberCustom(),
)

/**
* The member custom data, regardless of whether API v1 inlined it next to the declared fields or API v2 nested it
* under `custom`. The two shapes never coexist, so the merge only ever picks up one of them.
*/
private fun DownstreamMemberInfoDto.memberCustom(): Map<String, Any> = extraData + custom.orEmpty()

internal fun DownstreamLocationDto.toDomain(): Location =
Location(
cid = channel_cid,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,24 @@
) : ExtraDataDto

/**
* DTO holding limited data about a channel member.
* DTO holding limited data about a channel member, as attached to a message payload.
*
* See [io.getstream.chat.android.client.parser2.adapters.DownstreamMemberInfoDtoAdapter] for
* special [extraData] handling.
*
* @property channel_role The role of the member in the channel.
* @property notifications_muted If notifications are muted for the member in the channel.
* @property custom The member custom data, in the shape API v2 returns it: nested.
* @property extraData The member custom data, in the shape API v1 returns it: inlined next to [channel_role].
*/
@StreamHandsOff(
reason = "Field names can't be changed because [CustomObjectDtoAdapter] class uses reflections to add/remove " +
"content of [extraData] map",
)
@JsonClass(generateAdapter = true)
internal data class DownstreamMemberInfoDto(
val channel_role: String?,
)
val notifications_muted: Boolean? = null,

Check warning on line 95 in stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/MemberDtos.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this parameter to match the regular expression ^`?[_a-z][a-zA-Z0-9]*`?$

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-android&issues=AZ__VM93dHwfBd97dJiO&open=AZ__VM93dHwfBd97dJiO&pullRequest=6637
val custom: Map<String, Any>? = null,
val extraData: Map<String, Any> = emptyMap(),
) : ExtraDataDto
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package io.getstream.chat.android.client.extensions.internal

import io.getstream.chat.android.core.internal.InternalStreamChatApi
import io.getstream.chat.android.models.Member
import io.getstream.chat.android.models.MemberInfo
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.User

/** Updates collection of members with more recent data of [users]. */
Expand All @@ -29,3 +31,32 @@ public fun Collection<Member>.updateUsers(userMap: Map<String, User>): Collectio
member
}
}

/**
* Narrows a full [Member] down to the slim [MemberInfo] carried by [Message.member].
*
* [Member.extraData] holds every key the member DTO does not declare, which includes stored member fields such as
* `role` and `deleted_messages`. The projection the backend puts on `message.member` carries none of them, so they
* are dropped here to keep [MemberInfo.extraData] identical no matter whether it came from a message payload or from
* a member event.
*/
@InternalStreamChatApi
public fun Member.toMemberInfo(): MemberInfo = MemberInfo(
channelRole = channelRole,
notificationsMuted = notificationsMuted ?: false,
extraData = extraData - NON_CUSTOM_MEMBER_KEYS,
)

/**
* Keys that reach [Member.extraData] only because the member DTO does not declare them: every `ChannelMemberResponse`
* field the DTO leaves out, apart from `custom`, which is where member custom data itself arrives.
*/
private val NON_CUSTOM_MEMBER_KEYS = setOf(
"user_id",
"role",
"is_moderator",
"deleted_messages",
"deleted_at",
"ban_from_future_channels",
"future_channel_ban_expires",
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,48 @@ import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault
import io.getstream.chat.android.core.internal.InternalStreamChatApi
import io.getstream.chat.android.models.Attachment
import io.getstream.chat.android.models.Channel
import io.getstream.chat.android.models.MemberInfo
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.Reaction
import io.getstream.chat.android.models.User
import java.util.Date

/**
* Replaces the [Message.member] snapshot of this message, keeping the deprecated [Message.channelRole] in sync with it.
*
* The snapshot is taken verbatim: the same value reaches the in-memory state, the repository cache and the database, so
* the three cannot disagree. A blanket column update cannot preserve a previously known role, so neither does this.
*/
@InternalStreamChatApi
@Suppress("DEPRECATION")
public fun Message.withMemberInfo(memberInfo: MemberInfo?): Message =
copy(member = memberInfo, channelRole = memberInfo?.channelRole)

/**
* Whether this message, or the quoted message it carries, holds an out of date [Message.member] snapshot for [userId].
*/
@InternalStreamChatApi
public fun Message.hasOutdatedMemberInfo(userId: String, memberInfo: MemberInfo?): Boolean =
hasOutdatedMemberInfoFor(userId, memberInfo) || replyTo?.hasOutdatedMemberInfoFor(userId, memberInfo) == true

/**
* Applies [memberInfo] to this message and to the quoted message it carries, whichever of the two [userId] authored.
*
* The quoted copy is a snapshot of its own, so leaving it behind would show two different snapshots for one author.
*/
@InternalStreamChatApi
public fun Message.withRefreshedMemberInfo(userId: String, memberInfo: MemberInfo?): Message {
val refreshed = if (user.id == userId) withMemberInfo(memberInfo) else this
val quoted = refreshed.replyTo
return when {
quoted == null || quoted.user.id != userId -> refreshed
else -> refreshed.copy(replyTo = quoted.withMemberInfo(memberInfo))
}
}

private fun Message.hasOutdatedMemberInfoFor(userId: String, memberInfo: MemberInfo?): Boolean =
user.id == userId && member != memberInfo

/** Updates collection of messages with more recent data of [users]. */
@InternalStreamChatApi
public fun Collection<Message>.updateUsers(users: Map<String, User>): List<Message> = map { it.updateUsers(users) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.chat.android.client.internal.offline.repository.database.converter.internal

import androidx.room.TypeConverter
import com.squareup.moshi.adapter
import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.MemberInfoEntity

/**
* Converter class defining how the [MemberInfoEntity] is stored in the database.
*/
internal class MemberInfoConverter {

@OptIn(ExperimentalStdlibApi::class)
private val adapter = moshi.adapter<MemberInfoEntity>()

/**
* Converts a [String] to a [MemberInfoEntity].
*/
@TypeConverter
fun stringToMemberInfo(data: String?): MemberInfoEntity? {
if (data.isNullOrEmpty() || data == "null") {
return null
}
return adapter.fromJson(data)
}

/**
* Converts a [MemberInfoEntity] to a [String].
*/
@TypeConverter
fun memberInfoToString(memberInfo: MemberInfoEntity?): String? {
return memberInfo?.let(adapter::toJson)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import io.getstream.chat.android.client.internal.offline.repository.database.con
import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.LocationConverter
import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.MapConverter
import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.MemberConverter
import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.MemberInfoConverter
import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.ModerationConverter
import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.ModerationDetailsConverter
import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.OptionConverter
Expand Down Expand Up @@ -89,7 +90,7 @@ import io.getstream.chat.android.client.internal.offline.repository.domain.user.
ThreadOrderEntity::class,
DraftMessageEntity::class,
],
version = 203,
version = 204,
exportSchema = false,
)
@TypeConverters(
Expand All @@ -103,6 +104,7 @@ import io.getstream.chat.android.client.internal.offline.repository.domain.user.
SyncStatusConverter::class,
DateConverter::class,
MemberConverter::class,
MemberInfoConverter::class,
ModerationDetailsConverter::class,
ModerationConverter::class,
ReactionGroupConverter::class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ package io.getstream.chat.android.client.internal.offline.repository.domain.mess

import androidx.collection.LruCache
import io.getstream.chat.android.client.api.models.Pagination
import io.getstream.chat.android.client.extensions.internal.hasOutdatedMemberInfo
import io.getstream.chat.android.client.extensions.internal.withRefreshedMemberInfo
import io.getstream.chat.android.client.internal.offline.extensions.launchWithMutex
import io.getstream.chat.android.client.persistance.repository.MessageRepository
import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest
import io.getstream.chat.android.client.utils.message.LocalOnlyMessageTypes
import io.getstream.chat.android.client.utils.message.LocalOnlySyncStatuses
import io.getstream.chat.android.client.utils.message.isDeleted
import io.getstream.chat.android.models.DraftMessage
import io.getstream.chat.android.models.MemberInfo
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.Poll
import io.getstream.chat.android.models.SyncStatus
Expand Down Expand Up @@ -84,6 +87,29 @@ internal class DatabaseMessageRepository(
messageDao.selectByCidAndUserId(cid, userId)
.map { it.toMessage() }

override suspend fun updateChannelUserMessagesMember(cid: String, userId: String, member: MemberInfo?) {
val entity = member?.toEntity()
// The caches are read before the database, so they follow the edit straight away.
patchCachedMember(messageCache, cid, userId, member)
patchCachedMember(replyMessageCache, cid, userId, member)
// Held under the same mutex as the inserts so it cannot interleave with another write.
scope.launchWithMutex(dbMutex) {
messageDao.updateMemberByCidAndUserId(cid, userId, entity)
replyMessageDao.updateMemberByCidAndUserId(cid, userId, entity)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private fun patchCachedMember(
cache: LruCache<String, Message>,
cid: String,
userId: String,
member: MemberInfo?,
) {
cache.snapshot().values
.filter { message -> message.cid == cid && message.hasOutdatedMemberInfo(userId, member) }
.forEach { message -> cache.put(message.id, message.withRefreshedMemberInfo(userId, member)) }
}

private suspend fun selectRepliedMessage(messageId: String): Message? =
replyMessageCache[messageId] ?: replyMessageDao.selectById(messageId)?.toModel(getUser, ::getPoll)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.chat.android.client.internal.offline.repository.domain.message.internal

import com.squareup.moshi.JsonClass

/**
* DB entity holding limited data about the channel membership of the user who sent a message.
*
* @property channelRole The channel-level role of the member.
* @property notificationsMuted If notifications are muted for the member in the channel.
* @property extraData The custom data of the member.
*/
@JsonClass(generateAdapter = true)
internal data class MemberInfoEntity(
val channelRole: String? = null,
val notificationsMuted: Boolean = false,
val extraData: Map<String, Any> = emptyMap(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ internal interface MessageDao {
@Query("SELECT * FROM $MESSAGE_ENTITY_TABLE_NAME WHERE cid = :cid AND userId = :userId")
suspend fun selectByCidAndUserId(cid: String, userId: String): List<MessageEntity>

@Query("UPDATE $MESSAGE_ENTITY_TABLE_NAME SET member = :member WHERE cid = :cid AND userId = :userId")
suspend fun updateMemberByCidAndUserId(cid: String, userId: String, member: MemberInfoEntity?)

@Query(
"SELECT id FROM $MESSAGE_ENTITY_TABLE_NAME " +
"WHERE syncStatus = :syncStatus " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ internal data class MessageInnerEntity(
val reminder: ReminderInfoEntity? = null,
/** The shared location of the message, if any */
val sharedLocation: LocationEntity? = null,
/** The role of the member(who sent the message) in the channel */
val channelRole: String? = null,
/** Limited data about the channel membership of the user who sent the message */
val member: MemberInfoEntity? = null,
/** Whether the message was deleted for the current user */
val deletedForMe: Boolean = false,
)
Expand Down
Loading
Loading