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 cba3e6500cf..552ab4b9ed8 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 @@ -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 @@ -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(), 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 44a1e8ce22d..c1c5c8c7589 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 @@ -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 @@ -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 @@ -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( @@ -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) @@ -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 = extraData + custom.orEmpty() + internal fun DownstreamLocationDto.toDomain(): Location = Location( cid = channel_cid, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/MemberDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/MemberDtos.kt index 078c9852ffa..e0945a7766a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/MemberDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/MemberDtos.kt @@ -75,11 +75,24 @@ internal data class DownstreamMemberDto( ) : 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, + val custom: Map? = null, + val extraData: Map = emptyMap(), +) : ExtraDataDto diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Member.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Member.kt index 84b5ed96e46..78369512e69 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Member.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Member.kt @@ -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]. */ @@ -29,3 +31,20 @@ public fun Collection.updateUsers(userMap: Map): 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") diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Message.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Message.kt index e653f603762..d1a709ccc7f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Message.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Message.kt @@ -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.updateUsers(users: Map): List = map { it.updateUsers(users) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/internal/MemberInfoConverter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/internal/MemberInfoConverter.kt new file mode 100644 index 00000000000..d9729104bfc --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/internal/MemberInfoConverter.kt @@ -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() + + /** + * 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) + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt index eb10eea3d53..21ae5ca5855 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt @@ -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 @@ -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( @@ -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, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt index 4dbfe29972c..79e8fd2ddeb 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt @@ -18,6 +18,8 @@ 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 @@ -25,6 +27,7 @@ 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 @@ -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) + } + } + + private fun patchCachedMember( + cache: LruCache, + 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) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MemberInfoEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MemberInfoEntity.kt new file mode 100644 index 00000000000..37efcaead96 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MemberInfoEntity.kt @@ -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 = emptyMap(), +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt index 00e8af420f8..8e9d7999317 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt @@ -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 + @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 " + diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageEntity.kt index 6b8404145f0..9c209f7002d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageEntity.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageEntity.kt @@ -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, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMapper.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMapper.kt index a003601d9ce..00093cc8a46 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMapper.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMapper.kt @@ -25,12 +25,14 @@ import io.getstream.chat.android.client.internal.offline.repository.domain.messa import io.getstream.chat.android.client.internal.offline.repository.domain.reaction.internal.toEntity import io.getstream.chat.android.client.internal.offline.repository.domain.reaction.internal.toModel 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.MessageReminderInfo import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.Reaction import io.getstream.chat.android.models.User +@Suppress("DEPRECATION") internal suspend fun MessageEntity.toModel( getUser: suspend (userId: String) -> User, getReply: suspend (messageId: String) -> Message?, @@ -87,11 +89,13 @@ internal suspend fun MessageEntity.toModel( poll = pollId?.let { getPoll(it) }, reminder = reminder?.toModel(), sharedLocation = sharedLocation?.toModel(), - channelRole = channelRole, + channelRole = member?.channelRole, + member = member?.toModel(), deletedForMe = deletedForMe, ) } +@Suppress("DEPRECATION") internal fun Message.toEntity(): MessageEntity = MessageEntity( messageInnerEntity = MessageInnerEntity( id = id, @@ -140,7 +144,7 @@ internal fun Message.toEntity(): MessageEntity = MessageEntity( reminder = reminder?.toEntity(), restrictedVisibility = restrictedVisibility, sharedLocation = sharedLocation?.toEntity(), - channelRole = channelRole, + member = memberInfoToEntity(), deletedForMe = deletedForMe, ), attachments = attachments.mapIndexed { index, attachment -> attachment.toEntity(id, index) }, @@ -148,6 +152,7 @@ internal fun Message.toEntity(): MessageEntity = MessageEntity( ownReactions = ownReactions.map(Reaction::toEntity), ) +@Suppress("DEPRECATION") internal suspend fun ReplyMessageEntity.toModel( getUser: suspend (userId: String) -> User, getPoll: suspend (pollId: String) -> Poll?, @@ -197,11 +202,13 @@ internal suspend fun ReplyMessageEntity.toModel( restrictedVisibility = restrictedVisibility, channelInfo = channelInfo?.toModel(), reminder = reminder?.toModel(), - channelRole = channelRole, + channelRole = member?.channelRole, + member = member?.toModel(), ) } } +@Suppress("DEPRECATION") internal fun Message.toReplyEntity(): ReplyMessageEntity = ReplyMessageEntity( replyMessageInnerEntity = ReplyMessageInnerEntity( @@ -239,7 +246,7 @@ internal fun Message.toReplyEntity(): ReplyMessageEntity = moderationDetails = moderationDetails?.toEntity(), pollId = poll?.id, reminder = reminder?.toEntity(), - channelRole = channelRole, + member = memberInfoToEntity(), ), attachments = attachments.mapIndexed { index, attachment -> attachment.toReplyEntity(id, index) }, ) @@ -285,3 +292,23 @@ internal fun ReminderInfoEntity.toModel(): MessageReminderInfo = MessageReminder createdAt = createdAt, updatedAt = updatedAt, ) + +internal fun MemberInfo.toEntity(): MemberInfoEntity = MemberInfoEntity( + channelRole = channelRole, + notificationsMuted = notificationsMuted, + extraData = extraData, +) + +internal fun MemberInfoEntity.toModel(): MemberInfo = MemberInfo( + channelRole = channelRole, + notificationsMuted = notificationsMuted, + extraData = extraData, +) + +/** + * Maps [Message.member] to its entity, falling back to the deprecated [Message.channelRole] when only that one is set. + * Without the fallback a message built with the deprecated field would silently lose its role on the way to the store. + */ +@Suppress("DEPRECATION") +private fun Message.memberInfoToEntity(): MemberInfoEntity? = + member?.toEntity() ?: channelRole?.let { MemberInfoEntity(channelRole = it) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ReplyMessageDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ReplyMessageDao.kt index 2ba640ebe65..8b893e4ce58 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ReplyMessageDao.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ReplyMessageDao.kt @@ -46,6 +46,9 @@ internal interface ReplyMessageDao { @Delete suspend fun delete(replyMessageInnerEntity: ReplyMessageInnerEntity) + @Query("UPDATE $REPLY_MESSAGE_ENTITY_TABLE_NAME SET member = :member WHERE cid = :cid AND userId = :userId") + suspend fun updateMemberByCidAndUserId(cid: String, userId: String, member: MemberInfoEntity?) + @Query("DELETE FROM $REPLY_MESSAGE_ENTITY_TABLE_NAME") suspend fun deleteAll() } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ReplyMessageEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ReplyMessageEntity.kt index 4f6c032bcbe..d4a5fa2882b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ReplyMessageEntity.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/ReplyMessageEntity.kt @@ -115,8 +115,8 @@ internal data class ReplyMessageInnerEntity( val restrictedVisibility: List = emptyList(), /** Info about the reminder for the message **/ val reminder: ReminderInfoEntity? = 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, ) internal const val REPLY_MESSAGE_ENTITY_TABLE_NAME = "stream_chat_reply_message" diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index 6c63d0d07bb..0552c4385bd 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -96,6 +96,7 @@ import io.getstream.chat.android.client.extensions.internal.mergeReactions import io.getstream.chat.android.client.extensions.internal.processPoll import io.getstream.chat.android.client.extensions.internal.removeMember import io.getstream.chat.android.client.extensions.internal.removeMembership +import io.getstream.chat.android.client.extensions.internal.toMemberInfo import io.getstream.chat.android.client.extensions.internal.toMessageReminderInfo import io.getstream.chat.android.client.extensions.internal.updateMember import io.getstream.chat.android.client.extensions.internal.updateMemberBanned @@ -582,6 +583,15 @@ internal class EventHandlerSequential( } } + // A membership change carries no message id, so it cannot be grouped like the events above: every active + // thread is asked to refresh the author's replies, which the channel state refresh does not reach. + sortedEvents.filterIsInstance().forEach { event -> + val memberInfo = event.member.toMemberInfo() + logicRegistry.getActiveThreadsLogic().forEach { thread -> + thread.updateMessagesMemberInfo(event.cid, event.member.getUserId(), memberInfo) + } + } + logger.v { "[updateThreadState] completed batchId: ${batchEvent.id}" } } @@ -936,6 +946,15 @@ internal class EventHandlerSequential( is UserMessagesDeletedEvent -> { deleteMessagesFromUser(event.cid, event.user.id, event.hardDelete, event.createdAt) } + is MemberUpdatedEvent -> { + // The backend does not emit message.updated for membership changes, so the snapshot denormalized + // onto the stored messages would otherwise stay stale until they are fetched again. + repos.updateChannelUserMessagesMember( + cid = event.cid, + userId = event.member.getUserId(), + member = event.member.toMemberInfo(), + ) + } else -> Unit // Ignore other events } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt index 1f9740cebd3..36e253d76ba 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt @@ -263,6 +263,8 @@ internal class ChannelEventHandlerImpl( is MemberUpdatedEvent -> { state.upsertMember(event.member) + // The backend does not emit message.updated for membership changes, so refresh the snapshot ourselves + state.updateMessagesMemberInfo(event.member) // Update the channel.membership if the current user's member info is updated if (event.member.getUserId() == getCurrentUserId()) { state.setMembership(event.member) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt index 391deefdd4d..d4700e401d9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt @@ -196,6 +196,8 @@ internal class ChannelEventHandlerLegacyImpl( is MemberUpdatedEvent -> { stateLogic.upsertMember(event.member) stateLogic.updateMembership(event.member) + // The backend does not emit message.updated for membership changes, so refresh the snapshot ourselves + mutableState.updateMessagesMemberInfo(event.member) } is NotificationAddedToChannelEvent -> { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadLogic.kt index 7bcc5dfad32..8d3adfd51c8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadLogic.kt @@ -35,6 +35,7 @@ import io.getstream.chat.android.client.extensions.internal.processPoll import io.getstream.chat.android.client.extensions.internal.toMessageReminderInfo import io.getstream.chat.android.client.internal.state.plugin.state.channel.thread.internal.ThreadMutableState import io.getstream.chat.android.client.plugin.listeners.ThreadQueryListener +import io.getstream.chat.android.models.MemberInfo import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Poll @@ -74,6 +75,9 @@ internal class ThreadLogic( internal fun upsertMessages(messages: List) = threadStateLogic.upsertMessages(messages) + internal fun updateMessagesMemberInfo(cid: String, userId: String, memberInfo: MemberInfo) = + threadStateLogic.updateMessagesMemberInfo(cid, userId, memberInfo) + internal fun removeLocalMessage(message: Message) { threadStateLogic.deleteMessage(message) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadStateLogic.kt index 6b440b87304..893b7de3839 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadStateLogic.kt @@ -17,7 +17,10 @@ package io.getstream.chat.android.client.internal.state.plugin.logic.channel.thread.internal import io.getstream.chat.android.client.extensions.internal.NEVER +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.state.plugin.state.channel.thread.internal.ThreadMutableState +import io.getstream.chat.android.models.MemberInfo import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.SyncStatus @@ -51,6 +54,22 @@ internal class ThreadStateLogic(private val mutableState: ThreadMutableState) { */ fun upsertMessage(message: Message) = upsertMessages(listOf(message)) + /** + * Refreshes the [Message.member] snapshot carried by the thread replies the given member authored. + * + * Thread replies live in this state rather than the channel one, so the channel refresh does not reach them. + * + * @param cid The channel the membership belongs to. + * @param userId The author whose replies should be refreshed. + * @param memberInfo The member snapshot to store. + */ + fun updateMessagesMemberInfo(cid: String, userId: String, memberInfo: MemberInfo) { + val outdated = mutableState.rawMessage.value.values + .filter { message -> message.cid == cid && message.hasOutdatedMemberInfo(userId, memberInfo) } + if (outdated.isEmpty()) return + mutableState.upsertMessages(outdated.map { message -> message.withRefreshedMemberInfo(userId, memberInfo) }) + } + /** * Upsert messages in the channel. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt index 2767b0bfe40..e54264db762 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt @@ -299,6 +299,8 @@ internal class LogicRegistry internal constructor( */ fun getActiveChannelsLogic(): List = channels.values.toList() + fun getActiveThreadsLogic(): List = threads.values.toList() + fun isActiveThread(messageId: String): Boolean = threads.containsKey(messageId) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index 2a5db0ab7b1..0cfb17cb362 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -26,8 +26,11 @@ import io.getstream.chat.android.client.events.UserStartWatchingEvent import io.getstream.chat.android.client.events.UserStopWatchingEvent import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.getCreatedAtOrNull +import io.getstream.chat.android.client.extensions.internal.hasOutdatedMemberInfo +import io.getstream.chat.android.client.extensions.internal.toMemberInfo import io.getstream.chat.android.client.extensions.internal.updateUsers import io.getstream.chat.android.client.extensions.internal.wasCreatedAfter +import io.getstream.chat.android.client.extensions.internal.withRefreshedMemberInfo import io.getstream.chat.android.client.internal.state.message.attachments.internal.AttachmentUrlValidator import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl.Companion.CACHED_LATEST_MESSAGES_LIMIT import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl.Companion.TRIM_BUFFER @@ -891,6 +894,24 @@ internal class ChannelStateImpl( } } + /** + * Refreshes the [Message.member] snapshot carried by the messages the [member] authored. + * + * The backend does not emit `message.updated` when a membership changes, so without this the snapshot stored on + * already delivered messages would stay stale until they are fetched again. + * + * @param member The member whose messages should be refreshed. + */ + fun updateMessagesMemberInfo(member: Member) { + val userId = member.getUserId() + val memberInfo = member.toMemberInfo() + val isOutdated = { message: Message -> message.hasOutdatedMemberInfo(userId, memberInfo) } + val refresh = { message: Message -> message.withRefreshedMemberInfo(userId, memberInfo) } + _messages.update { it.updateIf(isOutdated, refresh) } + _cachedLatestMessages.update { it.updateIf(isOutdated, refresh) } + _pinnedMessages.update { it.updateIf(isOutdated, refresh) } + } + // endregion // region Membership diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt index e64e8cef2f9..c7a99ea2b08 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt @@ -19,8 +19,11 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.int import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.getCreatedAtOrNull +import io.getstream.chat.android.client.extensions.internal.hasOutdatedMemberInfo +import io.getstream.chat.android.client.extensions.internal.toMemberInfo import io.getstream.chat.android.client.extensions.internal.updateUsers import io.getstream.chat.android.client.extensions.internal.wasCreatedAfter +import io.getstream.chat.android.client.extensions.internal.withRefreshedMemberInfo import io.getstream.chat.android.client.extensions.syncUnreadCountWithReads import io.getstream.chat.android.client.internal.state.utils.internal.combineStates import io.getstream.chat.android.client.internal.state.utils.internal.mapState @@ -557,6 +560,31 @@ internal class ChannelStateLegacyImpl( _pinnedMessages?.apply { value = value.updateUsers(mapOf(user.id to user)) } } + /** + * Refreshes the [Message.member] snapshot carried by the messages the [member] authored. + * + * The backend does not emit `message.updated` when a membership changes, so without this the snapshot stored on + * already delivered messages would stay stale until they are fetched again. + * + * @param member The member whose messages should be refreshed. + */ + fun updateMessagesMemberInfo(member: Member) { + val userId = member.getUserId() + val memberInfo = member.toMemberInfo() + val refresh: (Map) -> Map = { messages -> + messages.mapValues { (_, message) -> + if (message.hasOutdatedMemberInfo(userId, memberInfo)) { + message.withRefreshedMemberInfo(userId, memberInfo) + } else { + message + } + } + } + _messages?.apply { value = refresh(value) } + _pinnedMessages?.apply { value = refresh(value) } + cachedLatestMessages.apply { value = refresh(value) } + } + fun upsertReads(reads: List) { _rawReads?.apply { value = value + reads.associateBy(ChannelUserRead::getUserId) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt index 3b0c71bd12b..3b40f1ce860 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt @@ -35,6 +35,7 @@ import io.getstream.chat.android.client.parser2.adapters.CreatePollOptionRequest import io.getstream.chat.android.client.parser2.adapters.CreatePollRequestAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamChannelDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamMemberDtoAdapter +import io.getstream.chat.android.client.parser2.adapters.DownstreamMemberInfoDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamMessageDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamModerationDetailsDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamPollDtoAdapter @@ -89,6 +90,7 @@ internal class MoshiChatParser( .add(UserResponseAdapter) .add(UserRequestAdapter) .add(DownstreamMemberDtoAdapter) + .add(DownstreamMemberInfoDtoAdapter) .add(UpstreamMemberDtoAdapter) .add(UpstreamMemberDataDtoAdapter) .add(FlagRequestAdapterFactory) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/MemberDtoAdapters.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/MemberDtoAdapters.kt index 81e21a9ebf8..39652e6533b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/MemberDtoAdapters.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/MemberDtoAdapters.kt @@ -22,6 +22,7 @@ import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter import com.squareup.moshi.ToJson 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.UpstreamMemberDto /** @@ -42,6 +43,26 @@ internal object DownstreamMemberDtoAdapter : CustomObjectDtoAdapter(DownstreamMemberInfoDto::class) { + + @FromJson + fun fromJson( + jsonReader: JsonReader, + mapAdapter: JsonAdapter>, + memberInfoAdapter: JsonAdapter, + ): DownstreamMemberInfoDto? = parseWithExtraData(jsonReader, mapAdapter, memberInfoAdapter) + + @ToJson + @Suppress("UNUSED_PARAMETER") + fun toJson(jsonWriter: JsonWriter, value: DownstreamMemberInfoDto): Unit = error("Can't convert this to Json") +} + /** * JSON adapter for [UpstreamMemberDto]. * Handles the proper serialization of the [extraData] field. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/JsonParsingUtils.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/JsonParsingUtils.kt index b1dd02d2e2d..827b0112582 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/JsonParsingUtils.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/JsonParsingUtils.kt @@ -174,4 +174,26 @@ internal object JsonParsingUtils { reader.endObject() return map } + + /** + * Parses a JSON object into a Map, keeping nested values as their natural JSON types. + * + * @param reader The JsonReader positioned at the object field. + * @return A map of string keys to arbitrary values, or null if the JSON value is null, missing, or not an object. + */ + fun parseAnyMap(reader: JsonReader): Map? { + if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return null + } + reader.beginObject() + val map = mutableMapOf() + while (reader.hasNext()) { + val key = reader.nextName() + reader.readJsonValue()?.let { map[key] = it } + } + reader.endObject() + return map + } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageAdapter.kt index dc75f7ce512..44979cf9df8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/direct/MessageAdapter.kt @@ -22,6 +22,7 @@ import com.squareup.moshi.JsonWriter import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.ChannelInfo import io.getstream.chat.android.models.Location +import io.getstream.chat.android.models.MemberInfo import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageModerationDetails import io.getstream.chat.android.models.MessageReminderInfo @@ -55,7 +56,7 @@ internal class MessageAdapter( return fromJson(reader, fallbackChannelInfo = null) } - @Suppress("LongMethod", "ThrowsCount") + @Suppress("LongMethod", "ThrowsCount", "DEPRECATION") fun fromJson(reader: JsonReader, fallbackChannelInfo: ChannelInfo?): Message? { if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull() @@ -103,7 +104,7 @@ internal class MessageAdapter( var poll: Poll? = null var reminder: MessageReminderInfo? = null var sharedLocation: Location? = null - var channelRole: String? = null + var member: MemberInfo? = null var deletedForMe: Boolean? = null var extraData: MutableMap? = null @@ -173,7 +174,7 @@ internal class MessageAdapter( "poll" -> poll = pollAdapter.fromJson(reader) "reminder" -> reminder = reminderAdapter.fromJson(reader) "shared_location" -> sharedLocation = locationAdapter.fromJson(reader) - "member" -> channelRole = parseMemberChannelRole(reader) + "member" -> member = parseMemberInfo(reader) "deleted_for_me" -> deletedForMe = JsonParsingUtils.readNullableBoolean(reader) else -> extraData = JsonParsingUtils.accumulateExtraData(key, reader, extraData) } @@ -270,13 +271,14 @@ internal class MessageAdapter( restrictedVisibility = emptyList(), reminder = reminder, sharedLocation = sharedLocation, - channelRole = channelRole, + channelRole = member?.channelRole, + member = member, deletedForMe = deletedForMe ?: false, extraData = extraData ?: emptyMap(), ).let(messageTransformer::transform) } - private fun parseMemberChannelRole(reader: JsonReader): String? { + private fun parseMemberInfo(reader: JsonReader): MemberInfo? { if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { reader.skipValue() return null @@ -284,16 +286,30 @@ internal class MessageAdapter( reader.beginObject() var channelRole: String? = null + var notificationsMuted: Boolean? = null + var custom: MutableMap? = null while (reader.hasNext()) { - when (reader.nextName()) { + val key = reader.nextName() + when (key) { "channel_role" -> channelRole = JsonParsingUtils.readNullableString(reader) - else -> reader.skipValue() + "notifications_muted" -> notificationsMuted = JsonParsingUtils.readNullableBoolean(reader) + // API v2 nests the member custom data under "custom", API v1 inlines it next to the declared + // fields, so unknown keys are member custom data too. + "custom" -> { + val nested = JsonParsingUtils.parseAnyMap(reader).orEmpty() + custom = (custom ?: mutableMapOf()).apply { putAll(nested) } + } + else -> custom = JsonParsingUtils.accumulateExtraData(key, reader, custom) } } reader.endObject() - return channelRole + return MemberInfo( + channelRole = channelRole, + notificationsMuted = notificationsMuted ?: false, + extraData = custom.orEmpty(), + ) } override fun toJson(p0: JsonWriter, p1: Message?) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageRepository.kt index 64d86dd36ea..9b81ddc5f38 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageRepository.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.persistance.repository import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.core.internal.InternalStreamChatApi 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.SyncStatus import java.util.Date @@ -67,6 +68,17 @@ public interface MessageRepository { */ public suspend fun selectAllChannelUserMessages(cid: String, userId: String): List + /** + * Replaces the [Message.member] snapshot on every message a given user authored in a given channel. + * + * Only that field is written, so a message being modified elsewhere at the same time is not overwritten. + * + * @param cid The channel ID to filter messages. + * @param userId The user ID to filter messages. + * @param member The member snapshot to store, or null to clear it. + */ + public suspend fun updateChannelUserMessagesMember(cid: String, userId: String, member: MemberInfo?) + /** * Selects messages by IDs. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpMessageRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpMessageRepository.kt index 5adaaccda6c..8c4b60694dd 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpMessageRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpMessageRepository.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.persistance.repository.noop import io.getstream.chat.android.client.persistance.repository.MessageRepository import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest 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.SyncStatus import java.util.Date @@ -57,5 +58,11 @@ internal object NoOpMessageRepository : MessageRepository { override suspend fun selectMessagesForThread(messageId: String, limit: Int): List = emptyList() override suspend fun selectAllUserMessages(userId: String): List = emptyList() override suspend fun selectAllChannelUserMessages(cid: String, userId: String): List = emptyList() + + override suspend fun updateChannelUserMessagesMember( + cid: String, + userId: String, + member: MemberInfo?, + ) { /* No-Op */ } override suspend fun selectLocalOnlyMessagesForChannel(cid: String): List = emptyList() } 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 aa165a826c8..41502e70e48 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 @@ -761,8 +761,17 @@ internal object Mother { extraData = extraData, ) - fun randomDownstreamMemberInfoDto(channelRole: String? = randomString()): DownstreamMemberInfoDto = - DownstreamMemberInfoDto(channel_role = channelRole) + fun randomDownstreamMemberInfoDto( + channelRole: String? = randomString(), + notificationsMuted: Boolean? = randomBoolean(), + custom: Map? = null, + extraData: Map = mapOf(randomString() to randomString()), + ): DownstreamMemberInfoDto = DownstreamMemberInfoDto( + channel_role = channelRole, + notifications_muted = notificationsMuted, + custom = custom, + extraData = extraData, + ) fun randomDownstreamChannelUserRead( user: DownstreamUserDto = randomDownstreamUserDto(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/MemberExtensionsTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/MemberExtensionsTests.kt new file mode 100644 index 00000000000..63be828d0c2 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/MemberExtensionsTests.kt @@ -0,0 +1,55 @@ +/* + * 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.extensions.internal + +import io.getstream.chat.android.models.MemberInfo +import io.getstream.chat.android.randomMember +import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Test + +internal class MemberExtensionsTests { + + @Test + fun `toMemberInfo should narrow the member down to the slim shape`() { + val member = randomMember(channelRole = "channel_moderator") + .copy(notificationsMuted = true, extraData = mapOf("flair" to mapOf("tier" to "gold"))) + + member.toMemberInfo() shouldBeEqualTo MemberInfo( + channelRole = "channel_moderator", + notificationsMuted = true, + extraData = mapOf("flair" to mapOf("tier" to "gold")), + ) + } + + @Test + fun `toMemberInfo should treat an absent notificationsMuted as false`() { + val member = randomMember().copy(notificationsMuted = null, extraData = emptyMap()) + + member.toMemberInfo().notificationsMuted shouldBeEqualTo false + } + + @Test + fun `toMemberInfo should drop keys that are not member custom data`() { + // user_id is not declared on the member DTO, so it reaches Member.extraData. The projection the backend puts + // on message.member never carries it, so it must not leak into MemberInfo either. + val member = randomMember().copy( + extraData = mapOf("user_id" to "leandro", "flair" to mapOf("tier" to "gold")), + ) + + member.toMemberInfo().extraData shouldBeEqualTo mapOf("flair" to mapOf("tier" to "gold")) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/MessageExtensionsTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/MessageExtensionsTests.kt index 56dab7dc678..5a37695c117 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/MessageExtensionsTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/internal/MessageExtensionsTests.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.extensions.internal import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.models.MemberInfo import io.getstream.chat.android.randomAttachment import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomDate @@ -28,6 +29,7 @@ import io.getstream.chat.android.randomReaction import io.getstream.chat.android.randomUser import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull import org.junit.jupiter.api.Test import java.util.Date import java.util.concurrent.TimeUnit @@ -626,4 +628,77 @@ internal class MessageExtensionsTests { // then result shouldBeEqualTo false } + + @Test + fun `withMemberInfo should set the member snapshot and keep the deprecated channelRole in sync`() { + val message = randomMessage(member = null) + val memberInfo = MemberInfo(channelRole = "channel_moderator", extraData = mapOf("flair" to "gold")) + + val result = message.withMemberInfo(memberInfo) + + result.member shouldBeEqualTo memberInfo + @Suppress("DEPRECATION") + result.channelRole shouldBeEqualTo "channel_moderator" + } + + @Test + fun `withMemberInfo should take the incoming snapshot verbatim`() { + // The same value has to reach the state, the repository cache and the database, and a blanket column update + // cannot preserve a previously known role, so nothing is carried over here either. + val message = randomMessage(member = MemberInfo(channelRole = "channel_moderator")) + val memberInfo = MemberInfo(channelRole = null, extraData = mapOf("flair" to "gold")) + + val result = message.withMemberInfo(memberInfo) + + result.member shouldBeEqualTo memberInfo + @Suppress("DEPRECATION") + result.channelRole shouldBeEqualTo null + } + + @Test + fun `withRefreshedMemberInfo should refresh the quoted copy of the same author`() { + // The quoted message carries its own snapshot, so leaving it behind shows one author with two values. + val author = randomUser() + val quoted = randomMessage(user = author, member = null) + val message = randomMessage(user = author, member = null, replyTo = quoted) + val memberInfo = MemberInfo(channelRole = "channel_moderator", extraData = mapOf("flair" to "gold")) + + val result = message.withRefreshedMemberInfo(author.id, memberInfo) + + result.member shouldBeEqualTo memberInfo + result.replyTo?.member shouldBeEqualTo memberInfo + } + + @Test + fun `withRefreshedMemberInfo should leave a quoted copy of another author alone`() { + val author = randomUser() + val quoted = randomMessage(user = randomUser(), member = null) + val message = randomMessage(user = author, member = null, replyTo = quoted) + + val result = message.withRefreshedMemberInfo(author.id, MemberInfo(channelRole = "channel_moderator")) + + result.member?.channelRole shouldBeEqualTo "channel_moderator" + result.replyTo?.member.shouldBeNull() + } + + @Test + fun `hasOutdatedMemberInfo should spot an outdated quoted copy`() { + val author = randomUser() + val memberInfo = MemberInfo(channelRole = "channel_moderator") + val quoted = randomMessage(user = author, member = null) + val message = randomMessage(user = randomUser(), member = null, replyTo = quoted) + + message.hasOutdatedMemberInfo(author.id, memberInfo) shouldBeEqualTo true + } + + @Test + fun `withMemberInfo should clear both fields when the member is absent`() { + val message = randomMessage(member = MemberInfo(channelRole = "channel_moderator")) + + val result = message.withMemberInfo(null) + + result.member shouldBeEqualTo null + @Suppress("DEPRECATION") + result.channelRole shouldBeEqualTo null + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt index 8f87e3b82dd..0c9b7849cd6 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt @@ -27,6 +27,7 @@ import io.getstream.chat.android.client.internal.offline.repository.domain.messa import io.getstream.chat.android.client.internal.offline.repository.domain.message.attachment.internal.UploadStateEntity import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.AnswerEntity import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.LocationEntity +import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.MemberInfoEntity import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.MessageEntity import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.MessageInnerEntity import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.OptionEntity @@ -161,7 +162,7 @@ internal fun randomMessageEntity( pollId: String? = null, reminder: ReminderInfoEntity = randomReminderInfoEntity(), sharedLocation: LocationEntity? = randomLocationEntity(), - channelRole: String? = null, + member: MemberInfoEntity? = randomMemberInfoEntity(), deletedForMe: Boolean = randomBoolean(), ) = MessageEntity( messageInnerEntity = MessageInnerEntity( @@ -199,7 +200,7 @@ internal fun randomMessageEntity( pollId = pollId, reminder = reminder, sharedLocation = sharedLocation, - channelRole = channelRole, + member = member, deletedForMe = deletedForMe, ), attachments = attachments, @@ -303,6 +304,16 @@ internal fun randomReplyAttachmentEntity( extraData = extraData, ) +internal fun randomMemberInfoEntity( + channelRole: String? = randomString(), + notificationsMuted: Boolean = randomBoolean(), + extraData: Map = mapOf(randomString() to randomString()), +): MemberInfoEntity = MemberInfoEntity( + channelRole = channelRole, + notificationsMuted = notificationsMuted, + extraData = extraData, +) + internal fun randomReminderInfoEntity( remindAt: Date? = randomDateOrNull(), createdAt: Date = randomDate(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/MessageMemberRefreshRepositoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/MessageMemberRefreshRepositoryTest.kt new file mode 100644 index 00000000000..02587cdf5f4 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/MessageMemberRefreshRepositoryTest.kt @@ -0,0 +1,84 @@ +/* + * 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 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.getstream.chat.android.client.internal.offline.integration.BaseDomainTest2 +import io.getstream.chat.android.models.MemberInfo +import io.getstream.chat.android.randomMessage +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Covers that refreshing the member snapshot reaches messages already held in the repository's in-memory cache. + * + * The cache is read before the database, so a targeted database update alone would leave stale objects being served. + */ +@RunWith(AndroidJUnit4::class) +internal class MessageMemberRefreshRepositoryTest : BaseDomainTest2() { + + private val member = MemberInfo( + channelRole = "channel_moderator", + notificationsMuted = true, + extraData = mapOf("flair" to mapOf("tier" to "gold")), + ) + + @Test + fun `refreshing the member updates a cached message`(): Unit = runTest { + val message = cachedMessage() + + repos.updateChannelUserMessagesMember(message.cid, message.user.id, member) + + repos.selectMessage(message.id)?.member shouldBeEqualTo member + } + + @Test + fun `refreshing the member keeps the deprecated channelRole of a cached message in sync`(): Unit = runTest { + val message = cachedMessage() + + repos.updateChannelUserMessagesMember(message.cid, message.user.id, member) + + @Suppress("DEPRECATION") + repos.selectMessage(message.id)?.channelRole shouldBeEqualTo "channel_moderator" + } + + @Test + fun `clearing the member updates a cached message`(): Unit = runTest { + val message = cachedMessage(member = member) + + repos.updateChannelUserMessagesMember(message.cid, message.user.id, null) + + repos.selectMessage(message.id)?.member.shouldBeNull() + } + + @Test + fun `refreshing the member leaves a cached message of another user alone`(): Unit = runTest { + val message = cachedMessage() + + repos.updateChannelUserMessagesMember(message.cid, "someone-else", member) + + repos.selectMessage(message.id)?.member.shouldBeNull() + } + + /** Inserting populates the repository cache synchronously, so the message is served from it afterwards. */ + private suspend fun cachedMessage(member: MemberInfo? = null) = + randomMessage(user = data.user1, member = member, replyTo = null, poll = null) + .also { repos.insertMessage(it) } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMapperTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMapperTest.kt index 8a2d24af238..cb39d3a6acb 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMapperTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMapperTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.internal.offline.repository.domain.message.internal +import io.getstream.chat.android.client.internal.offline.randomMemberInfoEntity import io.getstream.chat.android.client.internal.offline.randomMessageEntity import io.getstream.chat.android.client.internal.offline.randomReactionGroupEntity import io.getstream.chat.android.client.internal.offline.randomReminderInfoEntity @@ -122,7 +123,8 @@ internal class MessageMapperTest { deviceId = deviceId, ) }, - channelRole = messageEntity.messageInnerEntity.channelRole, + channelRole = messageEntity.messageInnerEntity.member?.channelRole, + member = messageEntity.messageInnerEntity.member?.toModel(), deletedForMe = messageEntity.messageInnerEntity.deletedForMe, ) @@ -198,7 +200,7 @@ internal class MessageMapperTest { deviceId = deviceId, ) }, - channelRole = message.channelRole, + member = message.member?.toEntity(), deletedForMe = message.deletedForMe, ), attachments = message.attachments.mapIndexed { index, attachment -> @@ -296,7 +298,7 @@ internal class MessageMapperTest { moderationDetails = message.moderationDetails?.toEntity(), pollId = message.poll?.id, reminder = message.reminder?.toEntity(), - channelRole = message.channelRole, + member = message.member?.toEntity(), ), attachments = message.attachments.mapIndexed { index, attachment -> attachment.toReplyEntity( @@ -353,7 +355,7 @@ internal class MessageMapperTest { pollId = null, restrictedVisibility = listOf(randomString()), reminder = randomReminderInfoEntity(), - channelRole = randomString(), + member = randomMemberInfoEntity(), ) val replyMessageEntity = ReplyMessageEntity( replyMessageInnerEntity = innerEntity, @@ -403,7 +405,8 @@ internal class MessageMapperTest { restrictedVisibility = innerEntity.restrictedVisibility, channelInfo = null, reminder = innerEntity.reminder?.toModel(), - channelRole = innerEntity.channelRole, + channelRole = innerEntity.member?.channelRole, + member = innerEntity.member?.toModel(), ) val result = replyMessageEntity.toModel( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMemberInfoDaoTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMemberInfoDaoTest.kt new file mode 100644 index 00000000000..06623daa6c3 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageMemberInfoDaoTest.kt @@ -0,0 +1,162 @@ +/* + * 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 androidx.test.ext.junit.runners.AndroidJUnit4 +import io.getstream.chat.android.client.internal.offline.createRoomDB +import io.getstream.chat.android.client.internal.offline.repository.database.internal.ChatDatabase +import io.getstream.chat.android.models.MemberInfo +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomUser +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Covers that the member snapshot attached to a message (`message.member`) survives a real database round trip, since + * it is the only place the author's projected member custom data is kept. + */ +@RunWith(AndroidJUnit4::class) +internal class MessageMemberInfoDaoTest { + + private lateinit var database: ChatDatabase + private lateinit var messageDao: MessageDao + private lateinit var replyMessageDao: ReplyMessageDao + + @Before + fun setUp() { + database = createRoomDB() + messageDao = database.messageDao() + replyMessageDao = database.replyMessageDao() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `member custom survives the round trip`(): Unit = runTest { + val member = MemberInfo( + channelRole = "channel_moderator", + notificationsMuted = true, + extraData = mapOf("flair" to mapOf("tier" to "gold", "badge" to "whale")), + ) + val message = randomMessage(member = member, replyTo = null, poll = null) + + messageDao.insert(message.toEntity()) + + messageDao.select(message.id)?.messageInnerEntity?.member shouldBeEqualTo member.toEntity() + } + + @Test + fun `a member without custom data survives the round trip`(): Unit = runTest { + val member = MemberInfo(channelRole = "channel_member", notificationsMuted = false, extraData = emptyMap()) + val message = randomMessage(member = member, replyTo = null, poll = null) + + messageDao.insert(message.toEntity()) + + messageDao.select(message.id)?.messageInnerEntity?.member shouldBeEqualTo member.toEntity() + } + + @Test + fun `an absent member stays absent across the round trip`(): Unit = runTest { + val message = randomMessage(member = null, replyTo = null, poll = null) + + messageDao.insert(message.toEntity()) + + messageDao.select(message.id)?.messageInnerEntity?.member.shouldBeNull() + } + + @Test + fun `member custom survives the round trip for a quoted message`(): Unit = runTest { + val member = MemberInfo( + channelRole = "channel_moderator", + notificationsMuted = true, + extraData = mapOf("flair" to "gold"), + ) + val reply = randomMessage(user = randomUser(), member = member, replyTo = null, poll = null) + + replyMessageDao.insert(listOf(reply.toReplyEntity())) + + replyMessageDao.selectById(reply.id)?.replyMessageInnerEntity?.member shouldBeEqualTo member.toEntity() + } + + @Test + fun `a message carrying only the deprecated channelRole keeps it across the round trip`(): Unit = runTest { + @Suppress("DEPRECATION") + val message = randomMessage(channelRole = "channel_moderator", member = null, replyTo = null, poll = null) + + messageDao.insert(message.toEntity()) + + val stored = messageDao.select(message.id)?.messageInnerEntity?.member + stored shouldBeEqualTo MemberInfoEntity(channelRole = "channel_moderator") + } + + @Test + fun `updating the member touches only the matching channel and author`(): Unit = runTest { + val author = randomUser(id = "author") + val other = randomUser(id = "other") + val mine = randomMessage(cid = CID, user = author, member = null, replyTo = null, poll = null) + val theirs = randomMessage(cid = CID, user = other, member = null, replyTo = null, poll = null) + val elsewhere = randomMessage(cid = OTHER_CID, user = author, member = null, replyTo = null, poll = null) + messageDao.insert(listOf(mine, theirs, elsewhere).map { it.toEntity() }) + val member = MemberInfoEntity(channelRole = "channel_moderator", extraData = mapOf("flair" to "gold")) + + messageDao.updateMemberByCidAndUserId(CID, author.id, member) + + messageDao.select(mine.id)?.messageInnerEntity?.member shouldBeEqualTo member + messageDao.select(theirs.id)?.messageInnerEntity?.member.shouldBeNull() + messageDao.select(elsewhere.id)?.messageInnerEntity?.member.shouldBeNull() + } + + @Test + fun `updating the member leaves the rest of the message untouched`(): Unit = runTest { + // This is the point of the targeted update: a concurrent edit elsewhere must not be reverted by the refresh. + val author = randomUser(id = "author") + val message = randomMessage(cid = CID, user = author, member = null, replyTo = null, poll = null) + messageDao.insert(message.toEntity()) + + messageDao.updateMemberByCidAndUserId(CID, author.id, MemberInfoEntity(channelRole = "channel_moderator")) + + val stored = messageDao.select(message.id)?.messageInnerEntity + stored?.text shouldBeEqualTo message.text + stored?.extraData shouldBeEqualTo message.extraData + stored?.member shouldBeEqualTo MemberInfoEntity(channelRole = "channel_moderator") + } + + @Test + fun `updating the member clears it when null is stored`(): Unit = runTest { + val author = randomUser(id = "author") + val member = MemberInfo(channelRole = "channel_member", extraData = mapOf("flair" to "gold")) + val message = randomMessage(cid = CID, user = author, member = member, replyTo = null, poll = null) + messageDao.insert(message.toEntity()) + + messageDao.updateMemberByCidAndUserId(CID, author.id, null) + + messageDao.select(message.id)?.messageInnerEntity?.member.shouldBeNull() + } + + private companion object { + const val CID = "messaging:probe" + const val OTHER_CID = "messaging:other" + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/attachment/UploadAttachmentsIntegrationTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/attachment/UploadAttachmentsIntegrationTests.kt index 21662d9b082..ce4878b1c4b 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/attachment/UploadAttachmentsIntegrationTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/attachment/UploadAttachmentsIntegrationTests.kt @@ -30,6 +30,7 @@ import io.getstream.chat.android.client.persistance.repository.MessageRepository import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.models.Attachment 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.SyncStatus import io.getstream.chat.android.models.UploadedFile @@ -231,6 +232,10 @@ internal class MockMessageRepository : MessageRepository { TODO("Not yet implemented") } + override suspend fun updateChannelUserMessagesMember(cid: String, userId: String, member: MemberInfo?) { + TODO("Not yet implemented") + } + override suspend fun selectMessages(messageIds: List): List { return messages.filter { (messageId, _) -> messageIds.contains(messageId) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialMemberUpdatedTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialMemberUpdatedTest.kt new file mode 100644 index 00000000000..924b68c30ce --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialMemberUpdatedTest.kt @@ -0,0 +1,121 @@ +/* + * 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.state.event.handler.internal + +import io.getstream.chat.android.client.ChatEventListener +import io.getstream.chat.android.client.api.MessageBufferConfig +import io.getstream.chat.android.client.api.state.StateRegistry +import io.getstream.chat.android.client.events.ChatEvent +import io.getstream.chat.android.client.events.MemberUpdatedEvent +import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.client.test.randomMemberUpdatedEvent +import io.getstream.chat.android.client.utils.observable.Disposable +import io.getstream.chat.android.models.MemberInfo +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomUser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +/** + * Covers the [EventHandlerSequential] handling of [MemberUpdatedEvent]: the backend never emits `message.updated` when + * a membership changes, so the member snapshot denormalized onto the stored messages has to be refreshed by the SDK. + */ +internal class EventHandlerSequentialMemberUpdatedTest { + + private val currentUser = randomUser() + private val author = randomUser() + private val testCid = "messaging:123" + + @Test + fun `When MemberUpdatedEvent is processed, should refresh the member snapshot on the author's stored messages`() = + runTest { + // Given + val member = randomMember(user = author, channelRole = "channel_moderator") + .copy(notificationsMuted = true, extraData = mapOf("flair" to "gold")) + val event = randomMemberUpdatedEvent(cid = testCid, user = author, member = member) + val repos = mockRepos() + + val handler = createEventHandler(scope = this, repos = repos) + + // When + handler.handleEvents(event) + + // Then - only the member column is written, so a concurrent edit elsewhere is not overwritten + verify(repos).updateChannelUserMessagesMember( + testCid, + author.id, + MemberInfo( + channelRole = "channel_moderator", + notificationsMuted = true, + extraData = mapOf("flair" to "gold"), + ), + ) + verify(repos, never()).insertMessages(argThat { isNotEmpty() }) + } + + private fun mockRepos(): RepositoryFacade = + mock { + onBlocking { selectChannels(any()) } doReturn emptyList() + onBlocking { selectMessages(any()) } doReturn emptyList() + onBlocking { selectThreads(any()) } doReturn emptyList() + } + + private fun createEventHandler( + scope: CoroutineScope, + logicRegistry: LogicRegistry = mock(), + repos: RepositoryFacade = mock(), + ): EventHandlerSequential { + val subscribeForEvents: (ChatEventListener) -> Disposable = { _ -> + EventHandlerSequential.EMPTY_DISPOSABLE + } + val stateRegistry: StateRegistry = mock() + val clientState: ClientState = mock { + on { user } doReturn MutableStateFlow(currentUser) + } + val mutableGlobalState = MutableGlobalState(currentUser.id) + val sideEffect: suspend () -> Unit = {} + val syncedEvents: Flow> = emptyFlow() + + return EventHandlerSequential( + currentUserId = currentUser.id, + subscribeForEvents = subscribeForEvents, + logicRegistry = logicRegistry, + stateRegistry = stateRegistry, + clientState = clientState, + mutableGlobalState = mutableGlobalState, + repos = repos, + sideEffect = sideEffect, + syncedEvents = syncedEvents, + bufferConfig = MessageBufferConfig(), + isLocalUnreadCountEnabled = false, + scope = scope, + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt index 87ef8b60780..c658cbdc795 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt @@ -45,6 +45,7 @@ import io.getstream.chat.android.client.test.randomChannelVisibleEvent import io.getstream.chat.android.client.test.randomMarkAllReadEvent import io.getstream.chat.android.client.test.randomMemberAddedEvent import io.getstream.chat.android.client.test.randomMemberRemovedEvent +import io.getstream.chat.android.client.test.randomMemberUpdatedEvent import io.getstream.chat.android.client.test.randomMessageDeliveredEvent import io.getstream.chat.android.client.test.randomMessageReadEvent import io.getstream.chat.android.client.test.randomMessageUpdateEvent @@ -739,6 +740,17 @@ internal class ChannelEventHandlerImplTest { verify(state).setMembership(member) } + @Test + fun `When MemberUpdatedEvent is handled, Then the member snapshot on the author's messages is refreshed`() { + // The backend does not emit message.updated for membership changes, so the SDK refreshes the snapshot itself + val member = randomMember() + val event = randomMemberUpdatedEvent(cid = cid, member = member) + + handler.handle(event) + + verify(state).updateMessagesMemberInfo(member) + } + @Test fun `When MemberUpdatedEvent for other user is handled, Then only member is updated`() { val member = randomMember(user = randomUser(id = randomString())) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadStateLogicTest.kt index a09c38ff6e6..f289144af53 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/thread/internal/ThreadStateLogicTest.kt @@ -17,9 +17,13 @@ package io.getstream.chat.android.client.internal.state.plugin.logic.channel.thread.internal import io.getstream.chat.android.client.internal.state.plugin.state.channel.thread.internal.ThreadMutableState +import io.getstream.chat.android.models.MemberInfo import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -30,6 +34,8 @@ import java.util.Date internal class ThreadStateLogicTest { private val parentId = randomString() + private val cid = randomCID() + private val author = randomUser() @Test fun `updateQuotedMessageReferences should update messages quoting via replyTo`() = runTest { @@ -85,6 +91,45 @@ internal class ThreadStateLogicTest { assertNull(mutableState.rawMessage.value[quotingMessage.id]?.replyTo) } + @Test + fun `updateMessagesMemberInfo should refresh the member snapshot on the author's thread replies`() = runTest { + // given - thread replies live in this state, so the channel refresh does not reach them + val (mutableState, threadStateLogic) = threadStateLogic(backgroundScope) + val reply = threadReply(user = author) + threadStateLogic.upsertMessages(listOf(reply)) + val memberInfo = MemberInfo(channelRole = "channel_moderator", extraData = mapOf("flair" to "gold")) + // when + threadStateLogic.updateMessagesMemberInfo(cid, author.id, memberInfo) + // then + assertEquals(memberInfo, mutableState.rawMessage.value[reply.id]?.member) + } + + @Test + fun `updateMessagesMemberInfo should leave replies of other authors and other channels alone`() = runTest { + // given + val (mutableState, threadStateLogic) = threadStateLogic(backgroundScope) + val otherAuthor = threadReply(user = randomUser()) + val otherChannel = threadReply(user = author, cid = randomCID()) + threadStateLogic.upsertMessages(listOf(otherAuthor, otherChannel)) + // when + threadStateLogic.updateMessagesMemberInfo(cid, author.id, MemberInfo(channelRole = "channel_moderator")) + // then + assertNull(mutableState.rawMessage.value[otherAuthor.id]?.member) + assertNull(mutableState.rawMessage.value[otherChannel.id]?.member) + } + + /** A reply that survives the thread state's deleted-message filtering. */ + private fun threadReply(user: User, cid: String = this.cid): Message = randomMessage( + cid = cid, + user = user, + parentId = parentId, + member = null, + replyTo = null, + poll = null, + deletedAt = null, + deletedForMe = false, + ) + private fun threadStateLogic(scope: CoroutineScope): Pair { val mutableState = ThreadMutableState(parentId, scope) return mutableState to ThreadStateLogic(mutableState) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMembersTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMembersTest.kt index 2c3329b9f76..6b23315a601 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMembersTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMembersTest.kt @@ -409,6 +409,65 @@ internal class ChannelStateImplMembersTest : ChannelStateImplTestBase() { } } + @Nested + inner class UpdateMessagesMemberInfo { + + @Test + fun `updateMessagesMemberInfo should refresh the member snapshot on the author's messages`() = runTest { + // given - the backend does not emit message.updated when a membership changes + val author = randomUser(id = "author") + val message = createMessage(1, user = author) + channelState.setMessages(listOf(message)) + // when + val member = randomMember(user = author, channelRole = "channel_moderator") + .copy(notificationsMuted = true, extraData = mapOf("flair" to "gold")) + channelState.updateMessagesMemberInfo(member) + // then + val updated = channelState.messages.value.first() + assertEquals("channel_moderator", updated.member?.channelRole) + assertEquals(true, updated.member?.notificationsMuted) + assertEquals(mapOf("flair" to "gold"), updated.member?.extraData) + } + + @Test + fun `updateMessagesMemberInfo should keep the deprecated channelRole in sync`() = runTest { + // given + val author = randomUser(id = "author") + channelState.setMessages(listOf(createMessage(1, user = author))) + // when + channelState.updateMessagesMemberInfo(randomMember(user = author, channelRole = "channel_moderator")) + // then + @Suppress("DEPRECATION") + assertEquals("channel_moderator", channelState.messages.value.first().channelRole) + } + + @Test + fun `updateMessagesMemberInfo should leave messages of other users untouched`() = runTest { + // given + val author = randomUser(id = "author") + val otherAuthor = randomUser(id = "other_author") + val otherMessage = createMessage(2, user = otherAuthor) + channelState.setMessages(listOf(createMessage(1, user = author), otherMessage)) + // when + channelState.updateMessagesMemberInfo(randomMember(user = author)) + // then + val untouched = channelState.messages.value.first { it.id == otherMessage.id } + assertNull(untouched.member) + } + + @Test + fun `updateMessagesMemberInfo should refresh pinned messages too`() = runTest { + // given + val author = randomUser(id = "author") + val pinnedMessage = createMessage(1, user = author, pinned = true, pinnedAt = Date()) + channelState.addPinnedMessages(listOf(pinnedMessage)) + // when + channelState.updateMessagesMemberInfo(randomMember(user = author, channelRole = "channel_moderator")) + // then + assertEquals("channel_moderator", channelState.pinnedMessages.value.first().member?.channelRole) + } + } + private fun createMember(index: Int): Member { val user = randomUser(id = "user_$index", name = "User $index") return randomMember(user = user) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplMemberInfoTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplMemberInfoTest.kt new file mode 100644 index 00000000000..e8f8e2625e5 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplMemberInfoTest.kt @@ -0,0 +1,122 @@ +/* + * 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.state.plugin.state.channel.internal + +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomMessage +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.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import java.util.Date +import kotlin.time.Duration.Companion.hours + +/** + * Tests for refreshing the member snapshot carried by messages on the legacy channel state implementation. The backend + * does not emit `message.updated` when a membership changes, so the snapshot has to be refreshed by the SDK. + */ +internal class ChannelStateLegacyImplMemberInfoTest { + + private val userFlow = MutableStateFlow(currentUser) + + @Test + fun `updateMessagesMemberInfo refreshes the member snapshot on the author's messages`() = runTest { + // given + val state = channelState() + val author = randomUser(id = "author") + state.setMessages(listOf(authoredMessage(author))) + val member = randomMember(user = author, channelRole = "channel_moderator") + .copy(notificationsMuted = true, extraData = mapOf("flair" to "gold")) + // when + state.updateMessagesMemberInfo(member) + // then + val updated = state.messages.value.first() + assertEquals("channel_moderator", updated.member?.channelRole) + assertEquals(true, updated.member?.notificationsMuted) + assertEquals(mapOf("flair" to "gold"), updated.member?.extraData) + } + + @Test + fun `updateMessagesMemberInfo leaves messages of other users untouched`() = runTest { + // given + val state = channelState() + val otherAuthor = randomUser(id = "other_author") + state.setMessages(listOf(authoredMessage(otherAuthor))) + // when + state.updateMessagesMemberInfo(randomMember(user = randomUser(id = "author"))) + // then + assertTrue(state.messages.value.all { it.member == null }) + } + + @Test + fun `updateMessagesMemberInfo refreshes pinned messages too`() = runTest { + // given + val state = channelState() + val author = randomUser(id = "author") + val pinnedMessage = authoredMessage(author).copy( + pinned = true, + pinnedAt = Date(currentTime()), + pinExpires = Date(currentTime() + 1.hours.inWholeMilliseconds), + ) + state.setPinnedMessages(listOf(pinnedMessage)) + // when + state.updateMessagesMemberInfo(randomMember(user = author, channelRole = "channel_moderator")) + // then + assertEquals("channel_moderator", state.pinnedMessages.value.first().member?.channelRole) + } + + private fun authoredMessage(author: User) = randomMessage( + cid = CID, + user = author, + parentId = null, + shadowed = false, + deletedAt = null, + deletedForMe = false, + member = null, + ) + + private fun channelState() = ChannelStateLegacyImpl( + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + userFlow = userFlow, + latestUsers = MutableStateFlow(mapOf(currentUser.id to currentUser)), + activeLiveLocations = MutableStateFlow(emptyList()), + baseMessageLimit = null, + now = ::currentTime, + ) + + private companion object { + @JvmField + @RegisterExtension + val testCoroutines = TestCoroutineExtension() + + const val CHANNEL_TYPE = "messaging" + const val CHANNEL_ID = "123" + const val CID = "messaging:123" + + val currentUser = User(id = "tom", name = "Tom") + + @OptIn(ExperimentalCoroutinesApi::class) + fun currentTime() = testCoroutines.dispatcher.scheduler.currentTime + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/DownstreamMemberInfoDtoAdapterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/DownstreamMemberInfoDtoAdapterTest.kt index 398100b15e4..68c16738651 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/DownstreamMemberInfoDtoAdapterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/DownstreamMemberInfoDtoAdapterTest.kt @@ -30,4 +30,22 @@ internal class DownstreamMemberInfoDtoAdapterTest { val member = parser.fromJson(MemberInfoDtoTestData.downstreamJson, DownstreamMemberInfoDto::class.java) member shouldBeEqualTo MemberInfoDtoTestData.downstreamMemberInfo } + + @Test + fun `Deserialize JSON member info with member custom inlined by API v1`() { + val member = parser.fromJson( + MemberInfoDtoTestData.downstreamJsonWithInlineCustom, + DownstreamMemberInfoDto::class.java, + ) + member shouldBeEqualTo MemberInfoDtoTestData.downstreamMemberInfoWithInlineCustom + } + + @Test + fun `Deserialize JSON member info with member custom nested by API v2`() { + val member = parser.fromJson( + MemberInfoDtoTestData.downstreamJsonWithNestedCustom, + DownstreamMemberInfoDto::class.java, + ) + member shouldBeEqualTo MemberInfoDtoTestData.downstreamMemberInfoWithNestedCustom + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/MessageParsingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/MessageParsingTest.kt index ab01221d2af..97939144f12 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/MessageParsingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/MessageParsingTest.kt @@ -393,5 +393,33 @@ internal class MessageParsingTest { } } + // endregion + // region Member info (message.member) + + @Test + fun `Both paths - member without projected custom keys`() { + val result = assertBothPaths(MessageTestData.jsonWithMemberWithoutCustom) + assertEquals(MessageTestData.expectedMemberWithoutCustom, result.member) + assertEquals(emptyMap(), result.member?.extraData) + } + + @Test + fun `Both paths - member custom inlined by API v1 lands on the domain field`() { + val result = assertBothPaths(MessageTestData.jsonWithMemberCustomInlined) + assertEquals(MessageTestData.expectedMemberWithCustom, result.member) + } + + @Test + fun `Both paths - member custom nested by API v2 lands on the same domain field`() { + val result = assertBothPaths(MessageTestData.jsonWithMemberCustomNested) + assertEquals(MessageTestData.expectedMemberWithCustom, result.member) + } + + @Test + fun `Both paths - member absent yields a null member`() { + val result = assertBothPaths(MessageTestData.jsonOptionalFieldsMissing) + assertEquals(null, result.member) + } + // endregion } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/MemberInfoDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/MemberInfoDtoTestData.kt index a59598ac45b..71ee8f76f04 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/MemberInfoDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/MemberInfoDtoTestData.kt @@ -29,4 +29,36 @@ internal object MemberInfoDtoTestData { """.trimIndent() val downstreamMemberInfo = DownstreamMemberInfoDto(channel_role = "channel_member") + + /** API v1 inlines the projected member custom keys next to the declared fields. */ + @Language("JSON") + val downstreamJsonWithInlineCustom: String = """ + { + "channel_role": "channel_member", + "notifications_muted": true, + "flair": { "tier": "gold" } + } + """.trimIndent() + + val downstreamMemberInfoWithInlineCustom = DownstreamMemberInfoDto( + channel_role = "channel_member", + notifications_muted = true, + extraData = mapOf("flair" to mapOf("tier" to "gold")), + ) + + /** API v2 nests the same keys under `custom`. */ + @Language("JSON") + val downstreamJsonWithNestedCustom: String = """ + { + "channel_role": "channel_member", + "notifications_muted": true, + "custom": { "flair": { "tier": "gold" } } + } + """.trimIndent() + + val downstreamMemberInfoWithNestedCustom = DownstreamMemberInfoDto( + channel_role = "channel_member", + notifications_muted = true, + custom = mapOf("flair" to mapOf("tier" to "gold")), + ) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/MessageTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/MessageTestData.kt index 265e7170722..e28e21ff41c 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/MessageTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/MessageTestData.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.parser2.testdata import io.getstream.chat.android.models.ChannelInfo +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 @@ -1121,5 +1122,67 @@ internal object MessageTestData { "mentioned_roles": null }""" + // endregion + // region Member info (message.member) + + private const val BASE_FIELDS = """ + "id": "msg-1", + "cid": "messaging:general", + "text": "Hello world", + "html": "

