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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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.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 @@ -438,6 +439,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,20 @@ 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 `user_id`. The projection the
* backend puts on `message.member` never carries it, so it is 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. */
private val NON_CUSTOM_MEMBER_KEYS = setOf("user_id")
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 =
isAuthoredBy(userId, memberInfo) || replyTo?.isAuthoredBy(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.isAuthoredBy(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 @@ -239,6 +239,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