diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/message/attachments/internal/AttachmentUrlValidator.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/message/attachments/internal/AttachmentUrlValidator.kt index a516092df85..9d80639b811 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/message/attachments/internal/AttachmentUrlValidator.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/message/attachments/internal/AttachmentUrlValidator.kt @@ -32,7 +32,7 @@ internal class AttachmentUrlValidator(private val attachmentHelper: AttachmentHe return newMessages.map { newMessage -> updateValidAttachmentsUrl(newMessage, oldMessages[newMessage.id]) } } - private fun updateValidAttachmentsUrl(newMessage: Message, oldMessage: Message?): Message { + internal fun updateValidAttachmentsUrl(newMessage: Message, oldMessage: Message?): Message { return if (newMessage.attachments.isEmpty() || oldMessage == null) { newMessage } else { 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 a549b567a2a..ac6f2332856 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 @@ -398,8 +398,8 @@ internal class ChannelEventHandlerImpl( } private fun updateMessageWithReaction(message: Message) { - state.updateMessageById(message.id) { oldMessage -> - message.copy(ownReactions = oldMessage.ownReactions) + state.updateMessageFromEvent(message) { old, new -> + new.copy(ownReactions = old.ownReactions) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt index dd10ee50053..43ad7ab4e58 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt @@ -313,20 +313,20 @@ internal class ChannelLogicImpl( // User's window was already trimmed away from the latest (insideSearch set by // trimNewestMessages, or a prior jump-to-message). Stay at current position; // refresh the "jump to latest" cache with the server's current latest page. - state.upsertCachedLatestMessages(sortedMessages) + state.upsertCachedLatestMessages(sortedMessages, preserveAttachmentUrls = true) } hasGap(currentMessages, sortedMessages) -> { // Incoming page is newer than the current window with no overlap. Inserting the // incoming messages would create a fragmented list. Instead, treat the user's // position as a mid-page: store the incoming as the "latest" cache and signal the UI. - state.upsertCachedLatestMessages(sortedMessages) + state.upsertCachedLatestMessages(sortedMessages, preserveAttachmentUrls = true) state.setInsideSearch(true) state.paginationManager.setEndOfNewerMessages(false) } else -> { // Incoming messages are contiguous with (or overlap) the current window. // Upsert preserves the user's scroll position while adding/updating messages. - state.upsertMessages(sortedMessages) + state.upsertMessages(sortedMessages, preserveAttachmentUrls = true) state.paginationManager.setEndOfOlderMessages(channel.messages.size < messageLimit) } } @@ -382,7 +382,7 @@ internal class ChannelLogicImpl( } query.isFilteringNewerMessages() -> { - // Loading newer messages - upsert + // Loading newer messages - append state.upsertMessages(channel.messages) state.trimOldestMessages() val endReached = query.messagesLimit() > channel.messages.size @@ -394,7 +394,7 @@ internal class ChannelLogicImpl( } query.filteringOlderMessages() -> { - // Loading older messages - prepend; ceiling does not change + // Loading older messages - prepend state.upsertMessages(channel.messages) state.trimNewestMessages() } 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 aba15f174d5..6d5404d9dd9 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 @@ -28,6 +28,7 @@ import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.getCreatedAtOrNull import io.getstream.chat.android.client.extensions.internal.updateUsers import io.getstream.chat.android.client.extensions.internal.wasCreatedAfter +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 import io.getstream.chat.android.client.internal.state.utils.internal.combineStates @@ -88,6 +89,7 @@ internal class ChannelStateImpl( private val liveLocations: StateFlow>, private val messageLimit: Int?, val paginationManager: MessagesPaginationManager = MessagesPaginationManagerImpl(), + private val attachmentUrlValidator: AttachmentUrlValidator = AttachmentUrlValidator(), ) : ChannelState { override val cid: String = "$channelType:$channelId" @@ -321,6 +323,8 @@ internal class ChannelStateImpl( /** * Upserts a single message into the current state. * Uses optimized single-element upsert with binary search insertion. + * When updating an existing message, valid attachment URLs from the old message are preserved + * to prevent unnecessary image reloads caused by CDN signature changes. * * @param message The message to upsert. */ @@ -334,6 +338,7 @@ internal class ChannelStateImpl( element = message, idSelector = Message::id, comparator = MESSAGE_COMPARATOR, + update = { old -> preserveAttachmentUrls(message, old) }, ) } // Update can be called for "ephemeral" messages (ex. Shuffle Giphy) @@ -343,6 +348,7 @@ internal class ChannelStateImpl( element = message, idSelector = Message::id, comparator = MESSAGE_COMPARATOR, + update = { old -> preserveAttachmentUrls(message, old) }, ) } } @@ -356,8 +362,11 @@ internal class ChannelStateImpl( * This is guaranteed when messages come from API responses or database queries. * * @param messages The list of messages to upsert (must be sorted by createdAt). + * @param preserveAttachmentUrls When `true`, valid attachment URLs from existing messages in state are + * preserved to prevent unnecessary image reloads caused by CDN signature changes. Defaults to `false` + * for pagination performance; set to `true` for reconnection/sync paths where messages may overlap. */ - fun upsertMessages(messages: List) { + fun upsertMessages(messages: List, preserveAttachmentUrls: Boolean = false) { val messagesToUpsert = messages.filterNot { shouldIgnoreUpsertion(it) } if (messagesToUpsert.isEmpty()) return for (message in messagesToUpsert) { @@ -366,8 +375,13 @@ internal class ChannelStateImpl( message.poll?.let { registerPollForMessage(it, message.id) } } _messages.update { current -> + val enriched = if (preserveAttachmentUrls) { + preserveAttachmentUrls(messagesToUpsert, current) + } else { + messagesToUpsert + } current.mergeSorted( - other = messagesToUpsert, + other = enriched, idSelector = Message::id, comparator = MESSAGE_COMPARATOR, ) @@ -377,7 +391,8 @@ internal class ChannelStateImpl( /** * Upserts a single message into the cached latest messages state. * The cached messages are bounded to [CACHED_LATEST_MESSAGES_LIMIT] to prevent unbounded growth - * while the user is in search mode. + * while the user is in search mode. When updating an existing message, valid attachment URLs + * from the old message are preserved to prevent unnecessary image reloads. * * @param message The message to upsert. */ @@ -392,17 +407,22 @@ internal class ChannelStateImpl( maxSize = CACHED_LATEST_MESSAGES_LIMIT, idSelector = Message::id, comparator = MESSAGE_COMPARATOR, + update = { old -> preserveAttachmentUrls(message, old) }, ) } } /** * Updates a message in the current state. Does nothing if the message does not exist. + * Valid attachment URLs from the existing message are preserved to prevent unnecessary + * image reloads caused by CDN signature changes. * * @param message The message to update. */ fun updateMessage(message: Message) { - updateMessageById(message.id) { message } + updateMessageById(message.id) { old -> + preserveAttachmentUrls(message, old) + } } /** @@ -419,6 +439,27 @@ internal class ChannelStateImpl( _pinnedMessages.update { it.updateIf({ msg -> msg.id == id }, transform) } } + /** + * Replaces an existing message with [eventMessage] while preserving valid attachment URLs + * from the old message, then applies the [enrich] function for caller-specific field merging. + * + * Use this for event-driven full-message replacements (e.g. reaction events) where the event + * payload may carry attachment URLs with different CDN signatures than the ones already in state. + * + * @param eventMessage The new message from the event payload. + * @param enrich A function that receives the old message and the URL-preserved new message, + * returning the final merged message. Typically used to preserve fields like `ownReactions`. + */ + fun updateMessageFromEvent( + eventMessage: Message, + enrich: (old: Message, new: Message) -> Message, + ) { + updateMessageById(eventMessage.id) { old -> + val urlPreserved = preserveAttachmentUrls(eventMessage, old) + enrich(old, urlPreserved) + } + } + /** * Hard deletes a message from the current state. * Note: Soft deletes are handled via [updateMessage]. @@ -661,6 +702,8 @@ internal class ChannelStateImpl( /** * Adds pinned messages to the current pinned messages list. + * When updating an existing pinned message, valid attachment URLs from the old message + * are preserved to prevent unnecessary image reloads caused by CDN signature changes. * * @param pinnedMessages The list of pinned messages to add. */ @@ -679,6 +722,7 @@ internal class ChannelStateImpl( element = pinnedMessage, idSelector = Message::id, comparator = compareBy { it.pinnedAt }, + update = { old -> preserveAttachmentUrls(pinnedMessage, old) }, ) } result @@ -1411,14 +1455,24 @@ internal class ChannelStateImpl( * * Called during reconnection to refresh the "jump to latest" cache with the server's * current latest page without disturbing the user's active scroll position. + * + * @param messages The list of messages to merge. + * @param preserveAttachmentUrls When `true`, valid attachment URLs from existing cached messages are + * preserved to prevent unnecessary image reloads caused by CDN signature changes. Defaults to `false`; + * set to `true` for reconnection/sync paths where messages may overlap. */ - fun upsertCachedLatestMessages(messages: List) { + fun upsertCachedLatestMessages(messages: List, preserveAttachmentUrls: Boolean = false) { if (messages.isEmpty()) return val messagesToUpsert = messages.filterNot { shouldIgnoreUpsertion(it) } if (messagesToUpsert.isEmpty()) return _cachedLatestMessages.update { current -> + val enriched = if (preserveAttachmentUrls) { + preserveAttachmentUrls(messagesToUpsert, current) + } else { + messagesToUpsert + } current.mergeSorted( - other = messagesToUpsert, + other = enriched, idSelector = Message::id, comparator = MESSAGE_COMPARATOR, ).takeLast(CACHED_LATEST_MESSAGES_LIMIT) @@ -1556,6 +1610,25 @@ internal class ChannelStateImpl( FROM_NEWEST, } + /** + * Preserves valid attachment URLs from [oldMessage] onto [newMessage]. + * Prevents unnecessary image reloads when the backend delivers events with different CDN signatures. + */ + private fun preserveAttachmentUrls(newMessage: Message, oldMessage: Message): Message = + attachmentUrlValidator.updateValidAttachmentsUrl(newMessage, oldMessage) + + /** + * Preserves valid attachment URLs for a batch of messages. + * Builds a lookup map from [oldMessages] and enriches each message in [newMessages]. + */ + private fun preserveAttachmentUrls( + newMessages: List, + oldMessages: List, + ): List { + val oldById = oldMessages.associateBy(Message::id) + return attachmentUrlValidator.updateValidAttachmentsUrl(newMessages, oldById) + } + private companion object { /** * Hard limit for cached latest messages to prevent unbounded memory growth while in search mode. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/List.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/List.kt index eff5e62b4b8..d1c447b3ab4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/List.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/List.kt @@ -148,8 +148,9 @@ internal fun List.upsertSortedBounded( maxSize: Int, idSelector: (T) -> ID, comparator: Comparator, + update: (old: T) -> T = { element }, ): List { - val result = upsertSorted(element, idSelector, comparator) + val result = upsertSorted(element, idSelector, comparator, update) return if (result.size > maxSize) result.takeLast(maxSize) else result } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/WhenHandleEvent.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/WhenHandleEvent.kt index 691356a368a..a2446cbc140 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/WhenHandleEvent.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/WhenHandleEvent.kt @@ -98,7 +98,12 @@ internal class WhenHandleEvent : SynchronizedCoroutineTest { fun setUp() { channelStateLegacyImpl.setEndOfNewerMessages(true) - whenever(attachmentUrlValidator.updateValidAttachmentsUrl(any(), any())) doAnswer { invocation -> + whenever( + attachmentUrlValidator.updateValidAttachmentsUrl( + any>(), + any>(), + ), + ) doAnswer { invocation -> invocation.arguments[0] as List } @@ -112,27 +117,36 @@ internal class WhenHandleEvent : SynchronizedCoroutineTest { // User watching event @Test - fun `when user watching event arrives, last message should upsert messages, increment count and appear`() = runTest { - val user = User() - val newDate = Date(Long.MAX_VALUE) - - val newMessage = randomMessage( - id = "thisId", - createdAt = newDate, - silent = false, - showInChannel = true, - ) - - whenever(attachmentUrlValidator.updateValidAttachmentsUrl(any(), any())) doReturn listOf(newMessage) - - val userStartWatchingEvent = randomNewMessageEvent(user = user, createdAt = newDate, message = newMessage) - - channelLogic.handleEvent(userStartWatchingEvent) - - verify(channelStateLogic).upsertMessage(newMessage) - verify(channelStateLogic).updateCurrentUserRead(userStartWatchingEvent.createdAt, userStartWatchingEvent.message) - verify(channelStateLogic).setHidden(false) - } + fun `when user watching event arrives, last message should upsert messages, increment count and appear`() = + runTest { + val user = User() + val newDate = Date(Long.MAX_VALUE) + + val newMessage = randomMessage( + id = "thisId", + createdAt = newDate, + silent = false, + showInChannel = true, + ) + + whenever( + attachmentUrlValidator.updateValidAttachmentsUrl( + any>(), + any>(), + ), + ) doReturn listOf(newMessage) + + val userStartWatchingEvent = randomNewMessageEvent(user = user, createdAt = newDate, message = newMessage) + + channelLogic.handleEvent(userStartWatchingEvent) + + verify(channelStateLogic).upsertMessage(newMessage) + verify(channelStateLogic).updateCurrentUserRead( + userStartWatchingEvent.createdAt, + userStartWatchingEvent.message, + ) + verify(channelStateLogic).setHidden(false) + } // Message update @Test @@ -154,31 +168,32 @@ internal class WhenHandleEvent : SynchronizedCoroutineTest { } @Test - fun `when message update event arrives without poll but original message has poll, poll should be preserved`() = runTest { - val poll = randomPoll() - val originalMessage = randomMessage( - id = randomString(), - user = User(id = "otherUserId"), - silent = false, - showInChannel = true, - poll = poll, - ) - channelStateLegacyImpl.setMessages(listOf(originalMessage)) - - val updatedMessageWithoutPoll = originalMessage.copy( - text = "Updated text", - poll = null, - ) - val messageUpdateEvent = randomMessageUpdateEvent(message = updatedMessageWithoutPoll) - - channelLogic.handleEvent(messageUpdateEvent) - - verify(channelStateLogic).upsertMessage( - org.mockito.kotlin.argThat { message -> - message.poll == poll && message.text == "Updated text" - }, - ) - } + fun `when message update event arrives without poll but original message has poll, poll should be preserved`() = + runTest { + val poll = randomPoll() + val originalMessage = randomMessage( + id = randomString(), + user = User(id = "otherUserId"), + silent = false, + showInChannel = true, + poll = poll, + ) + channelStateLegacyImpl.setMessages(listOf(originalMessage)) + + val updatedMessageWithoutPoll = originalMessage.copy( + text = "Updated text", + poll = null, + ) + val messageUpdateEvent = randomMessageUpdateEvent(message = updatedMessageWithoutPoll) + + channelLogic.handleEvent(messageUpdateEvent) + + verify(channelStateLogic).upsertMessage( + org.mockito.kotlin.argThat { message -> + message.poll == poll && message.text == "Updated text" + }, + ) + } @Test fun `when message update event arrives with poll, event poll should be used`() = runTest { @@ -260,20 +275,26 @@ internal class WhenHandleEvent : SynchronizedCoroutineTest { // Reaction event @Test - fun `when reaction event arrives, if message is in the list, the message of the event should be upsert`(): Unit = runTest { - val message = randomMessage( - showInChannel = true, - silent = false, - ) - channelStateLogic.upsertMessages(listOf(message)) - val reactionEvent = randomReactionNewEvent(user = randomUser(), message = message) - whenever(attachmentUrlValidator.updateValidAttachmentsUrl(any(), any())) doReturn listOf(message) - - channelLogic.handleEvent(reactionEvent) - - // Message is propagated - verify(channelStateLogic).upsertMessages(listOf(reactionEvent.message)) - } + fun `when reaction event arrives, if message is in the list, the message of the event should be upsert`(): Unit = + runTest { + val message = randomMessage( + showInChannel = true, + silent = false, + ) + channelStateLogic.upsertMessages(listOf(message)) + val reactionEvent = randomReactionNewEvent(user = randomUser(), message = message) + whenever( + attachmentUrlValidator.updateValidAttachmentsUrl( + any>(), + any>(), + ), + ) doReturn listOf(message) + + channelLogic.handleEvent(reactionEvent) + + // Message is propagated + verify(channelStateLogic).upsertMessages(listOf(reactionEvent.message)) + } // Channel deleted event @Test @@ -318,7 +339,12 @@ internal class WhenHandleEvent : SynchronizedCoroutineTest { text = "Updated text", createdLocallyAt = null, ) - whenever(attachmentUrlValidator.updateValidAttachmentsUrl(any(), any())) doReturn listOf(updatedMessage) + whenever( + attachmentUrlValidator.updateValidAttachmentsUrl( + any>(), + any>(), + ), + ) doReturn listOf(updatedMessage) val newMessageEvent = randomNewMessageEvent( user = currentUser, @@ -353,7 +379,12 @@ internal class WhenHandleEvent : SynchronizedCoroutineTest { text = "Updated text", createdLocallyAt = null, ) - whenever(attachmentUrlValidator.updateValidAttachmentsUrl(any(), any())) doReturn listOf(updatedMessage) + whenever( + attachmentUrlValidator.updateValidAttachmentsUrl( + any>(), + any>(), + ), + ) doReturn listOf(updatedMessage) val newMessageEvent = randomNewMessageEvent( user = otherUser, 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 7c4597815f3..d449aefc686 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 @@ -592,9 +592,9 @@ internal class ChannelEventHandlerImplTest { handler.handle(event) - val captor = argumentCaptor<(Message) -> Message>() - verify(state).updateMessageById(eq(message.id), captor.capture()) - val result = captor.firstValue(message) + val captor = argumentCaptor<(Message, Message) -> Message>() + verify(state).updateMessageFromEvent(eq(message), captor.capture()) + val result = captor.firstValue(message, message) assertEquals(ownReactions, result.ownReactions) } @@ -616,9 +616,9 @@ internal class ChannelEventHandlerImplTest { handler.handle(event) - val captor = argumentCaptor<(Message) -> Message>() - verify(state).updateMessageById(eq(message.id), captor.capture()) - val result = captor.firstValue(message) + val captor = argumentCaptor<(Message, Message) -> Message>() + verify(state).updateMessageFromEvent(eq(message), captor.capture()) + val result = captor.firstValue(message, message) assertEquals(ownReactions, result.ownReactions) } @@ -640,9 +640,9 @@ internal class ChannelEventHandlerImplTest { handler.handle(event) - val captor = argumentCaptor<(Message) -> Message>() - verify(state).updateMessageById(eq(message.id), captor.capture()) - val result = captor.firstValue(message) + val captor = argumentCaptor<(Message, Message) -> Message>() + verify(state).updateMessageFromEvent(eq(message), captor.capture()) + val result = captor.firstValue(message, message) assertEquals(ownReactions, result.ownReactions) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt index bd4c0bae4a2..723ff69d823 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt @@ -399,7 +399,7 @@ internal class ChannelLogicImplTest { sut.onQueryChannelResult(query, Result.Success(channel)) // Then verify(stateImpl, never()).setMessages(any()) - verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).upsertMessages(any(), any()) verify(stateImpl, never()).clearCachedLatestMessages() verify(stateImpl, never()).setInsideSearch(any()) } @@ -1279,9 +1279,9 @@ internal class ChannelLogicImplTest { watcherCount = 0, ) sut.updateDataForChannel(channel = channel, messageLimit = 30) - verify(stateImpl).upsertMessages(listOf(incomingMsg)) + verify(stateImpl).upsertMessages(listOf(incomingMsg), preserveAttachmentUrls = true) verify(stateImpl, never()).setMessages(any()) - verify(stateImpl, never()).upsertCachedLatestMessages(any()) + verify(stateImpl, never()).upsertCachedLatestMessages(any(), any()) verify(paginationManager, never()).setEndOfNewerMessages(any()) } @@ -1301,11 +1301,11 @@ internal class ChannelLogicImplTest { watcherCount = 0, ) sut.updateDataForChannel(channel = channel, messageLimit = 30) - verify(stateImpl).upsertCachedLatestMessages(listOf(incomingMsg)) + verify(stateImpl).upsertCachedLatestMessages(listOf(incomingMsg), preserveAttachmentUrls = true) verify(stateImpl).setInsideSearch(true) verify(paginationManager).setEndOfNewerMessages(false) verify(stateImpl, never()).setMessages(any()) - verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).upsertMessages(any(), any()) verify(paginationManager, never()).setEndOfOlderMessages(any()) } @@ -1326,9 +1326,9 @@ internal class ChannelLogicImplTest { watcherCount = 0, ) sut.updateDataForChannel(channel = channel, messageLimit = 30) - verify(stateImpl).upsertCachedLatestMessages(listOf(incomingMsg)) + verify(stateImpl).upsertCachedLatestMessages(listOf(incomingMsg), preserveAttachmentUrls = true) verify(stateImpl, never()).setMessages(any()) - verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).upsertMessages(any(), any()) verify(stateImpl, never()).setInsideSearch(any()) verify(paginationManager, never()).setEndOfNewerMessages(any()) } @@ -1350,8 +1350,8 @@ internal class ChannelLogicImplTest { ) sut.updateDataForChannel(channel = channel, messageLimit = 30, shouldRefreshMessages = true) verify(stateImpl).setMessages(listOf(incomingMsg)) - verify(stateImpl, never()).upsertMessages(any()) - verify(stateImpl, never()).upsertCachedLatestMessages(any()) + verify(stateImpl, never()).upsertMessages(any(), any()) + verify(stateImpl, never()).upsertCachedLatestMessages(any(), any()) } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt index cb1eacde121..595140e3bca 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt @@ -159,7 +159,12 @@ internal class ChannelStateLogicTest { private lateinit var spyMutableGlobalState: MutableGlobalState private val attachmentUrlValidator: AttachmentUrlValidator = mock { - on(it.updateValidAttachmentsUrl(any(), any())) doAnswer { invocationOnMock -> + on( + it.updateValidAttachmentsUrl( + any>(), + any>(), + ), + ) doAnswer { invocationOnMock -> invocationOnMock.arguments[0] as List } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt index b5abf3f5129..c54a4d6c962 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.int import io.getstream.chat.android.client.api.models.Pagination import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Config import io.getstream.chat.android.models.MessagesState import io.getstream.chat.android.randomUser @@ -773,4 +774,129 @@ internal class ChannelStateImplMessagesTest : ChannelStateImplTestBase() { } // endregion + + // region Attachment URL preservation + + @Nested + inner class AttachmentUrlPreservation { + + private val streamCdnImageUrl = "https://images.stream-io-cdn.com/image.jpg" + private val newStreamCdnImageUrl = "https://images.stream-io-cdn.com/image.jpg?token=new" + + private fun createMessageWithAttachment( + index: Int, + imageUrl: String, + timestamp: Long = currentTime() + index * 1000L, + ) = createMessage(index, timestamp = timestamp).copy( + attachments = mutableListOf( + Attachment( + type = "image", + imageUrl = imageUrl, + name = "photo.jpg", + mimeType = "image/jpeg", + ), + ), + ) + + @Test + fun `upsertMessage should preserve valid attachment imageUrl when updating existing message`() = runTest { + val original = createMessageWithAttachment(1, imageUrl = streamCdnImageUrl) + channelState.setMessages(listOf(original)) + + val updated = createMessageWithAttachment(1, imageUrl = newStreamCdnImageUrl) + channelState.upsertMessage(updated) + + val result = channelState.messages.value.first() + assertEquals(streamCdnImageUrl, result.attachments.first().imageUrl) + } + + @Test + fun `upsertMessage should not preserve attachment url for new message`() = runTest { + val newMsg = createMessageWithAttachment(1, imageUrl = newStreamCdnImageUrl) + channelState.upsertMessage(newMsg) + + val result = channelState.messages.value.first() + assertEquals(newStreamCdnImageUrl, result.attachments.first().imageUrl) + } + + @Test + fun `upsertMessages with preserveAttachmentUrls should preserve valid imageUrl`() = runTest { + val original = createMessageWithAttachment(1, imageUrl = streamCdnImageUrl, timestamp = 1000) + channelState.setMessages(listOf(original)) + + val updated = createMessageWithAttachment(1, imageUrl = newStreamCdnImageUrl, timestamp = 1000) + channelState.upsertMessages(listOf(updated), preserveAttachmentUrls = true) + + val result = channelState.messages.value.first() + assertEquals(streamCdnImageUrl, result.attachments.first().imageUrl) + } + + @Test + fun `upsertMessages without flag should not preserve imageUrl`() = runTest { + val original = createMessageWithAttachment(1, imageUrl = streamCdnImageUrl, timestamp = 1000) + channelState.setMessages(listOf(original)) + + val updated = createMessageWithAttachment(1, imageUrl = newStreamCdnImageUrl, timestamp = 1000) + channelState.upsertMessages(listOf(updated)) + + val result = channelState.messages.value.first() + assertEquals(newStreamCdnImageUrl, result.attachments.first().imageUrl) + } + + @Test + fun `updateMessage should preserve valid attachment imageUrl`() = runTest { + val original = createMessageWithAttachment(1, imageUrl = streamCdnImageUrl) + channelState.setMessages(listOf(original)) + + val updated = createMessageWithAttachment(1, imageUrl = newStreamCdnImageUrl) + channelState.updateMessage(updated) + + val result = channelState.messages.value.first() + assertEquals(streamCdnImageUrl, result.attachments.first().imageUrl) + } + + @Test + fun `updateMessageFromEvent should preserve valid attachment imageUrl`() = runTest { + val original = createMessageWithAttachment(1, imageUrl = streamCdnImageUrl) + channelState.setMessages(listOf(original)) + + val eventMsg = createMessageWithAttachment(1, imageUrl = newStreamCdnImageUrl) + channelState.updateMessageFromEvent(eventMsg) { _, new -> new } + + val result = channelState.messages.value.first() + assertEquals(streamCdnImageUrl, result.attachments.first().imageUrl) + } + + @Test + fun `updateMessageFromEvent should apply enrich lambda after url validation`() = runTest { + val ownReactions = listOf(io.getstream.chat.android.randomReaction()) + val original = createMessageWithAttachment(1, imageUrl = streamCdnImageUrl) + .copy(ownReactions = ownReactions) + channelState.setMessages(listOf(original)) + + val eventMsg = createMessageWithAttachment(1, imageUrl = newStreamCdnImageUrl) + .copy(ownReactions = emptyList()) + channelState.updateMessageFromEvent(eventMsg) { old, new -> + new.copy(ownReactions = old.ownReactions) + } + + val result = channelState.messages.value.first() + assertEquals(streamCdnImageUrl, result.attachments.first().imageUrl) + assertEquals(ownReactions, result.ownReactions) + } + + @Test + fun `updateMessageFromEvent should do nothing if message does not exist`() = runTest { + val original = createMessage(1) + channelState.setMessages(listOf(original)) + + val eventMsg = createMessageWithAttachment(999, imageUrl = newStreamCdnImageUrl) + channelState.updateMessageFromEvent(eventMsg) { _, new -> new } + + assertEquals(1, channelState.messages.value.size) + assertEquals(original.id, channelState.messages.value.first().id) + } + } + + // endregion }