Hello world

", + "type": "regular", + "user": {"id": "user-1", "role": "user", "banned": false, "online": true}, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "mentioned_users": [], + "reply_count": 0, + "deleted_reply_count": 0, + "created_at": "2020-01-01T00:00:00.000Z", + "updated_at": "2020-01-01T00:00:00.000Z", + "silent": false + """ + + /** Baseline shape: the member object without any projected custom keys. */ + @Language("JSON") + val jsonWithMemberWithoutCustom = """{ + $BASE_FIELDS, + "member": {"channel_role": "channel_member", "notifications_muted": false} + }""" + + /** API v1 inlines the projected member custom keys next to `channel_role`. */ + @Language("JSON") + val jsonWithMemberCustomInlined = """{ + $BASE_FIELDS, + "member": { + "channel_role": "channel_moderator", + "notifications_muted": true, + "flair": {"tier": "gold"} + } + }""" + + /** API v2 nests the same keys under `custom`. */ + @Language("JSON") + val jsonWithMemberCustomNested = """{ + $BASE_FIELDS, + "member": { + "channel_role": "channel_moderator", + "notifications_muted": true, + "custom": {"flair": {"tier": "gold"}} + } + }""" + + val expectedMemberWithoutCustom = MemberInfo( + channelRole = "channel_member", + notificationsMuted = false, + extraData = emptyMap(), + ) + + val expectedMemberWithCustom = MemberInfo( + channelRole = "channel_moderator", + notificationsMuted = true, + extraData = mapOf("flair" to mapOf("tier" to "gold")), + ) + // endregion } 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 710bd3ce159..ae1fbffc13b 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1359,14 +1359,32 @@ public final class io/getstream/chat/android/models/MemberData : io/getstream/ch public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/models/MemberInfo : io/getstream/chat/android/models/CustomObject { + public fun ()V + public fun (Ljava/lang/String;ZLjava/util/Map;)V + public synthetic fun (Ljava/lang/String;ZLjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Z + public final fun component3 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;ZLjava/util/Map;)Lio/getstream/chat/android/models/MemberInfo; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/MemberInfo;Ljava/lang/String;ZLjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/models/MemberInfo; + public fun equals (Ljava/lang/Object;)Z + public final fun getChannelRole ()Ljava/lang/String; + public fun getExtraData ()Ljava/util/Map; + public fun getExtraValue (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; + public final fun getNotificationsMuted ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/models/Message : io/getstream/chat/android/models/CustomObject, io/getstream/chat/android/models/querysort/ComparableFieldProvider { public static final field Companion Lio/getstream/chat/android/models/Message$Companion; public static final field TYPE_EPHEMERAL Ljava/lang/String; public static final field TYPE_ERROR Ljava/lang/String; public static final field TYPE_REGULAR Ljava/lang/String; public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;IILjava/util/Map;Ljava/util/Map;Ljava/util/Map;Lio/getstream/chat/android/models/SyncStatus;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/Map;ZZLjava/util/Map;ZLio/getstream/chat/android/models/ChannelInfo;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZLjava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/models/MessageModerationDetails;Lio/getstream/chat/android/models/Moderation;Ljava/util/Date;Lio/getstream/chat/android/models/Poll;Ljava/util/List;Lio/getstream/chat/android/models/MessageReminderInfo;Lio/getstream/chat/android/models/Location;Ljava/lang/String;ZZZLjava/util/List;Ljava/util/List;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;IILjava/util/Map;Ljava/util/Map;Ljava/util/Map;Lio/getstream/chat/android/models/SyncStatus;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/Map;ZZLjava/util/Map;ZLio/getstream/chat/android/models/ChannelInfo;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZLjava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/models/MessageModerationDetails;Lio/getstream/chat/android/models/Moderation;Ljava/util/Date;Lio/getstream/chat/android/models/Poll;Ljava/util/List;Lio/getstream/chat/android/models/MessageReminderInfo;Lio/getstream/chat/android/models/Location;Ljava/lang/String;ZZZLjava/util/List;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;IILjava/util/Map;Ljava/util/Map;Ljava/util/Map;Lio/getstream/chat/android/models/SyncStatus;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/Map;ZZLjava/util/Map;ZLio/getstream/chat/android/models/ChannelInfo;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZLjava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/models/MessageModerationDetails;Lio/getstream/chat/android/models/Moderation;Ljava/util/Date;Lio/getstream/chat/android/models/Poll;Ljava/util/List;Lio/getstream/chat/android/models/MessageReminderInfo;Lio/getstream/chat/android/models/Location;Ljava/lang/String;Lio/getstream/chat/android/models/MemberInfo;ZZZLjava/util/List;Ljava/util/List;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;IILjava/util/Map;Ljava/util/Map;Ljava/util/Map;Lio/getstream/chat/android/models/SyncStatus;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/Map;ZZLjava/util/Map;ZLio/getstream/chat/android/models/ChannelInfo;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZLjava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/models/MessageModerationDetails;Lio/getstream/chat/android/models/Moderation;Ljava/util/Date;Lio/getstream/chat/android/models/Poll;Ljava/util/List;Lio/getstream/chat/android/models/MessageReminderInfo;Lio/getstream/chat/android/models/Location;Ljava/lang/String;Lio/getstream/chat/android/models/MemberInfo;ZZZLjava/util/List;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I public final fun component11 ()I @@ -1409,18 +1427,19 @@ public final class io/getstream/chat/android/models/Message : io/getstream/chat/ public final fun component45 ()Lio/getstream/chat/android/models/MessageReminderInfo; public final fun component46 ()Lio/getstream/chat/android/models/Location; public final fun component47 ()Ljava/lang/String; - public final fun component48 ()Z + public final fun component48 ()Lio/getstream/chat/android/models/MemberInfo; public final fun component49 ()Z public final fun component5 ()Ljava/lang/String; public final fun component50 ()Z - public final fun component51 ()Ljava/util/List; + public final fun component51 ()Z public final fun component52 ()Ljava/util/List; + public final fun component53 ()Ljava/util/List; public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/util/List; public final fun component8 ()Ljava/util/List; public final fun component9 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;IILjava/util/Map;Ljava/util/Map;Ljava/util/Map;Lio/getstream/chat/android/models/SyncStatus;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/Map;ZZLjava/util/Map;ZLio/getstream/chat/android/models/ChannelInfo;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZLjava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/models/MessageModerationDetails;Lio/getstream/chat/android/models/Moderation;Ljava/util/Date;Lio/getstream/chat/android/models/Poll;Ljava/util/List;Lio/getstream/chat/android/models/MessageReminderInfo;Lio/getstream/chat/android/models/Location;Ljava/lang/String;ZZZLjava/util/List;Ljava/util/List;)Lio/getstream/chat/android/models/Message; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/Message;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;IILjava/util/Map;Ljava/util/Map;Ljava/util/Map;Lio/getstream/chat/android/models/SyncStatus;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/Map;ZZLjava/util/Map;ZLio/getstream/chat/android/models/ChannelInfo;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZLjava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/models/MessageModerationDetails;Lio/getstream/chat/android/models/Moderation;Ljava/util/Date;Lio/getstream/chat/android/models/Poll;Ljava/util/List;Lio/getstream/chat/android/models/MessageReminderInfo;Lio/getstream/chat/android/models/Location;Ljava/lang/String;ZZZLjava/util/List;Ljava/util/List;IILjava/lang/Object;)Lio/getstream/chat/android/models/Message; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;IILjava/util/Map;Ljava/util/Map;Ljava/util/Map;Lio/getstream/chat/android/models/SyncStatus;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/Map;ZZLjava/util/Map;ZLio/getstream/chat/android/models/ChannelInfo;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZLjava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/models/MessageModerationDetails;Lio/getstream/chat/android/models/Moderation;Ljava/util/Date;Lio/getstream/chat/android/models/Poll;Ljava/util/List;Lio/getstream/chat/android/models/MessageReminderInfo;Lio/getstream/chat/android/models/Location;Ljava/lang/String;Lio/getstream/chat/android/models/MemberInfo;ZZZLjava/util/List;Ljava/util/List;)Lio/getstream/chat/android/models/Message; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/Message;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;IILjava/util/Map;Ljava/util/Map;Ljava/util/Map;Lio/getstream/chat/android/models/SyncStatus;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/Map;ZZLjava/util/Map;ZLio/getstream/chat/android/models/ChannelInfo;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZLjava/util/Date;Ljava/util/Date;Lio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/models/MessageModerationDetails;Lio/getstream/chat/android/models/Moderation;Ljava/util/Date;Lio/getstream/chat/android/models/Poll;Ljava/util/List;Lio/getstream/chat/android/models/MessageReminderInfo;Lio/getstream/chat/android/models/Location;Ljava/lang/String;Lio/getstream/chat/android/models/MemberInfo;ZZZLjava/util/List;Ljava/util/List;IILjava/lang/Object;)Lio/getstream/chat/android/models/Message; public fun equals (Ljava/lang/Object;)Z public final fun getAttachments ()Ljava/util/List; public final fun getChannelInfo ()Lio/getstream/chat/android/models/ChannelInfo; @@ -1439,6 +1458,7 @@ public final class io/getstream/chat/android/models/Message : io/getstream/chat/ public final fun getI18n ()Ljava/util/Map; public final fun getId ()Ljava/lang/String; public final fun getLatestReactions ()Ljava/util/List; + public final fun getMember ()Lio/getstream/chat/android/models/MemberInfo; public final fun getMentionedChannel ()Z public final fun getMentionedGroups ()Ljava/util/List; public final fun getMentionedHere ()Z @@ -1504,6 +1524,7 @@ public final class io/getstream/chat/android/models/Message$Builder { public final fun withI18n (Ljava/util/Map;)Lio/getstream/chat/android/models/Message$Builder; public final fun withId (Ljava/lang/String;)Lio/getstream/chat/android/models/Message$Builder; public final fun withLatestReactions (Ljava/util/List;)Lio/getstream/chat/android/models/Message$Builder; + public final fun withMember (Lio/getstream/chat/android/models/MemberInfo;)Lio/getstream/chat/android/models/Message$Builder; public final fun withMentionedChannel (Z)Lio/getstream/chat/android/models/Message$Builder; public final fun withMentionedGroups (Ljava/util/List;)Lio/getstream/chat/android/models/Message$Builder; public final fun withMentionedHere (Z)Lio/getstream/chat/android/models/Message$Builder; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MemberInfo.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MemberInfo.kt new file mode 100644 index 00000000000..0c1f767395b --- /dev/null +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MemberInfo.kt @@ -0,0 +1,44 @@ +/* + * 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.models + +import androidx.compose.runtime.Immutable + +/** + * Model holding limited data about a channel member, as attached to a [Message] by [Message.member]. + * + * This is not a full [Member]: there is no user, no timestamps and no invite or ban state. + */ +@Immutable +public data class MemberInfo( + /** + * The channel-level role of the member. + */ + val channelRole: String? = null, + + /** + * If notifications are muted for the member in the channel. + */ + val notificationsMuted: Boolean = false, + + /** + * A map of custom fields for the member. + * + * Only populated when the app has member custom data on messages enabled. + */ + override val extraData: Map = emptyMap(), +) : CustomObject diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Message.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Message.kt index fbb96a94d02..5e7162c9f82 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Message.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Message.kt @@ -267,8 +267,18 @@ public data class Message( /** * The role of the member(who sent the message) in the channel. */ + @Deprecated( + message = "Use member?.channelRole instead.", + replaceWith = ReplaceWith("member?.channelRole"), + level = DeprecationLevel.WARNING, + ) val channelRole: String? = null, + /** + * Data about the channel membership of the user who sent the message. + */ + val member: MemberInfo? = null, + /** * Whether the message was deleted for the current user. */ @@ -412,7 +422,7 @@ public data class Message( if (moderationDetails != null) append(", moderationDetails=").append(moderationDetails) if (moderation != null) append(", moderation=").append(moderation) if (poll != null) append(", poll=").append(poll) - if (channelRole != null) append(", channelRole=").append(channelRole) + if (member != null) append(", member=").append(member) append(", deletedForMe=").append(deletedForMe) if (mentionedHere) append(", mentionedHere=true") if (mentionedChannel) append(", mentionedChannel=true") @@ -475,12 +485,14 @@ public data class Message( private var reminder: MessageReminderInfo? = null private var sharedLocation: Location? = null private var channelRole: String? = null + private var member: MemberInfo? = null private var deletedForMe: Boolean = false private var mentionedHere: Boolean = false private var mentionedChannel: Boolean = false private var mentionedGroups: List = emptyList() private var mentionedRoles: List = emptyList() + @Suppress("DEPRECATION") public constructor(message: Message) : this() { id = message.id cid = message.cid @@ -529,6 +541,7 @@ public data class Message( reminder = message.reminder sharedLocation = message.sharedLocation channelRole = message.channelRole + member = message.member deletedForMe = message.deletedForMe mentionedHere = message.mentionedHere mentionedChannel = message.mentionedChannel @@ -614,7 +627,14 @@ public data class Message( public fun withSharedLocation(sharedLocation: Location?): Builder = apply { this.sharedLocation = sharedLocation } + + @Deprecated( + message = "Use withMember instead.", + replaceWith = ReplaceWith("withMember(MemberInfo(channelRole = channelRole))"), + level = DeprecationLevel.WARNING, + ) public fun withChannelRole(channelRole: String?): Builder = apply { this.channelRole = channelRole } + public fun withMember(member: MemberInfo?): Builder = apply { this.member = member } public fun withDeletedForMe(deletedForMe: Boolean): Builder = apply { this.deletedForMe = deletedForMe } public fun withMentionedHere(mentionedHere: Boolean): Builder = apply { this.mentionedHere = mentionedHere } public fun withMentionedChannel(mentionedChannel: Boolean): Builder = apply { @@ -627,7 +647,9 @@ public data class Message( this.mentionedRoles = mentionedRoles } + @Suppress("DEPRECATION") public fun build(): Message { + val resolvedMember = member ?: channelRole?.let { MemberInfo(channelRole = it) } return Message( id = id, cid = cid, @@ -675,7 +697,8 @@ public data class Message( poll = poll, reminder = reminder, sharedLocation = sharedLocation, - channelRole = channelRole, + channelRole = resolvedMember?.channelRole, + member = resolvedMember, deletedForMe = deletedForMe, mentionedHere = mentionedHere, mentionedChannel = mentionedChannel, diff --git a/stream-chat-android-core/src/test/java/io/getstream/chat/android/models/MessageTest.kt b/stream-chat-android-core/src/test/java/io/getstream/chat/android/models/MessageTest.kt index bd241145320..5921e1c723d 100644 --- a/stream-chat-android-core/src/test/java/io/getstream/chat/android/models/MessageTest.kt +++ b/stream-chat-android-core/src/test/java/io/getstream/chat/android/models/MessageTest.kt @@ -23,6 +23,7 @@ import io.getstream.chat.android.randomChannelInfo import io.getstream.chat.android.randomDate import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomLocation +import io.getstream.chat.android.randomMemberInfo import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomMessageModerationDetails import io.getstream.chat.android.randomMessageReminderInfo @@ -42,6 +43,29 @@ import org.junit.jupiter.api.Test internal class MessageTest { + /** `Message` keeps `member` and the deprecated `channelRole` in sync, so fixtures must agree on both. */ + private val consistentMember = randomMemberInfo() + + @Test + @Suppress("DEPRECATION") + fun `builder should promote the deprecated channelRole into a member`() { + val built = Message.Builder().withChannelRole("channel_moderator").build() + + assertEquals(MemberInfo(channelRole = "channel_moderator"), built.member) + assertEquals("channel_moderator", built.channelRole) + } + + @Test + @Suppress("DEPRECATION") + fun `builder should let an explicit member win over the deprecated channelRole`() { + val member = MemberInfo(channelRole = "channel_member", extraData = mapOf("flair" to "gold")) + + val built = Message.Builder().withChannelRole("channel_moderator").withMember(member).build() + + assertEquals(member, built.member) + assertEquals("channel_member", built.channelRole) + } + @Test @Suppress("LongMethod") fun `builder should set every field`() { @@ -92,7 +116,8 @@ internal class MessageTest { restrictedVisibility = listOf(randomString()), reminder = randomMessageReminderInfo(), sharedLocation = randomLocation(), - channelRole = randomString(), + channelRole = consistentMember.channelRole, + member = consistentMember, deletedForMe = randomBoolean(), mentionedHere = randomBoolean(), mentionedChannel = randomBoolean(), @@ -148,6 +173,7 @@ internal class MessageTest { .withReminder(expected.reminder) .withSharedLocation(expected.sharedLocation) .withChannelRole(expected.channelRole) + .withMember(expected.member) .withDeletedForMe(expected.deletedForMe) .withMentionedHere(expected.mentionedHere) .withMentionedChannel(expected.mentionedChannel) @@ -165,7 +191,8 @@ internal class MessageTest { poll = randomPoll(), moderationDetails = randomMessageModerationDetails(), moderation = randomModeration(), - channelRole = randomString(), + channelRole = consistentMember.channelRole, + member = consistentMember, threadParticipants = listOf(randomUser()), mentionedGroups = listOf(randomUserGroup()), mentionedRoles = listOf(randomString()), 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 3ad6a85b182..d534e58a6fb 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 @@ -39,6 +39,7 @@ 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.MemberData +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 @@ -387,6 +388,7 @@ public fun randomMessage( i18n: Map = emptyMap(), reminder: MessageReminderInfo? = randomMessageReminderInfo(), channelRole: String? = null, + member: MemberInfo? = null, deletedForMe: Boolean = randomBoolean(), mentionedHere: Boolean = randomBoolean(), mentionedChannel: Boolean = randomBoolean(), @@ -438,6 +440,7 @@ public fun randomMessage( i18n = i18n, reminder = reminder, channelRole = channelRole, + member = member, deletedForMe = deletedForMe, mentionedHere = mentionedHere, mentionedChannel = mentionedChannel, @@ -733,6 +736,16 @@ public fun randomMember( archivedAt = archivedAt, ) +public fun randomMemberInfo( + channelRole: String? = randomString(), + notificationsMuted: Boolean = randomBoolean(), + extraData: Map = randomExtraData(), +): MemberInfo = MemberInfo( + channelRole = channelRole, + notificationsMuted = notificationsMuted, + extraData = extraData, +) + public fun randomMemberData( userId: String = randomString(), extraData: Map = randomExtraData(),