diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index fdf90f2a93f..c9a1a58cc18 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -35,9 +35,6 @@ import io.getstream.chat.android.client.api2.model.dto.MessageDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.MessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationAddedToChannelEventDto -import io.getstream.chat.android.client.api2.model.dto.ReactionDeletedEventDto -import io.getstream.chat.android.client.api2.model.dto.ReactionNewEventDto -import io.getstream.chat.android.client.api2.model.dto.ReactionUpdateEventDto import io.getstream.chat.android.client.api2.model.dto.UnknownEventDto import io.getstream.chat.android.client.events.AIIndicatorClearEvent import io.getstream.chat.android.client.events.AIIndicatorStopEvent @@ -144,6 +141,9 @@ import io.getstream.chat.android.network.models.PollUpdatedEvent as GeneratedPol import io.getstream.chat.android.network.models.PollVoteCastedEvent as GeneratedPollVoteCastedEvent import io.getstream.chat.android.network.models.PollVoteChangedEvent as GeneratedPollVoteChangedEvent import io.getstream.chat.android.network.models.PollVoteRemovedEvent as GeneratedPollVoteRemovedEvent +import io.getstream.chat.android.network.models.ReactionDeletedEvent as GeneratedReactionDeletedEvent +import io.getstream.chat.android.network.models.ReactionNewEvent as GeneratedReactionNewEvent +import io.getstream.chat.android.network.models.ReactionUpdatedEvent as GeneratedReactionUpdatedEvent import io.getstream.chat.android.network.models.ReminderCreatedEvent as GeneratedReminderCreatedEvent import io.getstream.chat.android.network.models.ReminderDeletedEvent as GeneratedReminderDeletedEvent import io.getstream.chat.android.network.models.ReminderNotificationEvent as GeneratedReminderNotificationEvent @@ -186,9 +186,6 @@ internal class EventMapping( is MessageDeletedEventDto -> toDomain() is MessageUpdatedEventDto -> toDomain() is NotificationAddedToChannelEventDto -> toDomain() - is ReactionDeletedEventDto -> toDomain() - is ReactionNewEventDto -> toDomain() - is ReactionUpdateEventDto -> toDomain() is UnknownEventDto -> toDomain() is GeneratedEventDto -> event.toDomain() is AIIndicatorUpdatedEventDto -> toDomain() @@ -425,57 +422,6 @@ internal class EventMapping( ) } - /** - * Transforms [ReactionDeletedEventDto] to [ReactionDeletedEvent]. - */ - private fun ReactionDeletedEventDto.toDomain(): ReactionDeletedEvent = with(domainMapping) { - ReactionDeletedEvent( - type = type, - createdAt = created_at.date, - rawCreatedAt = created_at.rawDate, - user = user.toDomain(), - cid = cid, - channelType = channel_type, - channelId = channel_id, - message = message.toDomain(), - reaction = reaction.toDomain(), - ) - } - - /** - * Transforms [ReactionNewEventDto] to [ReactionNewEvent]. - */ - private fun ReactionNewEventDto.toDomain(): ReactionNewEvent = with(domainMapping) { - ReactionNewEvent( - type = type, - createdAt = created_at.date, - rawCreatedAt = created_at.rawDate, - user = user.toDomain(), - cid = cid, - channelType = channel_type, - channelId = channel_id, - message = message.toDomain(), - reaction = reaction.toDomain(), - ) - } - - /** - * Transforms [ReactionUpdateEventDto] to [ReactionUpdateEvent]. - */ - private fun ReactionUpdateEventDto.toDomain(): ReactionUpdateEvent = with(domainMapping) { - ReactionUpdateEvent( - type = type, - createdAt = created_at.date, - rawCreatedAt = created_at.rawDate, - user = user.toDomain(), - cid = cid, - channelType = channel_type, - channelId = channel_id, - message = message.toDomain(), - reaction = reaction.toDomain(), - ) - } - /** * Transforms the generated [GeneratedUserDeletedEvent] to [UserDeletedEvent]. */ @@ -548,6 +494,9 @@ internal class EventMapping( is GeneratedUserMessagesDeletedEvent -> toDomain() is GeneratedDraftUpdatedEvent -> toDomain() is GeneratedDraftDeletedEvent -> toDomain() + is GeneratedReactionDeletedEvent -> toDomain() + is GeneratedReactionNewEvent -> toDomain() + is GeneratedReactionUpdatedEvent -> toDomain() else -> error("No mapping for the generated ${getWSEventType()} event") } @@ -709,6 +658,63 @@ internal class EventMapping( ) } + /** + * Transforms the generated [GeneratedReactionDeletedEvent] to [ReactionDeletedEvent]. + */ + private fun GeneratedReactionDeletedEvent.toDomain(): ReactionDeletedEvent = with(domainMapping) { + val cid = requireNotNull(cid) + val (channelType, channelId) = cid.cidToTypeAndId() + return ReactionDeletedEvent( + type = type, + createdAt = createdAt.date, + rawCreatedAt = createdAt.raw, + user = requireNotNull(user).toDomain(), + cid = cid, + channelType = channelType, + channelId = channelId, + message = requireNotNull(message).toDomain(), + reaction = requireNotNull(reaction).toDomain(), + ) + } + + /** + * Transforms the generated [GeneratedReactionNewEvent] to [ReactionNewEvent]. + */ + private fun GeneratedReactionNewEvent.toDomain(): ReactionNewEvent = with(domainMapping) { + val cid = requireNotNull(cid) + val (channelType, channelId) = cid.cidToTypeAndId() + return ReactionNewEvent( + type = type, + createdAt = createdAt.date, + rawCreatedAt = createdAt.raw, + user = requireNotNull(user).toDomain(), + cid = cid, + channelType = channelType, + channelId = channelId, + message = requireNotNull(message).toDomain(), + reaction = requireNotNull(reaction).toDomain(), + ) + } + + /** + * Transforms the generated [GeneratedReactionUpdatedEvent] to [ReactionUpdateEvent]. + */ + private fun GeneratedReactionUpdatedEvent.toDomain(): ReactionUpdateEvent = with(domainMapping) { + val cid = requireNotNull(cid) + val (channelType, channelId) = cid.cidToTypeAndId() + return ReactionUpdateEvent( + type = type, + createdAt = createdAt.date, + rawCreatedAt = createdAt.raw, + user = requireNotNull(user).toDomain(), + cid = cid, + channelType = channelType, + channelId = channelId, + message = message.toDomain(), + reaction = requireNotNull(reaction).toDomain(), + ) + } + /** * Transforms the generated [GeneratedTypingStartEvent] to [TypingStartEvent]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index 44721c835bb..b7f35d65f32 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -21,7 +21,6 @@ import io.getstream.chat.android.client.api2.model.dto.utils.internal.ExactDate import io.getstream.chat.android.network.models.ChannelMemberResponse import io.getstream.chat.android.network.models.ChannelResponse import io.getstream.chat.android.network.models.OwnUserResponse -import io.getstream.chat.android.network.models.ReactionResponse import io.getstream.chat.android.network.models.UserResponseCommonFields import io.getstream.chat.android.network.models.WSEvent import io.getstream.result.Error @@ -117,42 +116,6 @@ internal data class NotificationAddedToChannelEventDto( val unread_channels: Int = 0, ) : ChatEventDto() -@JsonClass(generateAdapter = true) -internal data class ReactionDeletedEventDto( - val type: String, - val created_at: ExactDate, - val user: DownstreamUserDto, - val cid: String, - val channel_type: String, - val channel_id: String, - val message: DownstreamMessageDto, - val reaction: ReactionResponse, -) : ChatEventDto() - -@JsonClass(generateAdapter = true) -internal data class ReactionNewEventDto( - val type: String, - val created_at: ExactDate, - val user: DownstreamUserDto, - val cid: String, - val channel_type: String, - val channel_id: String, - val message: DownstreamMessageDto, - val reaction: ReactionResponse, -) : ChatEventDto() - -@JsonClass(generateAdapter = true) -internal data class ReactionUpdateEventDto( - val type: String, - val created_at: ExactDate, - val user: DownstreamUserDto, - val cid: String, - val channel_type: String, - val channel_id: String, - val message: DownstreamMessageDto, - val reaction: ReactionResponse, -) : ChatEventDto() - /** * An event parsed with its generated model, which the event mapping turns into the domain event. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt index 1f93559572a..9f59eea67dc 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt @@ -38,9 +38,6 @@ import io.getstream.chat.android.client.api2.model.dto.MessageDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.MessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationAddedToChannelEventDto -import io.getstream.chat.android.client.api2.model.dto.ReactionDeletedEventDto -import io.getstream.chat.android.client.api2.model.dto.ReactionNewEventDto -import io.getstream.chat.android.client.api2.model.dto.ReactionUpdateEventDto import io.getstream.chat.android.client.api2.model.dto.UnknownEventDto import io.getstream.chat.android.client.api2.model.dto.utils.internal.ExactDate import io.getstream.chat.android.models.EventType @@ -72,6 +69,9 @@ import io.getstream.chat.android.network.models.PollUpdatedEvent import io.getstream.chat.android.network.models.PollVoteCastedEvent import io.getstream.chat.android.network.models.PollVoteChangedEvent import io.getstream.chat.android.network.models.PollVoteRemovedEvent +import io.getstream.chat.android.network.models.ReactionDeletedEvent +import io.getstream.chat.android.network.models.ReactionNewEvent +import io.getstream.chat.android.network.models.ReactionUpdatedEvent import io.getstream.chat.android.network.models.ReminderCreatedEvent import io.getstream.chat.android.network.models.ReminderDeletedEvent import io.getstream.chat.android.network.models.ReminderNotificationEvent @@ -133,9 +133,15 @@ internal class EventDtoAdapter( generatedEventAdapter { mapOf("cid" to cid, "user" to user) } private val typingStopEventAdapter = generatedEventAdapter { mapOf("cid" to cid, "user" to user) } - private val reactionNewEventAdapter = moshi.adapter(ReactionNewEventDto::class.java) - private val reactionUpdateEventAdapter = moshi.adapter(ReactionUpdateEventDto::class.java) - private val reactionDeletedEventAdapter = moshi.adapter(ReactionDeletedEventDto::class.java) + private val reactionNewEventAdapter = generatedEventAdapter { + mapOf("cid" to cid, "user" to user, "message" to message, "reaction" to reaction) + } + private val reactionUpdatedEventAdapter = generatedEventAdapter { + mapOf("cid" to cid, "user" to user, "reaction" to reaction) + } + private val reactionDeletedEventAdapter = generatedEventAdapter { + mapOf("cid" to cid, "user" to user, "message" to message, "reaction" to reaction) + } private val memberAddedEventAdapter = generatedEventAdapter { mapOf("cid" to cid, "user" to user) } private val memberRemovedEventAdapter = @@ -236,7 +242,7 @@ internal class EventDtoAdapter( EventType.TYPING_START -> typingStartEventAdapter EventType.TYPING_STOP -> typingStopEventAdapter EventType.REACTION_NEW -> reactionNewEventAdapter - EventType.REACTION_UPDATED -> reactionUpdateEventAdapter + EventType.REACTION_UPDATED -> reactionUpdatedEventAdapter EventType.REACTION_DELETED -> reactionDeletedEventAdapter EventType.MEMBER_ADDED -> memberAddedEventAdapter EventType.MEMBER_REMOVED -> memberRemovedEventAdapter diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ReactionDeletedEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ReactionDeletedEvent.kt new file mode 100644 index 00000000000..5ea7a1ed410 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ReactionDeletedEvent.kt @@ -0,0 +1,96 @@ +/* + * 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. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * Emitted when a reaction is deleted from a message. + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class ReactionDeletedEvent( + @Json(name = "created_at") + internal val createdAt: io.getstream.chat.android.network.infrastructure.ExactDate, + + // Patched: the spec marks `channel` required, but backends before v239.47.0 (CHA-3482, chat#17545) replay reaction + // events on /sync without it, and a parse failure fails the whole /sync response. On regen, make it required again + // only once every region runs v239.47.0 or later. + @Json(name = "channel") + internal val channel: io.getstream.chat.android.network.models.ChannelResponse? = null, + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "type") + internal val type: String = "reaction.deleted", + + @Json(name = "channel_id") + internal val channelId: String? = null, + + @Json(name = "channel_member_count") + internal val channelMemberCount: Int? = null, + + @Json(name = "channel_message_count") + internal val channelMessageCount: Int? = null, + + @Json(name = "channel_type") + internal val channelType: String? = null, + + @Json(name = "cid") + internal val cid: String? = null, + + @Json(name = "message_id") + internal val messageId: String? = null, + + @Json(name = "received_at") + internal val receivedAt: java.util.Date? = null, + + @Json(name = "team") + internal val team: String? = null, + + @Json(name = "thread_participants") + internal val threadParticipants: List? = null, + + @Json(name = "channel_custom") + internal val channelCustom: Map? = null, + + @Json(name = "message") + internal val message: io.getstream.chat.android.network.models.MessageResponse? = null, + + @Json(name = "reaction") + internal val reaction: io.getstream.chat.android.network.models.ReactionResponse? = null, + + @Json(name = "user") + internal val user: io.getstream.chat.android.network.models.UserResponseCommonFields? = null, +) : + io.getstream.chat.android.network.models.WSClientEvent, io.getstream.chat.android.network.models.WSEvent { + + override fun getWSClientEventType(): String { + return type + } + + override fun getWSEventType(): String { + return type + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ReactionNewEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ReactionNewEvent.kt new file mode 100644 index 00000000000..fc4fdd854df --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ReactionNewEvent.kt @@ -0,0 +1,96 @@ +/* + * 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. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * Emitted when a new reaction is added to a message. + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class ReactionNewEvent( + @Json(name = "created_at") + internal val createdAt: io.getstream.chat.android.network.infrastructure.ExactDate, + + // Patched: the spec marks `channel` required, but backends before v239.47.0 (CHA-3482, chat#17545) replay reaction + // events on /sync without it, and a parse failure fails the whole /sync response. On regen, make it required again + // only once every region runs v239.47.0 or later. + @Json(name = "channel") + internal val channel: io.getstream.chat.android.network.models.ChannelResponse? = null, + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "type") + internal val type: String = "reaction.new", + + @Json(name = "channel_id") + internal val channelId: String? = null, + + @Json(name = "channel_member_count") + internal val channelMemberCount: Int? = null, + + @Json(name = "channel_message_count") + internal val channelMessageCount: Int? = null, + + @Json(name = "channel_type") + internal val channelType: String? = null, + + @Json(name = "cid") + internal val cid: String? = null, + + @Json(name = "message_id") + internal val messageId: String? = null, + + @Json(name = "received_at") + internal val receivedAt: java.util.Date? = null, + + @Json(name = "team") + internal val team: String? = null, + + @Json(name = "thread_participants") + internal val threadParticipants: List? = null, + + @Json(name = "channel_custom") + internal val channelCustom: Map? = null, + + @Json(name = "message") + internal val message: io.getstream.chat.android.network.models.MessageResponse? = null, + + @Json(name = "reaction") + internal val reaction: io.getstream.chat.android.network.models.ReactionResponse? = null, + + @Json(name = "user") + internal val user: io.getstream.chat.android.network.models.UserResponseCommonFields? = null, +) : + io.getstream.chat.android.network.models.WSClientEvent, io.getstream.chat.android.network.models.WSEvent { + + override fun getWSClientEventType(): String { + return type + } + + override fun getWSEventType(): String { + return type + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ReactionUpdatedEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ReactionUpdatedEvent.kt new file mode 100644 index 00000000000..fabb992bc4f --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ReactionUpdatedEvent.kt @@ -0,0 +1,93 @@ +/* + * 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. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * Emitted when a reaction is updated on a message. + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class ReactionUpdatedEvent( + @Json(name = "created_at") + internal val createdAt: io.getstream.chat.android.network.infrastructure.ExactDate, + + // Patched: the spec marks `message_id` and `channel` required, but backends before v239.47.0 (CHA-3482, + // chat#17545) replay reaction events on /sync without them, and a parse failure fails the whole /sync response. + // On regen, make them required again only once every region runs v239.47.0 or later. + @Json(name = "message_id") + internal val messageId: String? = null, + + @Json(name = "channel") + internal val channel: io.getstream.chat.android.network.models.ChannelResponse? = null, + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "message") + internal val message: io.getstream.chat.android.network.models.MessageResponse, + + @Json(name = "type") + internal val type: String = "reaction.updated", + + @Json(name = "channel_id") + internal val channelId: String? = null, + + @Json(name = "channel_member_count") + internal val channelMemberCount: Int? = null, + + @Json(name = "channel_message_count") + internal val channelMessageCount: Int? = null, + + @Json(name = "channel_type") + internal val channelType: String? = null, + + @Json(name = "cid") + internal val cid: String? = null, + + @Json(name = "received_at") + internal val receivedAt: java.util.Date? = null, + + @Json(name = "team") + internal val team: String? = null, + + @Json(name = "channel_custom") + internal val channelCustom: Map? = null, + + @Json(name = "reaction") + internal val reaction: io.getstream.chat.android.network.models.ReactionResponse? = null, + + @Json(name = "user") + internal val user: io.getstream.chat.android.network.models.UserResponseCommonFields? = null, +) : + io.getstream.chat.android.network.models.WSClientEvent, io.getstream.chat.android.network.models.WSEvent { + + override fun getWSClientEventType(): String { + return type + } + + override fun getWSEventType(): String { + return type + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt index 366984a9d93..1bb0d4c823a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt @@ -393,11 +393,13 @@ internal fun createReactionDeletedEventStringJson() = createChatEventStringJson( "reaction.deleted", """ - "user": ${createUserJsonString()}, + "user": ${createCommonFieldsUserJsonString()}, "channel_type": "channelType", "channel_id": "channelId", "cid": "channelType:channelId", - "message": ${createMessageJsonString()}, + "channel": ${createGeneratedChannelJsonString()}, + "message_id": "09afcd85-9dbb-4da8-8d85-5a6b4268d755", + "message": ${createGeneratedMessageJsonString()}, "reaction": ${createReactionJsonString()}, "channel_last_message_at": "2020-06-29T06:14:28.000Z" """.trimIndent(), @@ -407,11 +409,13 @@ internal fun createReactionNewEventStringJson() = createChatEventStringJson( "reaction.new", """ - "user": ${createUserJsonString()}, + "user": ${createCommonFieldsUserJsonString()}, "channel_type": "channelType", "channel_id": "channelId", "cid": "channelType:channelId", - "message": ${createMessageJsonString()}, + "channel": ${createGeneratedChannelJsonString()}, + "message_id": "09afcd85-9dbb-4da8-8d85-5a6b4268d755", + "message": ${createGeneratedMessageJsonString()}, "reaction": ${createReactionJsonString()}, "channel_last_message_at": "2020-06-29T06:14:28.000Z" """.trimIndent(), @@ -421,11 +425,13 @@ internal fun createReactionUpdateEventStringJson() = createChatEventStringJson( "reaction.updated", """ - "user": ${createUserJsonString()}, + "user": ${createCommonFieldsUserJsonString()}, "channel_type": "channelType", "channel_id": "channelId", "cid": "channelType:channelId", - "message": ${createMessageJsonString()}, + "channel": ${createGeneratedChannelJsonString()}, + "message_id": "09afcd85-9dbb-4da8-8d85-5a6b4268d755", + "message": ${createGeneratedMessageJsonString()}, "reaction": ${createReactionJsonString()}, "channel_last_message_at": "2020-06-29T06:14:28.000Z" """.trimIndent(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index 81f861540ae..ac0121cdb4e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -34,9 +34,6 @@ import io.getstream.chat.android.client.api2.model.dto.MessageDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.MessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationAddedToChannelEventDto -import io.getstream.chat.android.client.api2.model.dto.ReactionDeletedEventDto -import io.getstream.chat.android.client.api2.model.dto.ReactionNewEventDto -import io.getstream.chat.android.client.api2.model.dto.ReactionUpdateEventDto import io.getstream.chat.android.client.api2.model.dto.UnknownEventDto import io.getstream.chat.android.client.api2.model.dto.utils.internal.ExactDate import io.getstream.chat.android.client.events.AIIndicatorClearEvent @@ -147,6 +144,9 @@ import io.getstream.chat.android.network.models.PollUpdatedEvent as GeneratedPol import io.getstream.chat.android.network.models.PollVoteCastedEvent as GeneratedPollVoteCastedEvent import io.getstream.chat.android.network.models.PollVoteChangedEvent as GeneratedPollVoteChangedEvent import io.getstream.chat.android.network.models.PollVoteRemovedEvent as GeneratedPollVoteRemovedEvent +import io.getstream.chat.android.network.models.ReactionDeletedEvent as GeneratedReactionDeletedEvent +import io.getstream.chat.android.network.models.ReactionNewEvent as GeneratedReactionNewEvent +import io.getstream.chat.android.network.models.ReactionUpdatedEvent as GeneratedReactionUpdatedEvent import io.getstream.chat.android.network.models.ReminderCreatedEvent as GeneratedReminderCreatedEvent import io.getstream.chat.android.network.models.ReminderDeletedEvent as GeneratedReminderDeletedEvent import io.getstream.chat.android.network.models.ReminderNotificationEvent as GeneratedReminderNotificationEvent @@ -668,39 +668,43 @@ internal object EventMappingTestArguments { private val notificationRemovedFromChannelDto = GeneratedEventDto(notificationRemovedFromChannelEvent) - private val reactionDeletedDto = ReactionDeletedEventDto( + private val reactionDeletedEvent = GeneratedReactionDeletedEvent( type = EventType.REACTION_DELETED, - created_at = EXACT_DATE, + createdAt = GENERATED_EXACT_DATE, cid = CID, - channel_type = CHANNEL_TYPE, - channel_id = CHANNEL_ID, - user = USER, + channel = GENERATED_CHANNEL, + user = COMMON_USER, reaction = REACTION, - message = MESSAGE, + message = GENERATED_MESSAGE, ) - private val reactionNewDto = ReactionNewEventDto( + private val reactionDeletedDto = GeneratedEventDto(reactionDeletedEvent) + + private val reactionNewEvent = GeneratedReactionNewEvent( type = EventType.REACTION_NEW, - created_at = EXACT_DATE, + createdAt = GENERATED_EXACT_DATE, cid = CID, - channel_type = CHANNEL_TYPE, - channel_id = CHANNEL_ID, - user = USER, + channel = GENERATED_CHANNEL, + user = COMMON_USER, reaction = REACTION, - message = MESSAGE, + message = GENERATED_MESSAGE, ) - private val reactionUpdateDto = ReactionUpdateEventDto( + private val reactionNewDto = GeneratedEventDto(reactionNewEvent) + + private val reactionUpdateEvent = GeneratedReactionUpdatedEvent( type = EventType.REACTION_UPDATED, - created_at = EXACT_DATE, + createdAt = GENERATED_EXACT_DATE, cid = CID, - channel_type = CHANNEL_TYPE, - channel_id = CHANNEL_ID, - user = USER, + channel = GENERATED_CHANNEL, + messageId = GENERATED_MESSAGE.id, + user = COMMON_USER, reaction = REACTION, - message = MESSAGE, + message = GENERATED_MESSAGE, ) + private val reactionUpdateDto = GeneratedEventDto(reactionUpdateEvent) + private val typingStartEvent = GeneratedTypingStartEvent( type = EventType.TYPING_START, createdAt = GENERATED_EXACT_DATE, @@ -1396,39 +1400,39 @@ internal object EventMappingTestArguments { ) private val reactionDeleted = ReactionDeletedEvent( - type = reactionDeletedDto.type, - createdAt = reactionDeletedDto.created_at.date, - rawCreatedAt = reactionDeletedDto.created_at.rawDate, - cid = reactionDeletedDto.cid, - channelType = reactionDeletedDto.channel_type, - channelId = reactionDeletedDto.channel_id, - user = with(domainMapping) { reactionDeletedDto.user.toDomain() }, - reaction = with(domainMapping) { reactionDeletedDto.reaction.toDomain() }, - message = with(domainMapping) { reactionDeletedDto.message.toDomain() }, + type = reactionDeletedEvent.type, + createdAt = reactionDeletedEvent.createdAt.date, + rawCreatedAt = reactionDeletedEvent.createdAt.raw, + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + user = with(domainMapping) { COMMON_USER.toDomain() }, + reaction = with(domainMapping) { REACTION.toDomain() }, + message = with(domainMapping) { GENERATED_MESSAGE.toDomain() }, ) private val reactionNew = ReactionNewEvent( - type = reactionNewDto.type, - createdAt = reactionNewDto.created_at.date, - rawCreatedAt = reactionNewDto.created_at.rawDate, - cid = reactionNewDto.cid, - channelType = reactionNewDto.channel_type, - channelId = reactionNewDto.channel_id, - user = with(domainMapping) { reactionNewDto.user.toDomain() }, - reaction = with(domainMapping) { reactionNewDto.reaction.toDomain() }, - message = with(domainMapping) { reactionNewDto.message.toDomain() }, + type = reactionNewEvent.type, + createdAt = reactionNewEvent.createdAt.date, + rawCreatedAt = reactionNewEvent.createdAt.raw, + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + user = with(domainMapping) { COMMON_USER.toDomain() }, + reaction = with(domainMapping) { REACTION.toDomain() }, + message = with(domainMapping) { GENERATED_MESSAGE.toDomain() }, ) private val reactionUpdate = ReactionUpdateEvent( - type = reactionUpdateDto.type, - createdAt = reactionUpdateDto.created_at.date, - rawCreatedAt = reactionUpdateDto.created_at.rawDate, - cid = reactionUpdateDto.cid, - channelType = reactionUpdateDto.channel_type, - channelId = reactionUpdateDto.channel_id, - user = with(domainMapping) { reactionUpdateDto.user.toDomain() }, - reaction = with(domainMapping) { reactionUpdateDto.reaction.toDomain() }, - message = with(domainMapping) { reactionUpdateDto.message.toDomain() }, + type = reactionUpdateEvent.type, + createdAt = reactionUpdateEvent.createdAt.date, + rawCreatedAt = reactionUpdateEvent.createdAt.raw, + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + user = with(domainMapping) { COMMON_USER.toDomain() }, + reaction = with(domainMapping) { REACTION.toDomain() }, + message = with(domainMapping) { GENERATED_MESSAGE.toDomain() }, ) private val typingStart = TypingStartEvent( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt index b3079588661..dfa15e1add5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt @@ -328,6 +328,14 @@ internal object EventArguments { /** The channel as the wire sends it: `disabled` always present, kept in extra data like the hand-written DTO. */ private val wireChannel = channel.copy(extraData = channel.extraData + ("disabled" to false)) + /** The message as reaction events send it: common-field users and no nested channel. */ + private val reactionEventMessage = message.copy( + user = nestedUser, + mentionedUsers = listOf(nestedUser), + threadParticipants = listOf(nestedUser), + channelInfo = null, + ) + /** A message inside a channel event: it carries no channel of its own, so its channel info is the event's. */ private val channelEventMessage = message.copy( user = nestedUser, @@ -691,33 +699,33 @@ internal object EventArguments { type = EventType.REACTION_DELETED, createdAt = date, rawCreatedAt = streamDateFormatter.format(date), - user = user, + user = commonFieldsUser, cid = cid, channelType = channelType, channelId = channelId, - message = message, + message = reactionEventMessage, reaction = reaction, ) private val reactionNewEvent = ReactionNewEvent( type = EventType.REACTION_NEW, createdAt = date, rawCreatedAt = streamDateFormatter.format(date), - user = user, + user = commonFieldsUser, cid = cid, channelType = channelType, channelId = channelId, - message = message, + message = reactionEventMessage, reaction = reaction, ) private val reactionUpdateEvent = ReactionUpdateEvent( type = EventType.REACTION_UPDATED, createdAt = date, rawCreatedAt = streamDateFormatter.format(date), - user = user, + user = commonFieldsUser, cid = cid, channelType = channelType, channelId = channelId, - message = message, + message = reactionEventMessage, reaction = reaction, ) private val typingStartEvent = TypingStartEvent( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedReactionEventParsingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedReactionEventParsingTest.kt new file mode 100644 index 00000000000..db48ec2c234 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedReactionEventParsingTest.kt @@ -0,0 +1,136 @@ +/* + * 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.parser2 + +import com.squareup.moshi.JsonDataException +import io.getstream.chat.android.client.api2.mapping.DomainMapping +import io.getstream.chat.android.client.api2.mapping.EventMapping +import io.getstream.chat.android.client.api2.model.response.SyncHistoryResponse +import io.getstream.chat.android.client.createReactionDeletedEventStringJson +import io.getstream.chat.android.client.createReactionNewEventStringJson +import io.getstream.chat.android.client.createReactionUpdateEventStringJson +import io.getstream.chat.android.client.events.ChatEvent +import io.getstream.chat.android.client.events.ReactionDeletedEvent +import io.getstream.chat.android.client.events.ReactionNewEvent +import io.getstream.chat.android.client.events.ReactionUpdateEvent +import io.getstream.chat.android.models.NoOpChannelTransformer +import io.getstream.chat.android.models.NoOpMessageTransformer +import io.getstream.chat.android.models.NoOpUserTransformer +import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import kotlin.reflect.KClass + +/** Reaction events parsed through their generated models, on both the WebSocket and the `/sync` path. */ +internal class GeneratedReactionEventParsingTest { + + private val parser = ParserFactory.createMoshiChatParser() + private val eventMapping = EventMapping( + DomainMapping( + currentUserIdProvider = { "" }, + channelTransformer = NoOpChannelTransformer, + messageTransformer = NoOpMessageTransformer, + userTransformer = NoOpUserTransformer, + ), + ) + + @ParameterizedTest + @MethodSource("events") + fun `A WebSocket reaction event keeps the created_at string as sent`( + json: String, + expected: KClass, + ) { + val event = parser.fromJson(json.withNanosecondCreatedAt(), ChatEvent::class.java) + + event::class shouldBeEqualTo expected + event.rawCreatedAt shouldBeEqualTo NANOSECOND_CREATED_AT + event.createdAt.time shouldBeEqualTo CREATED_AT_MILLIS + } + + @ParameterizedTest + @MethodSource("events") + fun `A synced reaction event keeps the created_at string as sent`(json: String, expected: KClass) { + val response = parser.fromJson( + """{ "events": [${json.withNanosecondCreatedAt()}] }""", + SyncHistoryResponse::class.java, + ) + + val event = with(eventMapping) { response.events.single().toDomain() } + + event::class shouldBeEqualTo expected + event.rawCreatedAt shouldBeEqualTo NANOSECOND_CREATED_AT + } + + @ParameterizedTest + @MethodSource("events") + fun `A reaction event replayed without channel and message_id maps to the same domain event`( + json: String, + expected: KClass, + ) { + val full = synced(json) + + val event = synced(json.without("channel").without("message_id")) + + event::class shouldBeEqualTo expected + event shouldBeEqualTo full + } + + private fun synced(json: String): ChatEvent { + val response = parser.fromJson("""{ "events": [$json] }""", SyncHistoryResponse::class.java) + return with(eventMapping) { response.events.single().toDomain() } + } + + @ParameterizedTest + @MethodSource("missingRequiredFields") + fun `A reaction event without a field the domain event requires is rejected`(json: String, field: String) { + assertThrows { + parser.fromJson(json.without(field), ChatEvent::class.java) + } + } + + private fun String.withNanosecondCreatedAt() = + replaceFirst(""""created_at": "2020-06-29T06:14:28.000Z"""", """"created_at": "$NANOSECOND_CREATED_AT"""") + + private fun String.without(field: String): String = + parser.toJson(parser.fromJson(this, Map::class.java).minus(field)) + + companion object { + private const val NANOSECOND_CREATED_AT = "2026-09-23T14:57:25.025029486Z" + + // The same instant truncated to milliseconds. + private const val CREATED_AT_MILLIS = 1790175445025L + + @JvmStatic + fun events(): List = listOf( + Arguments.of(createReactionNewEventStringJson(), ReactionNewEvent::class), + Arguments.of(createReactionUpdateEventStringJson(), ReactionUpdateEvent::class), + Arguments.of(createReactionDeletedEventStringJson(), ReactionDeletedEvent::class), + ) + + @JvmStatic + fun missingRequiredFields(): List = + listOf( + createReactionNewEventStringJson(), + createReactionUpdateEventStringJson(), + createReactionDeletedEventStringJson(), + ).flatMap { json -> + listOf("cid", "user", "message", "reaction").map { field -> Arguments.of(json, field) } + } + } +}