Skip to content

Commit 01d8ed4

Browse files
committed
Merge branch 'v7' into develop_to_v7_20260330
# Conflicts: # stream-chat-android-compose/api/stream-chat-android-compose.api # stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt # stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelperTest.kt
2 parents 6066a0c + 0df0b42 commit 01d8ed4

586 files changed

Lines changed: 4719 additions & 4364 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/message/attachments/internal/AttachmentUrlValidator.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ internal class AttachmentUrlValidator(private val attachmentHelper: AttachmentHe
3232
return newMessages.map { newMessage -> updateValidAttachmentsUrl(newMessage, oldMessages[newMessage.id]) }
3333
}
3434

35-
private fun updateValidAttachmentsUrl(newMessage: Message, oldMessage: Message?): Message {
35+
internal fun updateValidAttachmentsUrl(newMessage: Message, oldMessage: Message?): Message {
3636
return if (newMessage.attachments.isEmpty() || oldMessage == null) {
3737
newMessage
3838
} else {

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,8 @@ internal class ChannelEventHandlerImpl(
398398
}
399399

400400
private fun updateMessageWithReaction(message: Message) {
401-
state.updateMessageById(message.id) { oldMessage ->
402-
message.copy(ownReactions = oldMessage.ownReactions)
401+
state.updateMessageFromEvent(message) { old, new ->
402+
new.copy(ownReactions = old.ownReactions)
403403
}
404404
}
405405

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,20 +313,20 @@ internal class ChannelLogicImpl(
313313
// User's window was already trimmed away from the latest (insideSearch set by
314314
// trimNewestMessages, or a prior jump-to-message). Stay at current position;
315315
// refresh the "jump to latest" cache with the server's current latest page.
316-
state.upsertCachedLatestMessages(sortedMessages)
316+
state.upsertCachedLatestMessages(sortedMessages, preserveAttachmentUrls = true)
317317
}
318318
hasGap(currentMessages, sortedMessages) -> {
319319
// Incoming page is newer than the current window with no overlap. Inserting the
320320
// incoming messages would create a fragmented list. Instead, treat the user's
321321
// position as a mid-page: store the incoming as the "latest" cache and signal the UI.
322-
state.upsertCachedLatestMessages(sortedMessages)
322+
state.upsertCachedLatestMessages(sortedMessages, preserveAttachmentUrls = true)
323323
state.setInsideSearch(true)
324324
state.paginationManager.setEndOfNewerMessages(false)
325325
}
326326
else -> {
327327
// Incoming messages are contiguous with (or overlap) the current window.
328328
// Upsert preserves the user's scroll position while adding/updating messages.
329-
state.upsertMessages(sortedMessages)
329+
state.upsertMessages(sortedMessages, preserveAttachmentUrls = true)
330330
state.paginationManager.setEndOfOlderMessages(channel.messages.size < messageLimit)
331331
}
332332
}
@@ -382,7 +382,7 @@ internal class ChannelLogicImpl(
382382
}
383383

384384
query.isFilteringNewerMessages() -> {
385-
// Loading newer messages - upsert
385+
// Loading newer messages - append
386386
state.upsertMessages(channel.messages)
387387
state.trimOldestMessages()
388388
val endReached = query.messagesLimit() > channel.messages.size
@@ -394,7 +394,7 @@ internal class ChannelLogicImpl(
394394
}
395395

396396
query.filteringOlderMessages() -> {
397-
// Loading older messages - prepend; ceiling does not change
397+
// Loading older messages - prepend
398398
state.upsertMessages(channel.messages)
399399
state.trimNewestMessages()
400400
}

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault
2828
import io.getstream.chat.android.client.extensions.getCreatedAtOrNull
2929
import io.getstream.chat.android.client.extensions.internal.updateUsers
3030
import io.getstream.chat.android.client.extensions.internal.wasCreatedAfter
31+
import io.getstream.chat.android.client.internal.state.message.attachments.internal.AttachmentUrlValidator
3132
import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl.Companion.CACHED_LATEST_MESSAGES_LIMIT
3233
import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl.Companion.TRIM_BUFFER
3334
import io.getstream.chat.android.client.internal.state.utils.internal.combineStates
@@ -88,6 +89,7 @@ internal class ChannelStateImpl(
8889
private val liveLocations: StateFlow<List<Location>>,
8990
private val messageLimit: Int?,
9091
val paginationManager: MessagesPaginationManager = MessagesPaginationManagerImpl(),
92+
private val attachmentUrlValidator: AttachmentUrlValidator = AttachmentUrlValidator(),
9193
) : ChannelState {
9294

9395
override val cid: String = "$channelType:$channelId"
@@ -321,6 +323,8 @@ internal class ChannelStateImpl(
321323
/**
322324
* Upserts a single message into the current state.
323325
* Uses optimized single-element upsert with binary search insertion.
326+
* When updating an existing message, valid attachment URLs from the old message are preserved
327+
* to prevent unnecessary image reloads caused by CDN signature changes.
324328
*
325329
* @param message The message to upsert.
326330
*/
@@ -334,6 +338,7 @@ internal class ChannelStateImpl(
334338
element = message,
335339
idSelector = Message::id,
336340
comparator = MESSAGE_COMPARATOR,
341+
update = { old -> preserveAttachmentUrls(message, old) },
337342
)
338343
}
339344
// Update can be called for "ephemeral" messages (ex. Shuffle Giphy)
@@ -343,6 +348,7 @@ internal class ChannelStateImpl(
343348
element = message,
344349
idSelector = Message::id,
345350
comparator = MESSAGE_COMPARATOR,
351+
update = { old -> preserveAttachmentUrls(message, old) },
346352
)
347353
}
348354
}
@@ -356,8 +362,11 @@ internal class ChannelStateImpl(
356362
* This is guaranteed when messages come from API responses or database queries.
357363
*
358364
* @param messages The list of messages to upsert (must be sorted by createdAt).
365+
* @param preserveAttachmentUrls When `true`, valid attachment URLs from existing messages in state are
366+
* preserved to prevent unnecessary image reloads caused by CDN signature changes. Defaults to `false`
367+
* for pagination performance; set to `true` for reconnection/sync paths where messages may overlap.
359368
*/
360-
fun upsertMessages(messages: List<Message>) {
369+
fun upsertMessages(messages: List<Message>, preserveAttachmentUrls: Boolean = false) {
361370
val messagesToUpsert = messages.filterNot { shouldIgnoreUpsertion(it) }
362371
if (messagesToUpsert.isEmpty()) return
363372
for (message in messagesToUpsert) {
@@ -366,8 +375,13 @@ internal class ChannelStateImpl(
366375
message.poll?.let { registerPollForMessage(it, message.id) }
367376
}
368377
_messages.update { current ->
378+
val enriched = if (preserveAttachmentUrls) {
379+
preserveAttachmentUrls(messagesToUpsert, current)
380+
} else {
381+
messagesToUpsert
382+
}
369383
current.mergeSorted(
370-
other = messagesToUpsert,
384+
other = enriched,
371385
idSelector = Message::id,
372386
comparator = MESSAGE_COMPARATOR,
373387
)
@@ -377,7 +391,8 @@ internal class ChannelStateImpl(
377391
/**
378392
* Upserts a single message into the cached latest messages state.
379393
* The cached messages are bounded to [CACHED_LATEST_MESSAGES_LIMIT] to prevent unbounded growth
380-
* while the user is in search mode.
394+
* while the user is in search mode. When updating an existing message, valid attachment URLs
395+
* from the old message are preserved to prevent unnecessary image reloads.
381396
*
382397
* @param message The message to upsert.
383398
*/
@@ -392,17 +407,22 @@ internal class ChannelStateImpl(
392407
maxSize = CACHED_LATEST_MESSAGES_LIMIT,
393408
idSelector = Message::id,
394409
comparator = MESSAGE_COMPARATOR,
410+
update = { old -> preserveAttachmentUrls(message, old) },
395411
)
396412
}
397413
}
398414

399415
/**
400416
* Updates a message in the current state. Does nothing if the message does not exist.
417+
* Valid attachment URLs from the existing message are preserved to prevent unnecessary
418+
* image reloads caused by CDN signature changes.
401419
*
402420
* @param message The message to update.
403421
*/
404422
fun updateMessage(message: Message) {
405-
updateMessageById(message.id) { message }
423+
updateMessageById(message.id) { old ->
424+
preserveAttachmentUrls(message, old)
425+
}
406426
}
407427

408428
/**
@@ -419,6 +439,27 @@ internal class ChannelStateImpl(
419439
_pinnedMessages.update { it.updateIf({ msg -> msg.id == id }, transform) }
420440
}
421441

442+
/**
443+
* Replaces an existing message with [eventMessage] while preserving valid attachment URLs
444+
* from the old message, then applies the [enrich] function for caller-specific field merging.
445+
*
446+
* Use this for event-driven full-message replacements (e.g. reaction events) where the event
447+
* payload may carry attachment URLs with different CDN signatures than the ones already in state.
448+
*
449+
* @param eventMessage The new message from the event payload.
450+
* @param enrich A function that receives the old message and the URL-preserved new message,
451+
* returning the final merged message. Typically used to preserve fields like `ownReactions`.
452+
*/
453+
fun updateMessageFromEvent(
454+
eventMessage: Message,
455+
enrich: (old: Message, new: Message) -> Message,
456+
) {
457+
updateMessageById(eventMessage.id) { old ->
458+
val urlPreserved = preserveAttachmentUrls(eventMessage, old)
459+
enrich(old, urlPreserved)
460+
}
461+
}
462+
422463
/**
423464
* Hard deletes a message from the current state.
424465
* Note: Soft deletes are handled via [updateMessage].
@@ -585,29 +626,36 @@ internal class ChannelStateImpl(
585626

586627
/**
587628
* Updates each message that quotes the given message.
629+
* Valid attachment URLs from the existing quoted message are preserved to prevent
630+
* unnecessary image reloads caused by CDN signature changes.
588631
*
589632
* @param quotedMessage The message whose quoting messages should be updated.
590633
*/
591634
fun updateQuotedMessageReferences(quotedMessage: Message) {
592635
val quotingMessageIds = _quotedMessagesMap.value[quotedMessage.id]
593636
if (quotingMessageIds.isNullOrEmpty()) return
594637

638+
fun preservedReplyTo(existing: Message): Message {
639+
val oldReplyTo = existing.replyTo ?: return quotedMessage
640+
return preserveAttachmentUrls(quotedMessage, oldReplyTo)
641+
}
642+
595643
_messages.update { current ->
596644
current.updateIf(
597645
filter = { it.id in quotingMessageIds },
598-
update = { it.copy(replyTo = quotedMessage) },
646+
update = { it.copy(replyTo = preservedReplyTo(it)) },
599647
)
600648
}
601649
_cachedLatestMessages.update { current ->
602650
current.updateIf(
603651
filter = { it.id in quotingMessageIds },
604-
update = { it.copy(replyTo = quotedMessage) },
652+
update = { it.copy(replyTo = preservedReplyTo(it)) },
605653
)
606654
}
607655
_pinnedMessages.update { current ->
608656
current.updateIf(
609657
filter = { it.id in quotingMessageIds },
610-
update = { it.copy(replyTo = quotedMessage) },
658+
update = { it.copy(replyTo = preservedReplyTo(it)) },
611659
)
612660
}
613661
}
@@ -661,6 +709,8 @@ internal class ChannelStateImpl(
661709

662710
/**
663711
* Adds pinned messages to the current pinned messages list.
712+
* When updating an existing pinned message, valid attachment URLs from the old message
713+
* are preserved to prevent unnecessary image reloads caused by CDN signature changes.
664714
*
665715
* @param pinnedMessages The list of pinned messages to add.
666716
*/
@@ -679,6 +729,7 @@ internal class ChannelStateImpl(
679729
element = pinnedMessage,
680730
idSelector = Message::id,
681731
comparator = compareBy { it.pinnedAt },
732+
update = { old -> preserveAttachmentUrls(pinnedMessage, old) },
682733
)
683734
}
684735
result
@@ -1411,14 +1462,24 @@ internal class ChannelStateImpl(
14111462
*
14121463
* Called during reconnection to refresh the "jump to latest" cache with the server's
14131464
* current latest page without disturbing the user's active scroll position.
1465+
*
1466+
* @param messages The list of messages to merge.
1467+
* @param preserveAttachmentUrls When `true`, valid attachment URLs from existing cached messages are
1468+
* preserved to prevent unnecessary image reloads caused by CDN signature changes. Defaults to `false`;
1469+
* set to `true` for reconnection/sync paths where messages may overlap.
14141470
*/
1415-
fun upsertCachedLatestMessages(messages: List<Message>) {
1471+
fun upsertCachedLatestMessages(messages: List<Message>, preserveAttachmentUrls: Boolean = false) {
14161472
if (messages.isEmpty()) return
14171473
val messagesToUpsert = messages.filterNot { shouldIgnoreUpsertion(it) }
14181474
if (messagesToUpsert.isEmpty()) return
14191475
_cachedLatestMessages.update { current ->
1476+
val enriched = if (preserveAttachmentUrls) {
1477+
preserveAttachmentUrls(messagesToUpsert, current)
1478+
} else {
1479+
messagesToUpsert
1480+
}
14201481
current.mergeSorted(
1421-
other = messagesToUpsert,
1482+
other = enriched,
14221483
idSelector = Message::id,
14231484
comparator = MESSAGE_COMPARATOR,
14241485
).takeLast(CACHED_LATEST_MESSAGES_LIMIT)
@@ -1556,6 +1617,25 @@ internal class ChannelStateImpl(
15561617
FROM_NEWEST,
15571618
}
15581619

1620+
/**
1621+
* Preserves valid attachment URLs from [oldMessage] onto [newMessage].
1622+
* Prevents unnecessary image reloads when the backend delivers events with different CDN signatures.
1623+
*/
1624+
private fun preserveAttachmentUrls(newMessage: Message, oldMessage: Message): Message =
1625+
attachmentUrlValidator.updateValidAttachmentsUrl(newMessage, oldMessage)
1626+
1627+
/**
1628+
* Preserves valid attachment URLs for a batch of messages.
1629+
* Builds a lookup map from [oldMessages] and enriches each message in [newMessages].
1630+
*/
1631+
private fun preserveAttachmentUrls(
1632+
newMessages: List<Message>,
1633+
oldMessages: List<Message>,
1634+
): List<Message> {
1635+
val oldById = oldMessages.associateBy(Message::id)
1636+
return attachmentUrlValidator.updateValidAttachmentsUrl(newMessages, oldById)
1637+
}
1638+
15591639
private companion object {
15601640
/**
15611641
* Hard limit for cached latest messages to prevent unbounded memory growth while in search mode.

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/List.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ internal fun <T, ID> List<T>.upsertSortedBounded(
148148
maxSize: Int,
149149
idSelector: (T) -> ID,
150150
comparator: Comparator<in T>,
151+
update: (old: T) -> T = { element },
151152
): List<T> {
152-
val result = upsertSorted(element, idSelector, comparator)
153+
val result = upsertSorted(element, idSelector, comparator, update)
153154
return if (result.size > maxSize) result.takeLast(maxSize) else result
154155
}
155156

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/handler/NotificationConfig.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public data class NotificationConfig @JvmOverloads constructor(
7676
/**
7777
* Whether or not the auto-translation feature is enabled.
7878
*/
79-
val autoTranslationEnabled: Boolean = false,
79+
val autoTranslationEnabled: Boolean = true,
8080

8181
/**
8282
* A token provider to be used on case of restoring user credentials and an expired token needs to be refreshed.

0 commit comments

Comments
 (0)