diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 2147e9e86d7..62d0bd7405e 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -3188,6 +3188,7 @@ public final class io/getstream/chat/android/client/internal/offline/repository/ public fun select (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectByCidAndUserId (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectBySyncStatus (Lio/getstream/chat/android/models/SyncStatus;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun selectBySyncStatusOrTypeForChannel (Ljava/lang/String;Ljava/util/List;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectByUserId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectChunked (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectDraftMessageByCid (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3514,6 +3515,7 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract fun selectDraftMessageByParentId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectDraftMessages (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectDraftMessagesByCid (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun selectLocalOnlyMessagesForChannel (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectMessage (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectMessageBySyncState (Lio/getstream/chat/android/models/SyncStatus;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectMessageIdsBySyncState (Lio/getstream/chat/android/models/SyncStatus;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 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 6560a0c2f3b..5d63b09bb01 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 @@ -88,7 +88,7 @@ import io.getstream.chat.android.client.internal.offline.repository.domain.user. ThreadOrderEntity::class, DraftMessageEntity::class, ], - version = 101, + version = 102, exportSchema = false, ) @TypeConverters( 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 1f519bb3b9e..4ec0f280f43 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 @@ -21,6 +21,8 @@ import io.getstream.chat.android.client.api.models.Pagination import io.getstream.chat.android.client.internal.offline.extensions.launchWithMutex import io.getstream.chat.android.client.persistance.repository.MessageRepository import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest +import io.getstream.chat.android.client.utils.message.LocalOnlyMessageTypes +import io.getstream.chat.android.client.utils.message.LocalOnlySyncStatuses import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.Message @@ -262,6 +264,13 @@ internal class DatabaseMessageRepository( } } + override suspend fun selectLocalOnlyMessagesForChannel(cid: String): List = + messageDao.selectBySyncStatusOrTypeForChannel( + cid = cid, + syncStatuses = LocalOnlySyncStatuses.map(SyncStatus::status), + types = LocalOnlyMessageTypes.toList(), + ).map { entity -> entity.toMessage() } + private suspend fun selectMessagesEntitiesForChannel( cid: String, pagination: AnyChannelPaginationRequest?, 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 12ba86c4310..bce422d9d38 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 @@ -253,6 +253,19 @@ internal interface MessageDao { @Query("DELETE FROM $MESSAGE_ENTITY_TABLE_NAME") suspend fun deleteAll() + @Query( + "SELECT * FROM $MESSAGE_ENTITY_TABLE_NAME " + + "WHERE cid = :cid " + + "AND (syncStatus IN (:syncStatuses) OR type IN (:types)) " + + "ORDER BY CASE WHEN createdAt IS NULL THEN createdLocallyAt ELSE createdAt END ASC", + ) + @Transaction + suspend fun selectBySyncStatusOrTypeForChannel( + cid: String, + syncStatuses: List, + types: List, + ): List + private companion object { private const val SQLITE_MAX_VARIABLE_NUMBER: Int = 999 private const val NO_LIMIT: Int = -1 diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt index 1cd9e3345f3..7e07d1c10bb 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt @@ -53,8 +53,8 @@ internal class QueryChannelListenerState(private val logic: LogicRegistry) : Que ) { logger.d { "[onQueryChannelRequest] cid: $channelType:$channelId, request: $request" } logic.channel(channelType, channelId).apply { - updateStateFromDatabase(request) setPaginationDirection(request) + updateStateFromDatabase(request) } } 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 d3340d5c02d..dd10ee50053 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 @@ -24,7 +24,9 @@ import io.getstream.chat.android.client.channel.ChannelMessagesUpdateLogic import io.getstream.chat.android.client.errors.isPermanent import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.getCreatedAtOrNull +import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.QueryChannelPaginationRequest import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.toAnyChannelPaginationRequest import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl @@ -88,39 +90,36 @@ internal class ChannelLogicImpl( if (query.isFilteringMessages()) return // Populate from DB ONLY if loading latest messages val channel = fetchOfflineChannel(cid, query) ?: return + val localOnlyMessages = repository.selectLocalOnlyMessagesForChannel(cid) updateDataForChannel( channel = channel, messageLimit = query.messagesLimit(), + shouldRefreshMessages = true, // Note: The following arguments are NOT used. But they are kept for backwards compatibility. - shouldRefreshMessages = query.shouldRefresh, scrollUpdate = false, isNotificationUpdate = query.isNotificationUpdate, isChannelsStateUpdate = true, ) + // Set the currently known oldest message until online data is retrieved + state.paginationManager.setOldestMessage(channel.messages.lastOrNull()) + state.setLocalOnlyMessages(localOnlyMessages) } override fun setPaginationDirection(query: QueryChannelRequest) { - when { - query.filteringOlderMessages() -> state.setLoadingOlderMessages(true) - query.isFilteringNewerMessages() -> state.setLoadingNewerMessages(true) - } + state.paginationManager.begin(query) } override fun onQueryChannelResult(query: QueryChannelRequest, result: Result) { + val limit = query.messagesLimit() + val isNotificationUpdate = query.isNotificationUpdate + // Update pagination state only if it's not a notification update and the call was made for fetching messages + // (from LoadNotificationDataWorker) and a limit is set (otherwise we are not loading messages) + if (!isNotificationUpdate && limit != 0) { + state.paginationManager.end(query, result) + } when (result) { is Result.Success -> { - val limit = query.messagesLimit() val channel = result.value - val endReached = limit > channel.messages.size - val isNotificationUpdate = query.isNotificationUpdate - - // Update pagination/recovery state only if it's not a notification update - // (from LoadNotificationDataWorker) and a limit is set (otherwise we are not loading messages) - if (!isNotificationUpdate && limit != 0) { - state.setRecoveryNeeded(false) - updatePaginationEnd(query, endReached) - } - // Update channel data val channelData = channel.toChannelData() state.updateChannelData { @@ -145,18 +144,16 @@ internal class ChannelLogicImpl( } // Add pinned messages state.addPinnedMessages(channel.pinnedMessages) - // Update loading states - state.setLoadingOlderMessages(false) - state.setLoadingNewerMessages(false) + // Reset recovery state + if (!isNotificationUpdate && limit != 0) { + state.setRecoveryNeeded(false) + } } is Result.Failure -> { // Mark the channel as needing recovery if the error is not permanent val isPermanent = result.value.isPermanent() state.setRecoveryNeeded(recoveryNeeded = !isPermanent) - // Reset loading states - state.setLoadingOlderMessages(false) - state.setLoadingNewerMessages(false) } } } @@ -171,7 +168,6 @@ internal class ChannelLogicImpl( } override suspend fun loadAfter(messageId: String, limit: Int): Result { - state.setLoadingNewerMessages(true) val request = QueryChannelPaginationRequest(limit) .apply { messageFilterValue = messageId @@ -182,7 +178,6 @@ internal class ChannelLogicImpl( } override suspend fun loadBefore(messageId: String?, limit: Int): Result { - state.setLoadingOlderMessages(true) val messageId = messageId ?: state.getOldestMessage()?.id val request = QueryChannelPaginationRequest(limit) .apply { @@ -302,19 +297,42 @@ internal class ChannelLogicImpl( state.setChannelConfig(channel.config) // Set pending messages state.setPendingMessages(channel.pendingMessages.map(PendingMessage::message)) - // Reset messages (ensure they are sorted - when coming from DB) + // Update messages based on the relationship between the incoming page and existing state. if (messageLimit > 0) { val sortedMessages = withContext(Dispatchers.Default) { channel.messages.sortedBy { it.getCreatedAtOrNull() } } - state.setMessages(sortedMessages) - state.setEndOfOlderMessages(channel.messages.size < messageLimit) + val currentMessages = state.messages.value + when { + shouldRefreshMessages || currentMessages.isEmpty() -> { + // Initial load (DB seed or first fetch) or explicit refresh — full replace + state.setMessages(sortedMessages) + state.paginationManager.setEndOfOlderMessages(channel.messages.size < messageLimit) + } + state.insideSearch.value -> { + // 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) + } + 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.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.paginationManager.setEndOfOlderMessages(channel.messages.size < messageLimit) + } + } } // Add pinned messages state.addPinnedMessages(channel.pinnedMessages) - // Update loading states - state.setLoadingOlderMessages(false) - state.setLoadingNewerMessages(false) } override fun handleEvents(events: List) { @@ -328,53 +346,28 @@ internal class ChannelLogicImpl( } private suspend fun queryChannel(request: WatchChannelRequest): Result { + state.paginationManager.begin(request) val (type, id) = cid.cidToTypeAndId() return ChatClient.instance() .queryChannel(type, id, request, skipOnRequest = true) .await() } - private fun updatePaginationEnd(query: QueryChannelRequest, endReached: Boolean) { - when { - // Querying the newest messages (no pagination applied) - !query.isFilteringMessages() -> { - state.setEndOfOlderMessages(endReached) - state.setEndOfNewerMessages(true) - } - // Querying messages around a specific message - no way to know if we reached the end - query.isFilteringAroundIdMessages() -> { - state.setEndOfOlderMessages(false) - state.setEndOfNewerMessages(false) - } - // Querying older messages and reached the end - query.filteringOlderMessages() && endReached -> { - state.setEndOfOlderMessages(true) - } - // Querying newer messages and reached the end - query.isFilteringNewerMessages() && endReached -> { - state.setEndOfNewerMessages(true) - } - } - } - private fun updateMessages(query: QueryChannelRequest, channel: Channel) { when { !query.isFilteringMessages() -> { // Loading newest messages (no pagination): // 1. Clear any cached latest messages (we are replacing the whole list) // 2. Replace the active messages with the loaded ones - // 3. No pending messages ceiling — we are at the latest messages state.clearCachedLatestMessages() state.setMessages(channel.messages) state.setInsideSearch(false) - state.setNewestLoadedDate(null) } query.isFilteringAroundIdMessages() -> { // Loading messages around a specific message: // 1. Cache the current messages (for access to latest messages) (unless already inside search) // 2. Replace the active messages with the loaded ones - // 3. Set ceiling to newest in loaded page — pending messages newer than the page are hidden if (state.insideSearch.value) { // We are currently around a message, don't cache the latest messages, just replace the active set // Otherwise, the cached message set will wrongly hold the previous "around" set, instead of the @@ -386,7 +379,6 @@ internal class ChannelLogicImpl( state.setMessages(channel.messages) state.setInsideSearch(true) } - state.setNewestLoadedDate(channel.messages.lastOrNull()?.getCreatedAtOrNull()) } query.isFilteringNewerMessages() -> { @@ -395,13 +387,9 @@ internal class ChannelLogicImpl( state.trimOldestMessages() val endReached = query.messagesLimit() > channel.messages.size if (endReached) { - // Reached the latest messages — remove the ceiling + // Reached the latest messages state.clearCachedLatestMessages() state.setInsideSearch(false) - state.setNewestLoadedDate(null) - } else { - // Still paginating toward latest — advance ceiling to include newly loaded messages - state.advanceNewestLoadedDate(channel.messages.lastOrNull()?.getCreatedAtOrNull()) } } @@ -411,10 +399,6 @@ internal class ChannelLogicImpl( state.trimNewestMessages() } } - // Advance the oldest-loaded-date floor. Only queryChannel pagination (this path) should - // set this floor — updateDataForChannel (QueryChannels) must not, otherwise a channel-list - // preview would incorrectly filter out older pending messages. - state.advanceOldestLoadedDate(channel.messages) // Replace pending messages — server always returns the full latest set (up to 100, ASC). state.setPendingMessages(channel.pendingMessages.map { it.message }) } @@ -428,4 +412,14 @@ internal class ChannelLogicImpl( // Enrich the channel with messages return channel.copy(messages = messages) } + + private fun hasGap(currentMessages: List, incomingMessages: List): Boolean { + val currentNewest = currentMessages.maxByOrNull { it.getCreatedAtOrDefault(NEVER) } + val incomingOldest = incomingMessages.firstOrNull() + return currentMessages.isNotEmpty() && + currentNewest != null && + incomingOldest != null && + currentMessages.none { it.id == incomingOldest.id } && + incomingOldest.getCreatedAtOrDefault(NEVER).after(currentNewest.getCreatedAtOrDefault(NEVER)) + } } 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 25cc4bca93a..0c38583421b 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 @@ -37,6 +37,7 @@ import io.getstream.chat.android.client.internal.state.utils.internal.updateIf import io.getstream.chat.android.client.internal.state.utils.internal.upsertSorted import io.getstream.chat.android.client.internal.state.utils.internal.upsertSortedBounded import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt +import io.getstream.chat.android.client.utils.message.isEphemeral import io.getstream.chat.android.extensions.lastMessageAt import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelData @@ -75,6 +76,7 @@ import kotlin.math.max * @property liveLocations A [StateFlow] providing the active live locations. * @property messageLimit The initial limit specifying how many of the visible messages should be kept in memory. If * null, no limit is enforced. + * @property paginationManager The [MessagesPaginationManager] handling the pagination state tracking. */ @Suppress("LargeClass", "LongParameterList", "TooManyFunctions") internal class ChannelStateImpl( @@ -85,6 +87,7 @@ internal class ChannelStateImpl( private val mutedUsers: StateFlow>, private val liveLocations: StateFlow>, private val messageLimit: Int?, + val paginationManager: MessagesPaginationManager = MessagesPaginationManagerImpl(), ) : ChannelState { override val cid: String = "$channelType:$channelId" @@ -93,8 +96,9 @@ internal class ChannelStateImpl( private val _repliedMessage = MutableStateFlow(null) private val _quotedMessagesMap = MutableStateFlow>>(emptyMap()) private val _messages = MutableStateFlow>(emptyList()) - - private val pendingMessagesManager = PendingMessagesManager() + private val localOnlyMessages = MutableStateFlow>(emptyList()) + private val _pendingEnabled = MutableStateFlow(false) + private val _pendingMessages = MutableStateFlow>(emptyList()) /** * Keeps track of the latest messages in the channel, if `Jump to message` was called, and a different, non-latest @@ -125,11 +129,7 @@ internal class ChannelStateImpl( private val _channelConfig = MutableStateFlow(Config()) // Non-channel states - private val _loading = MutableStateFlow(false) - private val _loadingOlderMessages = MutableStateFlow(false) - private val _loadingNewerMessages = MutableStateFlow(false) - private val _endOfOlderMessages = MutableStateFlow(false) - private val _endOfNewerMessages = MutableStateFlow(true) + private val _loading = paginationManager.state.mapState(MessagesPaginationState::isLoadingMessages) private var _recoveryNeeded = false private val _insideSearch = MutableStateFlow(false) private var lastStartTypingEvent: Date? = null @@ -146,6 +146,20 @@ internal class ChannelStateImpl( /* Keeps track of messages processed when updating the current user read state */ private val processedMessageIds = LruCache(maxSize = 100) + /* The local-only messages fitting into the currently loaded range */ + private val localOnlyMessagesInRange: StateFlow> = + combineStates(localOnlyMessages, paginationManager.state) { localOnly, pagination -> + if (localOnly.isEmpty()) return@combineStates emptyList() + localOnly.filter { pagination.isInWindow(it) } + } + + /* The pending messages fitting into the currently loaded range */ + private val pendingMessagesInRange: StateFlow> = + combineStates(_pendingEnabled, _pendingMessages, paginationManager.state) { enabled, pending, pagination -> + if (!enabled || pending.isEmpty()) return@combineStates emptyList() + pending.filter { pagination.isInWindow(it) } + } + private val logger by taggedLogger("ChannelStateImpl") override val repliedMessage: StateFlow = _repliedMessage.asStateFlow() @@ -156,17 +170,30 @@ internal class ChannelStateImpl( override val messages: StateFlow> = combineStates( _messages, - pendingMessagesManager.pendingMessagesInRange, - ) { regular, pending -> - if (pending.isEmpty()) return@combineStates regular - regular.mergeSorted(pending, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + pendingMessagesInRange, + localOnlyMessagesInRange, + ) { regular, pending, localOnly -> + // Pending and local-only are most often empty — skip merge entirely + when { + pending.isEmpty() && localOnly.isEmpty() -> regular + // Only one extra list is non-empty — single merge with regular + pending.isEmpty() -> + localOnly.mergeSorted(regular, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + localOnly.isEmpty() -> + pending.mergeSorted(regular, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + // Both non-empty — merge pending+localOnly first, then with regular + else -> + pending + .mergeSorted(localOnly, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + .mergeSorted(regular, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + } } override val pinnedMessages: StateFlow> = _pinnedMessages.asStateFlow() override val messagesState: StateFlow = combineStates(_loading, messages) { loading, messages -> when { - loading -> MessagesState.Loading + loading && messages.isEmpty() -> MessagesState.Loading messages.isEmpty() -> MessagesState.OfflineNoResults else -> MessagesState.Result(messages) } @@ -216,15 +243,19 @@ internal class ChannelStateImpl( override val muted: StateFlow = _muted.asStateFlow() - override val loading: StateFlow = _loading.asStateFlow() + override val loading: StateFlow = _loading - override val loadingOlderMessages: StateFlow = _loadingOlderMessages.asStateFlow() + override val loadingOlderMessages: StateFlow = + paginationManager.state.mapState(MessagesPaginationState::isLoadingPreviousMessages) - override val loadingNewerMessages: StateFlow = _loadingNewerMessages.asStateFlow() + override val loadingNewerMessages: StateFlow = + paginationManager.state.mapState(MessagesPaginationState::isLoadingNextMessages) - override val endOfOlderMessages: StateFlow = _endOfOlderMessages.asStateFlow() + override val endOfOlderMessages: StateFlow = + paginationManager.state.mapState(MessagesPaginationState::hasLoadedAllPreviousMessages) - override val endOfNewerMessages: StateFlow = _endOfNewerMessages.asStateFlow() + override val endOfNewerMessages: StateFlow = + paginationManager.state.mapState(MessagesPaginationState::hasLoadedAllNextMessages) override val recoveryNeeded: Boolean get() = _recoveryNeeded @@ -309,6 +340,16 @@ internal class ChannelStateImpl( comparator = MESSAGE_COMPARATOR, ) } + // Update can be called for "ephemeral" messages (ex. Shuffle Giphy) + if (message.isEphemeral()) { + localOnlyMessages.update { current -> + current.upsertSorted( + element = message, + idSelector = Message::id, + comparator = MESSAGE_COMPARATOR, + ) + } + } } /** @@ -398,6 +439,9 @@ internal class ChannelStateImpl( _pinnedMessages.update { current -> current.filterNot { id == it.id } } + localOnlyMessages.update { current -> + current.filterNot { id == it.id } + } } /** @@ -481,12 +525,21 @@ internal class ChannelStateImpl( } /** - * Retrieves the oldest (non-pending) message. + * Retrieves the oldest (non-pending, non-local-only) message. */ fun getOldestMessage(): Message? { return _messages.value.firstOrNull() } + /** + * Sets the state for the local-only messages. + * + * @param messages The local-only list of messages. + */ + fun setLocalOnlyMessages(messages: List) { + localOnlyMessages.update { messages } + } + // endregion // region PendingMessages @@ -496,33 +549,19 @@ internal class ChannelStateImpl( * channel response returns the latest 100 pending messages sorted by createdAt ASC, so we * always replace rather than merge. */ - fun setPendingMessages(messages: List) = pendingMessagesManager.setPendingMessages(messages) + fun setPendingMessages(messages: List) { + _pendingMessages.value = messages + } /** * Removes a single pending message by [id]. Called when a pending message is promoted to a * regular message (message.new event) or deleted (message.deleted event). */ - fun removePendingMessage(id: String) = pendingMessagesManager.removePendingMessage(id) - - /** - * Advances the oldest-loaded-date floor to the oldest date in [messages] if it is earlier - * than the current floor. The floor only ever moves backward — must only be called from - * paginated channel queries, never from a full channel data update. - */ - fun advanceOldestLoadedDate(messages: List) = pendingMessagesManager.advanceOldestLoadedDate(messages) - - /** - * Sets the newest-loaded-date ceiling to [date]. Pass `null` to remove the ceiling, i.e. - * when the user is viewing the latest messages. Set to the newest message date when jumping - * to a specific message so that newer pending messages are hidden. - */ - fun setNewestLoadedDate(date: Date?) = pendingMessagesManager.setNewestLoadedDate(date) - - /** - * Advances the newest-loaded-date ceiling forward if [date] is more recent than the current - * ceiling. Used when paginating toward newer messages while still not at the latest page. - */ - fun advanceNewestLoadedDate(date: Date?) = pendingMessagesManager.advanceNewestLoadedDate(date) + fun removePendingMessage(id: String) { + _pendingMessages.update { current -> + if (current.none { it.id == id }) current else current.filterNot { it.id == id } + } + } // endregion @@ -1178,7 +1217,9 @@ internal class ChannelStateImpl( */ fun setChannelConfig(config: Config) { _channelConfig.value = config - pendingMessagesManager.setEnabled(config.markMessagesPending) + val enabled = config.markMessagesPending + if (!enabled) _pendingMessages.value = emptyList() + _pendingEnabled.value = enabled } // endregion @@ -1278,51 +1319,6 @@ internal class ChannelStateImpl( // region NonChannelStates - /** - * Sets the loading state. - * - * @param loading `true` if loading, `false` otherwise. - */ - fun setLoading(loading: Boolean) { - _loading.value = loading - } - - /** - * Sets the loading older messages state. - * - * @param loadingOlderMessages `true` if loading older messages, `false` otherwise. - */ - fun setLoadingOlderMessages(loadingOlderMessages: Boolean) { - _loadingOlderMessages.value = loadingOlderMessages - } - - /** - * Sets the loading newer messages state. - * - * @param loadingNewerMessages `true` if loading newer messages, `false` otherwise. - */ - fun setLoadingNewerMessages(loadingNewerMessages: Boolean) { - _loadingNewerMessages.value = loadingNewerMessages - } - - /** - * Sets the end of older messages state. - * - * @param endOfOlderMessages `true` if there are no more older messages to load, `false` otherwise. - */ - fun setEndOfOlderMessages(endOfOlderMessages: Boolean) { - _endOfOlderMessages.value = endOfOlderMessages - } - - /** - * Sets the end of newer messages state. - * - * @param endOfNewerMessages `true` if there are no more newer messages to load, `false` otherwise. - */ - fun setEndOfNewerMessages(endOfNewerMessages: Boolean) { - _endOfNewerMessages.value = endOfNewerMessages - } - /** * Trims messages from the oldest end if the limit is exceeded. * Call after loading newer messages or receiving new messages via WebSocket while at the end of the list. @@ -1361,14 +1357,16 @@ internal class ChannelStateImpl( when (direction) { TrimDirection.FROM_OLDEST -> { _messages.update { it.takeLast(limit) } - _endOfOlderMessages.value = false + paginationManager.setEndOfOlderMessages(false) + paginationManager.setOldestMessage(_messages.value.firstOrNull()) } TrimDirection.FROM_NEWEST -> { // Cache the latest messages before trimming to preserve them for later cacheLatestMessages() _messages.update { it.take(limit) } - _endOfNewerMessages.value = false + paginationManager.setEndOfNewerMessages(false) + paginationManager.setNewestMessage(_messages.value.lastOrNull()) _insideSearch.value = true } } @@ -1411,6 +1409,26 @@ internal class ChannelStateImpl( _cachedLatestMessages.value = emptyList() } + /** + * Merges [messages] into the cached latest messages, replacing any existing entry + * with the same id and capping the list at [CACHED_LATEST_MESSAGES_LIMIT]. + * + * 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. + */ + fun upsertCachedLatestMessages(messages: List) { + if (messages.isEmpty()) return + val messagesToUpsert = messages.filterNot { shouldIgnoreUpsertion(it) } + if (messagesToUpsert.isEmpty()) return + _cachedLatestMessages.update { current -> + current.mergeSorted( + other = messagesToUpsert, + idSelector = Message::id, + comparator = MESSAGE_COMPARATOR, + ).takeLast(CACHED_LATEST_MESSAGES_LIMIT) + } + } + // endregion // region Destroy @@ -1425,7 +1443,8 @@ internal class ChannelStateImpl( _repliedMessage.value = null _quotedMessagesMap.value = emptyMap() _messages.value = emptyList() - pendingMessagesManager.reset() + _pendingMessages.value = emptyList() + _pendingEnabled.value = false _cachedLatestMessages.value = emptyList() _pinnedMessages.value = emptyList() _oldMessages.value = emptyList() @@ -1451,11 +1470,7 @@ internal class ChannelStateImpl( _channelConfig.value = Config() // Non-channel states - _loading.value = false - _loadingOlderMessages.value = false - _loadingNewerMessages.value = false - _endOfOlderMessages.value = false - _endOfNewerMessages.value = true + paginationManager.reset() _recoveryNeeded = false _insideSearch.value = false lastStartTypingEvent = null diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt new file mode 100644 index 00000000000..dc9ca2bda14 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt @@ -0,0 +1,262 @@ +/* + * 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.client.api.models.QueryChannelRequest +import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault +import io.getstream.chat.android.client.extensions.internal.NEVER +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Message +import io.getstream.result.Result +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.Date + +/** + * Keeps track of the current channel messages pagination state. + * + * @param oldestMessage The oldest fetched message while paginating. + * @param newestMessage The newest fetched message while paginating. + * @param hasLoadedAllNextMessages Indicator whether the newest messages have all been loaded. If false, it means the + * channel is currently in a mid-page. + * @param hasLoadedAllPreviousMessages Indicator whether the oldest messages have been loaded. + * @param isLoadingNextMessages Indicator whether the channel is currently loading next (newer) messages. + * @param isLoadingPreviousMessages Indicator whether the channel is currently loading previous (older) messages. + * @param isLoadingMiddleMessages Indicator whether the channel is currently loading a page around a message. + */ +internal data class MessagesPaginationState( + val oldestMessage: Message? = null, + val newestMessage: Message? = null, + val hasLoadedAllNextMessages: Boolean = true, + val hasLoadedAllPreviousMessages: Boolean = false, + val isLoadingNextMessages: Boolean = false, + val isLoadingPreviousMessages: Boolean = false, + val isLoadingMiddleMessages: Boolean = false, +) { + + /** + * Indicator whether the channel is currently loading messages on either previous, middle or next pages. + */ + val isLoadingMessages: Boolean + get() = isLoadingNextMessages || isLoadingPreviousMessages || isLoadingMiddleMessages + + /** + * Indicator if the channel is currently mid-page. + */ + val isJumpingToMessage: Boolean + get() = !hasLoadedAllNextMessages + + /** + * The oldest fetched message createdAt date while paginating. + */ + val oldestMessageAt: Date? + get() = oldestMessage?.createdAt + + /** + * The newest fetched message createdAt date while paginating. + */ + val newestMessageAt: Date? + get() = newestMessage?.createdAt + + /** + * Returns true if [Message] falls within the currently loaded pagination window. + * + * The floor is [oldestMessageAt] (null = no floor). The ceiling is [newestMessageAt] (null = at + * the latest page, no ceiling). + */ + fun isInWindow(message: Message): Boolean { + val date = message.getCreatedAtOrDefault(NEVER) + return (oldestMessageAt == null || date >= oldestMessageAt) && + (newestMessageAt == null || date <= newestMessageAt) + } +} + +/** + * State manager for the channel pagination state. + */ +internal interface MessagesPaginationManager { + + /** + * The current state of the messages pagination. + */ + val state: StateFlow + + /** + * Called whenever a pagination call is about to happen. + * + * @param query The pagination request. + */ + fun begin(query: QueryChannelRequest) + + /** + * Called whenever a pagination call has finished. + * + * @param query The pagination request. + * @param result The pagination result. + */ + fun end(query: QueryChannelRequest, result: Result) + + /** + * Sets the oldest [message] to the pagination state. + */ + fun setOldestMessage(message: Message?) + + /** + * Sets the newest [message] (ceiling) in the pagination state. + * Pass null to indicate the latest page has no ceiling. + */ + fun setNewestMessage(message: Message?) + + /** + * Sets whether all older (previous) messages have been loaded. + */ + fun setEndOfOlderMessages(hasLoadedAll: Boolean) + + /** + * Sets whether all newer (next) messages have been loaded. + * When [hasLoadedAll] is true, the newest-message ceiling is cleared. + */ + fun setEndOfNewerMessages(hasLoadedAll: Boolean) + + /** + * Resets pagination state back to its initial defaults. + */ + fun reset() +} + +/** + * Default implementation of the [MessagesPaginationManager]. + */ +internal class MessagesPaginationManagerImpl : MessagesPaginationManager { + + private val _state: MutableStateFlow = MutableStateFlow(MessagesPaginationState()) + + override val state: StateFlow + get() = _state.asStateFlow() + + override fun begin(query: QueryChannelRequest) { + val current = _state.value + val new = when { + query.filteringOlderMessages() -> { + current.copy(isLoadingPreviousMessages = true) + } + query.isFilteringNewerMessages() -> { + current.copy(isLoadingNextMessages = true) + } + query.isFilteringAroundIdMessages() -> { + current.copy(isLoadingMiddleMessages = true, hasLoadedAllNextMessages = false) + } + else -> { + MessagesPaginationState() + } + } + _state.update { new } + } + + override fun end(query: QueryChannelRequest, result: Result) { + // Failure + if (result is Result.Failure) { + _state.update { current -> + current.copy( + isLoadingNextMessages = false, + isLoadingPreviousMessages = false, + isLoadingMiddleMessages = false, + ) + } + return + } + // Success + result as Result.Success + val current = _state.value + val messages = result.value.messages + val oldestMessage = messages.firstOrNull() + val newestMessage = messages.lastOrNull() + val new = when { + // Loading older + query.filteringOlderMessages() -> { + val hasLoadedAllPreviousMessages = messages.size < query.messagesLimit() + current.copy( + oldestMessage = oldestMessage, + hasLoadedAllPreviousMessages = hasLoadedAllPreviousMessages, + isLoadingNextMessages = false, + isLoadingPreviousMessages = false, + isLoadingMiddleMessages = false, + ) + } + // Loading newer + query.isFilteringNewerMessages() -> { + val hasLoadedAllNextMessages = messages.size < query.messagesLimit() + current.copy( + newestMessage = if (hasLoadedAllNextMessages) null else newestMessage, + hasLoadedAllNextMessages = hasLoadedAllNextMessages, + isLoadingNextMessages = false, + isLoadingPreviousMessages = false, + isLoadingMiddleMessages = false, + ) + } + // Loading around + query.isFilteringAroundIdMessages() -> { + current.copy( + oldestMessage = oldestMessage, + newestMessage = newestMessage, + hasLoadedAllNextMessages = false, + hasLoadedAllPreviousMessages = false, + isLoadingNextMessages = false, + isLoadingPreviousMessages = false, + isLoadingMiddleMessages = false, + ) + } + // Else - no pagination + else -> { + current.copy( + oldestMessage = oldestMessage, + newestMessage = null, + hasLoadedAllNextMessages = true, + hasLoadedAllPreviousMessages = messages.size < query.messagesLimit(), + ) + } + } + _state.update { new } + } + + override fun setOldestMessage(message: Message?) { + _state.update { it.copy(oldestMessage = message) } + } + + override fun setNewestMessage(message: Message?) { + _state.update { it.copy(newestMessage = message) } + } + + override fun setEndOfOlderMessages(hasLoadedAll: Boolean) { + _state.update { it.copy(hasLoadedAllPreviousMessages = hasLoadedAll) } + } + + override fun setEndOfNewerMessages(hasLoadedAll: Boolean) { + _state.update { current -> + current.copy( + hasLoadedAllNextMessages = hasLoadedAll, + newestMessage = if (hasLoadedAll) null else current.newestMessage, + ) + } + } + + override fun reset() { + _state.value = MessagesPaginationState() + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManager.kt deleted file mode 100644 index 58f21ec27f1..00000000000 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManager.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * 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.client.extensions.getCreatedAtOrNull -import io.getstream.chat.android.client.internal.state.utils.internal.combineStates -import io.getstream.chat.android.models.Message -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import java.util.Date - -/** - * Encapsulates all pending-message state and logic for a channel. - * - * Pending messages are messages awaiting moderation approval, kept separate from regular messages - * and merged into the public message list at their natural position in the timeline. - * - * The feature is gated by [setEnabled]: when disabled, [pendingMessagesInRange] always emits an - * empty list and all buffered state is cleared. - */ -internal class PendingMessagesManager { - - private val _enabled = MutableStateFlow(false) - private val _pendingMessages = MutableStateFlow>(emptyList()) - private val _dateRange = MutableStateFlow(DateRange(oldest = null, newest = null)) - - /** - * Filtered pending messages ready for merging into the regular message list. - * Returns an empty list when disabled or no pending messages fall within the loaded window. - */ - val pendingMessagesInRange: StateFlow> = combineStates( - _enabled, - _pendingMessages, - _dateRange, - ) { enabled, pending, range -> - if (!enabled || pending.isEmpty()) return@combineStates emptyList() - pending.filter { msg -> - val date = msg.getCreatedAtOrNull() ?: Date(0) - (range.oldest == null || date >= range.oldest) && - (range.newest == null || date <= range.newest) - } - } - - /** - * Enables or disables the pending-messages feature. Clears all buffered state before - * disabling so no stale data leaks through [pendingMessagesInRange]. - */ - fun setEnabled(enabled: Boolean) { - if (!enabled) clear() - _enabled.value = enabled - } - - /** - * Replaces the pending messages list. The server is authoritative — every channel response - * returns the latest 100 pending messages sorted by createdAt ASC, so we always replace. - */ - fun setPendingMessages(messages: List) { - _pendingMessages.value = messages.sortedWith(MESSAGE_COMPARATOR) - } - - /** - * Removes a single pending message by ID. Called when a pending message is promoted to a - * regular message (message.new event) or deleted (message.deleted event). - */ - fun removePendingMessage(id: String) { - _pendingMessages.update { current -> - if (current.none { it.id == id }) { - current - } else { - current.filterNot { it.id == id } - } - } - } - - /** - * Advances the floor of the date range to the oldest message date if it is older than the - * current floor. The floor only ever moves backward. - */ - fun advanceOldestLoadedDate(messages: List) { - val newOldest = messages.firstOrNull()?.getCreatedAtOrNull() ?: return - _dateRange.update { current -> - if (current.oldest == null || newOldest < current.oldest) { - current.copy(oldest = newOldest) - } else { - current - } - } - } - - /** - * Sets the ceiling of the date range to the given date. Pass null to remove the ceiling - * (i.e. when viewing the latest messages). - */ - fun setNewestLoadedDate(date: Date?) { - _dateRange.update { it.copy(newest = date) } - } - - /** - * Advances the ceiling of the date range forward if [date] is newer than the current ceiling. - * Used when loading newer pages while still not at the latest messages. - */ - fun advanceNewestLoadedDate(date: Date?) { - if (date == null) return - _dateRange.update { current -> - if (current.newest == null || date > current.newest) { - current.copy(newest = date) - } else { - current - } - } - } - - /** Clears all buffered state. Called by [setEnabled] when disabling and by [ChannelStateImpl.destroy]. */ - fun reset() = clear() - - private fun clear() { - _pendingMessages.value = emptyList() - _dateRange.value = DateRange(oldest = null, newest = null) - } - - private data class DateRange(val oldest: Date?, val newest: Date?) - - private companion object { - private val MESSAGE_COMPARATOR: Comparator = compareBy { it.getCreatedAtOrNull() } - } -} 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 621dc86dfb2..941df3472e7 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 @@ -199,4 +199,15 @@ public interface MessageRepository { * Clear messages of this repository. */ public suspend fun clear() + + /** + * Returns all messages for [cid] that are local-only: syncStatus is one of + * SYNC_NEEDED, IN_PROGRESS, AWAITING_ATTACHMENTS, FAILED_PERMANENTLY, or type is + * "ephemeral" or "error". Used by the preservation mechanism before a server response + * replaces the active message window. + * + * @param cid The channel ID (format "type:id"). + * @return List of local-only messages, unordered. + */ + public suspend fun selectLocalOnlyMessagesForChannel(cid: String): List } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt index 6d36501a469..e29bfecccb4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt @@ -44,8 +44,6 @@ internal object NoOpChannelRepository : ChannelRepository { override suspend fun selectMembersForChannel(cid: String): List = emptyList() override suspend fun updateMembersForChannel(cid: String, members: List) { /* No-Op */ } override suspend fun updateLastMessageForChannel(cid: String, lastMessage: Message) { /* No-Op */ } - override suspend fun evictChannel(cid: String) { /* No-Op */ } - override suspend fun clear() { /* No-Op */ } } 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 09cad10631c..5adaaccda6c 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 @@ -57,4 +57,5 @@ 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 selectLocalOnlyMessagesForChannel(cid: String): List = emptyList() } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt index 6ff38793da3..68e9f67fad8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt @@ -224,3 +224,32 @@ internal fun DraftMessage.ensureId(): DraftMessage = * Generates a fallback message id (lowercase UUID). */ internal fun fallbackMessageId(): String = UUID.randomUUID().toString().lowercase() + +/** + * Returns true if this message is local-only and must be preserved across server message + * window replacements. Local-only messages are never returned by the server after the + * initial send attempt completes. + * + * Covers: + * - Pending sends: SYNC_NEEDED, IN_PROGRESS + * - Attachment upload in-flight: AWAITING_ATTACHMENTS + * - Send failed: FAILED_PERMANENTLY (user must see to retry or dismiss) + * - Ephemeral: type == "ephemeral" (e.g. Giphy previews — not re-delivered by server) + * - Error type: type == "error" (client-generated, not re-delivered by server) + * + * DOES NOT include COMPLETED messages — those are already in the server response. + */ +internal fun Message.isLocalOnly(): Boolean = + syncStatus in LocalOnlySyncStatuses || type in LocalOnlyMessageTypes + +internal val LocalOnlySyncStatuses = setOf( + SyncStatus.SYNC_NEEDED, // new message or pending edit/delete + SyncStatus.IN_PROGRESS, // send in flight + SyncStatus.AWAITING_ATTACHMENTS, // attachment upload pending + SyncStatus.FAILED_PERMANENTLY, // permanent failure — user must see to retry +) + +internal val LocalOnlyMessageTypes = setOf( + MessageType.EPHEMERAL, // giphy preview etc. — not re-delivered by server + MessageType.ERROR, // error type — not re-delivered by server +) 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 a3b2d662136..21662d9b082 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 @@ -309,6 +309,8 @@ internal class MockMessageRepository : MessageRepository { // No-op } + override suspend fun selectLocalOnlyMessagesForChannel(cid: String): List = emptyList() + override suspend fun clear() { messages.clear() } 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 3951b1cc1a2..bd4c0bae4a2 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 @@ -20,6 +20,7 @@ import io.getstream.chat.android.client.api.models.Pagination import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.channel.ChannelMessagesUpdateLogic import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl +import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MessagesPaginationManager 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.models.ChannelData @@ -64,6 +65,8 @@ internal class ChannelLogicImplTest { val testCoroutines = TestCoroutineExtension() } + private lateinit var paginationManager: MessagesPaginationManager + private lateinit var stateImpl: ChannelStateImpl private lateinit var repository: RepositoryFacade private lateinit var mutableGlobalState: MutableGlobalState @@ -75,6 +78,7 @@ internal class ChannelLogicImplTest { @BeforeEach fun setUp() { + paginationManager = mock() stateImpl = mock() repository = mock() mutableGlobalState = mock() @@ -86,6 +90,7 @@ internal class ChannelLogicImplTest { whenever(stateImpl.channelConfig).thenReturn(MutableStateFlow(Config())) whenever(stateImpl.messages).thenReturn(MutableStateFlow(emptyList())) whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(false)) + whenever(stateImpl.paginationManager).thenReturn(paginationManager) // Stub global state whenever(mutableGlobalState.channelMutes).thenReturn(MutableStateFlow(emptyList())) @@ -127,36 +132,13 @@ internal class ChannelLogicImplTest { inner class SetPaginationDirection { @Test - fun `should set loading older messages when filtering older messages`() { + fun `setPaginationDirection delegates to MessagesPaginationManager`() { // Given val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30) // When sut.setPaginationDirection(query) // Then - verify(stateImpl).setLoadingOlderMessages(true) - verify(stateImpl, never()).setLoadingNewerMessages(true) - } - - @Test - fun `should set loading newer messages when filtering newer messages`() { - // Given - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30) - // When - sut.setPaginationDirection(query) - // Then - verify(stateImpl).setLoadingNewerMessages(true) - verify(stateImpl, never()).setLoadingOlderMessages(true) - } - - @Test - fun `should not set any loading state when not filtering messages`() { - // Given - val query = QueryChannelRequest().withMessages(30) - // When - sut.setPaginationDirection(query) - // Then - verify(stateImpl, never()).setLoadingOlderMessages(true) - verify(stateImpl, never()).setLoadingNewerMessages(true) + verify(paginationManager).begin(query) } } @@ -391,11 +373,11 @@ internal class ChannelLogicImplTest { watcherCount = 0, ) val query = QueryChannelRequest().withMessages(30) + val result = Result.Success(channel) // When - sut.onQueryChannelResult(query, Result.Success(channel)) + sut.onQueryChannelResult(query, result) // Then - verify(stateImpl).setLoadingOlderMessages(false) - verify(stateImpl).setLoadingNewerMessages(false) + verify(paginationManager).end(query, result) } @Test @@ -425,122 +407,6 @@ internal class ChannelLogicImplTest { // endregion - // region onQueryChannelResult - Success - Pagination end - - @Nested - inner class OnQueryChannelResultPaginationEnd { - - @Test - fun `should set end of older messages when loading latest and end reached`() { - // Given (limit=30, messages.size=10, so endReached=true) - val messages = (1..10).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfOlderMessages(true) - verify(stateImpl).setEndOfNewerMessages(true) - } - - @Test - fun `should not set end of older messages when loading latest and not reached`() { - // Given (limit=10, messages.size=10, so endReached=false) - val messages = (1..10).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(10) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfOlderMessages(false) - verify(stateImpl).setEndOfNewerMessages(true) - } - - @Test - fun `should set both ends to false when filtering around id`() { - // Given - val messages = (1..5).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "m3", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfOlderMessages(false) - verify(stateImpl).setEndOfNewerMessages(false) - } - - @Test - fun `should set end of older messages when filtering older and end reached`() { - // Given (limit=30, messages.size=5, so endReached=true) - val messages = (1..5).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m10", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfOlderMessages(true) - } - - @Test - fun `should set end of newer messages when filtering newer and end reached`() { - // Given (limit=30, messages.size=5, so endReached=true) - val messages = (1..5).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m1", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfNewerMessages(true) - } - } - - // endregion - // region onQueryChannelResult - Success - Message updates @Nested @@ -618,7 +484,7 @@ internal class ChannelLogicImplTest { @Test fun `should upsert messages and trim oldest when loading newer messages`() { - // Given + // Given — limit=30, messages.size=30 → endReached=false val messages = (1..30).map { randomMessage(id = "m$it") } val channel = randomChannel( id = "123", @@ -633,7 +499,6 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then verify(stateImpl).upsertMessages(messages) verify(stateImpl).trimOldestMessages() } @@ -743,11 +608,11 @@ internal class ChannelLogicImplTest { // Given val error = Error.GenericError("Error") val query = QueryChannelRequest().withMessages(30) + val result = Result.Failure(error) // When - sut.onQueryChannelResult(query, Result.Failure(error)) + sut.onQueryChannelResult(query, result) // Then - verify(stateImpl).setLoadingOlderMessages(false) - verify(stateImpl).setLoadingNewerMessages(false) + verify(paginationManager).end(query, result) } } @@ -813,6 +678,39 @@ internal class ChannelLogicImplTest { verify(repository).selectChannel(cid) verify(stateImpl).updateChannelData(any<(ChannelData?) -> ChannelData?>()) } + + @Test + fun `should set oldest message and local-only messages on state after loading from database`() = runTest { + // Given + val dbChannel = randomChannel(id = "123", type = "messaging", messages = emptyList()) + val olderMessage = randomMessage(id = "m1", createdAt = Date(1000)) + val newerMessage = randomMessage(id = "m2", createdAt = Date(2000)) + val messages = listOf(newerMessage, olderMessage) + val localOnlyMessages = listOf(randomMessage(id = "lo1"), randomMessage(id = "lo2")) + val query = QueryChannelRequest().withMessages(30) + whenever(repository.selectChannel(cid)).thenReturn(dbChannel) + whenever(repository.selectMessagesForChannel(any(), any())).thenReturn(messages) + whenever(repository.selectLocalOnlyMessagesForChannel(cid)).thenReturn(localOnlyMessages) + // When + sut.updateStateFromDatabase(query) + // Then + verify(paginationManager).setOldestMessage(olderMessage) + verify(repository).selectLocalOnlyMessagesForChannel(cid) + verify(stateImpl).setLocalOnlyMessages(localOnlyMessages) + } + + @Test + fun `should set oldest message as null when no messages are loaded from database`() = runTest { + // Given + val dbChannel = randomChannel(id = "123", type = "messaging", messages = emptyList()) + val query = QueryChannelRequest().withMessages(30) + whenever(repository.selectChannel(cid)).thenReturn(dbChannel) + whenever(repository.selectMessagesForChannel(any(), any())).thenReturn(emptyList()) + // When + sut.updateStateFromDatabase(query) + // Then + verify(paginationManager).setOldestMessage(null) + } } // endregion @@ -1279,7 +1177,7 @@ internal class ChannelLogicImplTest { // When sut.updateDataForChannel(channel = channel, messageLimit = 30) // Then - verify(stateImpl).setEndOfOlderMessages(true) + verify(paginationManager).setEndOfOlderMessages(true) } @Test @@ -1299,7 +1197,7 @@ internal class ChannelLogicImplTest { // When sut.updateDataForChannel(channel = channel, messageLimit = 2) // Then - verify(stateImpl).setEndOfOlderMessages(false) + verify(paginationManager).setEndOfOlderMessages(false) } @Test @@ -1320,7 +1218,7 @@ internal class ChannelLogicImplTest { sut.updateDataForChannel(channel = channel, messageLimit = 0) // Then verify(stateImpl, never()).setMessages(any()) - verify(stateImpl, never()).setEndOfOlderMessages(any()) + verify(paginationManager, never()).setEndOfOlderMessages(any()) } @Test @@ -1343,26 +1241,6 @@ internal class ChannelLogicImplTest { verify(stateImpl).addPinnedMessages(pinnedMessages) } - @Test - fun `should reset loading states`() = runTest { - // Given - val channel = randomChannel( - id = "123", - type = "messaging", - messages = emptyList(), - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - // When - sut.updateDataForChannel(channel = channel, messageLimit = 0) - // Then - verify(stateImpl).setLoadingOlderMessages(false) - verify(stateImpl).setLoadingNewerMessages(false) - } - @Test fun `should set pending messages from channel response`() = runTest { // Given @@ -1384,195 +1262,126 @@ internal class ChannelLogicImplTest { // Then verify(stateImpl).setPendingMessages(listOf(pendingMsg)) } - } - - // endregion - - // region pending date range — onQueryChannelResult - - @Nested - inner class PendingDateRange { @Test - fun `should call setPendingMessages when loading latest messages`() { - // Given - val pendingMsg = randomMessage(id = "pm1") + fun `should upsert messages when state has messages and incoming are contiguous`() = runTest { + val existingMsg = randomMessage(id = "existing", createdAt = Date(1000L), createdLocallyAt = null) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + val incomingMsg = randomMessage(id = "new", createdAt = Date(500L), createdLocallyAt = null) val channel = randomChannel( id = "123", type = "messaging", - messages = emptyList(), + messages = listOf(incomingMsg), members = emptyList(), watchers = emptyList(), read = emptyList(), memberCount = 0, watcherCount = 0, - pendingMessages = listOf(randomPendingMessage(message = pendingMsg)), ) - val query = QueryChannelRequest().withMessages(30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl, atLeastOnce()).setPendingMessages(listOf(pendingMsg)) + sut.updateDataForChannel(channel = channel, messageLimit = 30) + verify(stateImpl).upsertMessages(listOf(incomingMsg)) + verify(stateImpl, never()).setMessages(any()) + verify(stateImpl, never()).upsertCachedLatestMessages(any()) + verify(paginationManager, never()).setEndOfNewerMessages(any()) } @Test - fun `should call setNewestLoadedDate with null when loading latest messages`() { - // Given + fun `should cache incoming and signal newer messages when gap is detected`() = runTest { + val existingMsg = randomMessage(id = "old", createdAt = Date(1000L), createdLocallyAt = null) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) val channel = randomChannel( id = "123", type = "messaging", - messages = emptyList(), + messages = listOf(incomingMsg), members = emptyList(), watchers = emptyList(), read = emptyList(), memberCount = 0, watcherCount = 0, - pendingMessages = emptyList(), ) - val query = QueryChannelRequest().withMessages(30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setNewestLoadedDate(null) + sut.updateDataForChannel(channel = channel, messageLimit = 30) + verify(stateImpl).upsertCachedLatestMessages(listOf(incomingMsg)) + verify(stateImpl).setInsideSearch(true) + verify(paginationManager).setEndOfNewerMessages(false) + verify(stateImpl, never()).setMessages(any()) + verify(stateImpl, never()).upsertMessages(any()) + verify(paginationManager, never()).setEndOfOlderMessages(any()) } @Test - fun `should call setNewestLoadedDate with newest message date when loading around id`() { - // Given - whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(false)) - val newestDate = Date(2000L) - val messages = listOf( - randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null), - randomMessage(id = "m2", createdAt = newestDate, createdLocallyAt = null), - ) + fun `should refresh cached latest messages when already inside search`() = runTest { + val existingMsg = randomMessage(id = "mid", createdAt = Date(1000L), createdLocallyAt = null) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(true)) + val incomingMsg = randomMessage(id = "latest", createdAt = Date(5000L), createdLocallyAt = null) val channel = randomChannel( id = "123", type = "messaging", - messages = messages, + messages = listOf(incomingMsg), members = emptyList(), watchers = emptyList(), read = emptyList(), memberCount = 0, watcherCount = 0, - pendingMessages = emptyList(), ) - val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "m1", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setNewestLoadedDate(newestDate) + sut.updateDataForChannel(channel = channel, messageLimit = 30) + verify(stateImpl).upsertCachedLatestMessages(listOf(incomingMsg)) + verify(stateImpl, never()).setMessages(any()) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).setInsideSearch(any()) + verify(paginationManager, never()).setEndOfNewerMessages(any()) } @Test - fun `should call setNewestLoadedDate with null when paginating newer reaches the end`() { - // Given (limit=30, messages.size=5, so endReached=true) - val messages = (1..5).map { randomMessage(id = "m$it") } + fun `should replace messages when shouldRefreshMessages is true regardless of existing state`() = runTest { + val existingMsg = randomMessage(id = "old", createdAt = Date(1000L), createdLocallyAt = null) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) val channel = randomChannel( id = "123", type = "messaging", - messages = messages, + messages = listOf(incomingMsg), members = emptyList(), watchers = emptyList(), read = emptyList(), memberCount = 0, watcherCount = 0, - pendingMessages = emptyList(), ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setNewestLoadedDate(null) + sut.updateDataForChannel(channel = channel, messageLimit = 30, shouldRefreshMessages = true) + verify(stateImpl).setMessages(listOf(incomingMsg)) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).upsertCachedLatestMessages(any()) } + } - @Test - fun `should call advanceNewestLoadedDate when paginating newer and not at end`() { - // Given (limit=5, messages.size=5, so endReached=false) - val newestDate = Date(5000L) - val messages = (1..5).map { i -> - randomMessage(id = "m$i", createdAt = Date(i * 1000L), createdLocallyAt = null) - } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 5) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).advanceNewestLoadedDate(newestDate) - } + // endregion - @Test - fun `should call advanceOldestLoadedDate with channel messages when loading latest`() { - // Given - val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - val query = QueryChannelRequest().withMessages(30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).advanceOldestLoadedDate(messages) - } + // region pending date range — onQueryChannelResult + + @Nested + inner class PendingDateRange { @Test - fun `should call advanceOldestLoadedDate with channel messages when loading older`() { + fun `should call setPendingMessages when loading latest messages`() { // Given - val messages = (1..10).map { randomMessage(id = "m$it") } + val pendingMsg = randomMessage(id = "pm1") val channel = randomChannel( id = "123", type = "messaging", - messages = messages, + messages = emptyList(), members = emptyList(), watchers = emptyList(), read = emptyList(), memberCount = 0, watcherCount = 0, - pendingMessages = emptyList(), + pendingMessages = listOf(randomPendingMessage(message = pendingMsg)), ) - val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m20", 30) + val query = QueryChannelRequest().withMessages(30) // When sut.onQueryChannelResult(query, Result.Success(channel)) // Then - verify(stateImpl).advanceOldestLoadedDate(messages) - } - - @Test - fun `should NOT call advanceOldestLoadedDate from updateDataForChannel`() = runTest { - // Given — updateDataForChannel is the QueryChannels path, not the paginated path - val messages = listOf(randomMessage(id = "m1")) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - // When - sut.updateDataForChannel(channel = channel, messageLimit = 30) - // Then — floor must only be set from queryChannel pagination, never from updateDataForChannel - verify(stateImpl, never()).advanceOldestLoadedDate(any()) + verify(stateImpl, atLeastOnce()).setPendingMessages(listOf(pendingMsg)) } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalOnlyMessagesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalOnlyMessagesTest.kt new file mode 100644 index 00000000000..40c9d5e11c7 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalOnlyMessagesTest.kt @@ -0,0 +1,182 @@ +/* + * 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.randomMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.util.Date + +@ExperimentalCoroutinesApi +internal class ChannelStateImplLocalOnlyMessagesTest : ChannelStateImplTestBase() { + + // region setLocalOnlyMessages — visibility + + @Nested + inner class SetLocalOnlyMessages { + + @Test + fun `local-only message appears in messages flow after setLocalOnlyMessages`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `replacing with empty list removes previously visible local-only messages`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + channelState.setLocalOnlyMessages(emptyList()) + assertFalse(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `local-only message is merged before a later regular message`() = runTest { + val regular = createMessage(2, timestamp = 2000L) + val localOnly = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.setMessages(listOf(regular)) + channelState.setLocalOnlyMessages(listOf(localOnly)) + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.indexOf(localOnly.id) < ids.indexOf(regular.id)) + } + + @Test + fun `local-only message is merged after an earlier regular message`() = runTest { + val regular = createMessage(1, timestamp = 1000L) + val localOnly = createLocalOnlyMessage(2, timestamp = 2000L) + channelState.setMessages(listOf(regular)) + channelState.setLocalOnlyMessages(listOf(localOnly)) + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.indexOf(regular.id) < ids.indexOf(localOnly.id)) + } + } + + // endregion + + // region floor / ceiling filtering + + @Nested + inner class WindowFiltering { + + @Test + fun `local-only message below oldest loaded date floor is not shown`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 500L) + val floor = randomMessage(id = "floor", createdAt = Date(1000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setOldestMessage(floor) + assertFalse(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `local-only message above newest loaded date ceiling is not shown`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 3000L) + val ceiling = randomMessage(id = "ceiling", createdAt = Date(2000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setNewestMessage(ceiling) + assertFalse(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `local-only message within both floor and ceiling is shown`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 2000L) + val floor = randomMessage(id = "f", createdAt = Date(1000L), createdLocallyAt = null) + val ceiling = randomMessage(id = "c", createdAt = Date(3000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setOldestMessage(floor) + channelState.paginationManager.setNewestMessage(ceiling) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `local-only message at exact floor boundary is shown`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 1000L) + val floor = randomMessage(id = "f", createdAt = Date(1000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setOldestMessage(floor) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `removing ceiling reveals previously hidden local-only message`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 5000L) + val ceiling = randomMessage(id = "c", createdAt = Date(1000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setNewestMessage(ceiling) + assertFalse(channelState.messages.value.any { it.id == localOnly.id }) + channelState.paginationManager.setNewestMessage(null) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `null floor means no floor restriction, all local-only messages are visible`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 1L) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + } + + // endregion + + // region upsertMessage — ephemeral path + + @Nested + inner class UpsertMessageEphemeral { + + @Test + fun `upsertMessage with ephemeral message adds it to local-only messages`() = runTest { + val ephemeral = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.upsertMessage(ephemeral) + assertTrue(channelState.messages.value.any { it.id == ephemeral.id }) + } + + @Test + fun `upsertMessage with ephemeral message updates existing entry without duplicating it`() = runTest { + val ephemeral = createLocalOnlyMessage(1, timestamp = 1000L) + // Seed local-only state first, then update via upsertMessage + channelState.setLocalOnlyMessages(listOf(ephemeral)) + channelState.upsertMessage(ephemeral.copy(text = "Updated")) + val found = channelState.messages.value.find { it.id == ephemeral.id } + assertEquals("Updated", found?.text) + assertEquals(1, channelState.messages.value.count { it.id == ephemeral.id }) + } + } + + // endregion + + // region deleteMessage — local-only cleanup + + @Nested + inner class DeleteMessage { + + @Test + fun `deleteMessage removes ephemeral message from local-only messages`() = runTest { + val ephemeral = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.setLocalOnlyMessages(listOf(ephemeral)) + assertTrue(channelState.messages.value.any { it.id == ephemeral.id }) + channelState.deleteMessage(ephemeral.id) + assertFalse(channelState.messages.value.any { it.id == ephemeral.id }) + } + } + + // endregion +} 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 93304d094d7..b5abf3f5129 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 @@ -16,43 +16,25 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.internal -import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.User -import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.client.api.models.Pagination +import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.models.Config +import io.getstream.chat.android.models.MessagesState 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.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension import java.util.Date @ExperimentalCoroutinesApi -internal class ChannelStateImplMessagesTest { - - private val userFlow = MutableStateFlow(currentUser) - private lateinit var channelState: ChannelStateImpl - - @BeforeEach - fun setUp() { - channelState = ChannelStateImpl( - channelType = CHANNEL_TYPE, - channelId = CHANNEL_ID, - currentUser = userFlow, - latestUsers = MutableStateFlow(mapOf(currentUser.id to currentUser)), - mutedUsers = MutableStateFlow(emptyList()), - liveLocations = MutableStateFlow(emptyList()), - messageLimit = null, - ) - } +internal class ChannelStateImplMessagesTest : ChannelStateImplTestBase() { @Nested inner class SetMessages { @@ -601,6 +583,56 @@ internal class ChannelStateImplMessagesTest { } } + @Nested + inner class UpsertCachedLatestMessages { + + @Test + fun `upsertCachedLatestMessages with empty list should not change cache`() = runTest { + // given + val messages = createMessages(3) + channelState.setMessages(messages) + channelState.cacheLatestMessages() + channelState.setMessages(emptyList()) + val before = channelState.toChannel().cachedLatestMessages + // when + channelState.upsertCachedLatestMessages(emptyList()) + // then + assertEquals(before, channelState.toChannel().cachedLatestMessages) + } + + @Test + fun `upsertCachedLatestMessages with all filtered messages should not change cache`() = runTest { + // given + val regularMsg = createMessage(1, timestamp = 1000) + channelState.setMessages(listOf(regularMsg)) + channelState.cacheLatestMessages() + channelState.setMessages(emptyList()) + val before = channelState.toChannel().cachedLatestMessages + // when — thread reply not shown in channel is always filtered out + val threadReply = createMessage(2, parentId = "parent1", showInChannel = false) + channelState.upsertCachedLatestMessages(listOf(threadReply)) + // then + assertEquals(before, channelState.toChannel().cachedLatestMessages) + } + + @Test + fun `upsertCachedLatestMessages should merge incoming messages into the cache`() = runTest { + // given + val msg1 = createMessage(1, timestamp = 1000) + val msg5 = createMessage(5, timestamp = 5000) + channelState.setMessages(listOf(msg1, msg5)) + channelState.cacheLatestMessages() + channelState.setMessages(emptyList()) + // when + val msg3 = createMessage(3, timestamp = 3000) + channelState.upsertCachedLatestMessages(listOf(msg3)) + // then + val cachedMessages = channelState.toChannel().cachedLatestMessages + assertEquals(3, cachedMessages.size) + assertEquals(listOf("message_1", "message_3", "message_5"), cachedMessages.map { it.id }) + } + } + @Nested inner class GetMessageById { @@ -667,52 +699,78 @@ internal class ChannelStateImplMessagesTest { } } - private fun createMessage( - index: Int, - timestamp: Long = currentTime() + index * 1000L, - text: String = "Test message $index", - user: User = currentUser, - parentId: String? = null, - showInChannel: Boolean = true, - shadowed: Boolean = false, - pinned: Boolean = false, - pinnedAt: Date? = null, - ): Message = randomMessage( - id = "message_$index", - cid = CID, - createdAt = Date(timestamp), - createdLocallyAt = null, - text = text, - user = user, - parentId = parentId, - showInChannel = showInChannel, - shadowed = shadowed, - pinned = pinned, - pinnedAt = pinnedAt, - deletedAt = null, - ) - - private fun createMessages( - count: Int, - startIndex: Int = 1, - baseTimestamp: Long = currentTime(), - ): List { - return (startIndex until startIndex + count).map { i -> - createMessage(i, timestamp = baseTimestamp + i * 1000L) + // region MessagesState + + @Nested + inner class MessagesStateTests { + + @Test + fun `messagesState is Loading when loading and messages are empty`() = runTest { + channelState.paginationManager.begin( + QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30), + ) + assertTrue(channelState.loading.value) + assertInstanceOf(MessagesState.Loading::class.java, channelState.messagesState.value) + } + + @Test + fun `messagesState is Result when loading but messages are non-empty`() = runTest { + channelState.setMessages(createMessages(3)) + channelState.paginationManager.begin( + QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30), + ) + assertTrue(channelState.loading.value) + assertInstanceOf(MessagesState.Result::class.java, channelState.messagesState.value) + } + + @Test + fun `messagesState is OfflineNoResults when not loading and messages are empty`() = runTest { + assertFalse(channelState.loading.value) + assertInstanceOf(MessagesState.OfflineNoResults::class.java, channelState.messagesState.value) } } - companion object { - @JvmField - @RegisterExtension - val testCoroutines = TestCoroutineExtension() + // endregion + + // region ThreeWayMerge — messages StateFlow combine branches + + @Nested + inner class ThreeWayMerge { + + private fun enablePendingMessages() { + channelState.setChannelConfig(Config(markMessagesPending = true)) + } - private const val CHANNEL_TYPE = "messaging" - private const val CHANNEL_ID = "123" - private const val CID = "messaging:123" + @Test + fun `given three sources, all messages appear in chronological order`() = runTest { + enablePendingMessages() + val regular = createMessage(2, timestamp = 2000L) + val pending = createMessage(1, timestamp = 1000L) + val localOnly = createLocalOnlyMessage(3, timestamp = 3000L) + channelState.setMessages(listOf(regular)) + channelState.setPendingMessages(listOf(pending)) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertEquals(listOf(pending.id, regular.id, localOnly.id), channelState.messages.value.map { it.id }) + } - private val currentUser = User(id = "tom", name = "Tom") + @Test + fun `given two sources, pending and local-only, appear in chronological order`() = runTest { + enablePendingMessages() + val pending = createMessage(1, timestamp = 1000L) + val localOnly = createLocalOnlyMessage(2, timestamp = 2000L) + channelState.setPendingMessages(listOf(pending)) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertEquals(listOf(pending.id, localOnly.id), channelState.messages.value.map { it.id }) + } - private fun currentTime() = testCoroutines.dispatcher.scheduler.currentTime + @Test + fun `given one source, regular is returned as-is when pending and local-only are empty`() = runTest { + val regular1 = createMessage(1, timestamp = 1000L) + val regular2 = createMessage(2, timestamp = 2000L) + channelState.setMessages(listOf(regular1, regular2)) + assertEquals(listOf(regular1.id, regular2.id), channelState.messages.value.map { it.id }) + } } + + // endregion } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplNonChannelStatesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplNonChannelStatesTest.kt index 18a9f4f399c..900f38191b3 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplNonChannelStatesTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplNonChannelStatesTest.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.internal -import io.getstream.chat.android.models.MessagesState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -29,167 +28,6 @@ import org.junit.jupiter.api.Test @ExperimentalCoroutinesApi internal class ChannelStateImplNonChannelStatesTest : ChannelStateImplTestBase() { - // region Loading - - @Nested - inner class SetLoading { - - @Test - fun `loading should default to false`() = runTest { - assertFalse(channelState.loading.value) - } - - @Test - fun `setLoading should set loading to true`() = runTest { - // when - channelState.setLoading(true) - // then - assertTrue(channelState.loading.value) - } - - @Test - fun `setLoading should set loading to false`() = runTest { - // given - channelState.setLoading(true) - // when - channelState.setLoading(false) - // then - assertFalse(channelState.loading.value) - } - - @Test - fun `messagesState should be Loading when loading is true`() = runTest { - // when - channelState.setLoading(true) - // then - assertTrue(channelState.messagesState.value is MessagesState.Loading) - } - - @Test - fun `messagesState should be OfflineNoResults when loading is false and no messages`() = runTest { - // when - channelState.setLoading(false) - // then - assertTrue(channelState.messagesState.value is MessagesState.OfflineNoResults) - } - - @Test - fun `messagesState should be Result when loading is false and messages exist`() = runTest { - // given - channelState.setMessages(listOf(createMessage(1))) - // when - channelState.setLoading(false) - // then - assertTrue(channelState.messagesState.value is MessagesState.Result) - } - } - - // endregion - - // region LoadingOlderMessages - - @Nested - inner class SetLoadingOlderMessages { - - @Test - fun `loadingOlderMessages should default to false`() = runTest { - assertFalse(channelState.loadingOlderMessages.value) - } - - @Test - fun `setLoadingOlderMessages should set to true`() = runTest { - channelState.setLoadingOlderMessages(true) - assertTrue(channelState.loadingOlderMessages.value) - } - - @Test - fun `setLoadingOlderMessages should set to false`() = runTest { - channelState.setLoadingOlderMessages(true) - channelState.setLoadingOlderMessages(false) - assertFalse(channelState.loadingOlderMessages.value) - } - } - - // endregion - - // region LoadingNewerMessages - - @Nested - inner class SetLoadingNewerMessages { - - @Test - fun `loadingNewerMessages should default to false`() = runTest { - assertFalse(channelState.loadingNewerMessages.value) - } - - @Test - fun `setLoadingNewerMessages should set to true`() = runTest { - channelState.setLoadingNewerMessages(true) - assertTrue(channelState.loadingNewerMessages.value) - } - - @Test - fun `setLoadingNewerMessages should set to false`() = runTest { - channelState.setLoadingNewerMessages(true) - channelState.setLoadingNewerMessages(false) - assertFalse(channelState.loadingNewerMessages.value) - } - } - - // endregion - - // region EndOfOlderMessages - - @Nested - inner class SetEndOfOlderMessages { - - @Test - fun `endOfOlderMessages should default to false`() = runTest { - assertFalse(channelState.endOfOlderMessages.value) - } - - @Test - fun `setEndOfOlderMessages should set to true`() = runTest { - channelState.setEndOfOlderMessages(true) - assertTrue(channelState.endOfOlderMessages.value) - } - - @Test - fun `setEndOfOlderMessages should set to false`() = runTest { - channelState.setEndOfOlderMessages(true) - channelState.setEndOfOlderMessages(false) - assertFalse(channelState.endOfOlderMessages.value) - } - } - - // endregion - - // region EndOfNewerMessages - - @Nested - inner class SetEndOfNewerMessages { - - @Test - fun `endOfNewerMessages should default to true`() = runTest { - assertTrue(channelState.endOfNewerMessages.value) - } - - @Test - fun `setEndOfNewerMessages should set to false`() = runTest { - channelState.setEndOfNewerMessages(false) - assertFalse(channelState.endOfNewerMessages.value) - } - - @Test - fun `setEndOfNewerMessages should set to true`() = runTest { - channelState.setEndOfNewerMessages(false) - channelState.setEndOfNewerMessages(true) - assertTrue(channelState.endOfNewerMessages.value) - } - } - - // endregion - // region RecoveryNeeded @Nested @@ -388,7 +226,7 @@ internal class ChannelStateImplNonChannelStatesTest : ChannelStateImplTestBase() fun `trimOldestMessages should set endOfOlderMessages to false`() = runTest { // given val stateWithLimit = createChannelStateWithLimit(50) - stateWithLimit.setEndOfOlderMessages(true) + stateWithLimit.paginationManager.setEndOfOlderMessages(true) val messages = createMessages(81) stateWithLimit.setMessages(messages) // when @@ -469,7 +307,7 @@ internal class ChannelStateImplNonChannelStatesTest : ChannelStateImplTestBase() fun `trimNewestMessages should set endOfNewerMessages to false`() = runTest { // given val stateWithLimit = createChannelStateWithLimit(50) - stateWithLimit.setEndOfNewerMessages(true) + stateWithLimit.paginationManager.setEndOfNewerMessages(true) val messages = createMessages(81) stateWithLimit.setMessages(messages) // when @@ -518,11 +356,8 @@ internal class ChannelStateImplNonChannelStatesTest : ChannelStateImplTestBase() fun `destroy should reset all state to defaults`() = runTest { // given - populate various state channelState.setMessages(createMessages(5)) - channelState.setLoading(true) - channelState.setLoadingOlderMessages(true) - channelState.setLoadingNewerMessages(true) - channelState.setEndOfOlderMessages(true) - channelState.setEndOfNewerMessages(false) + channelState.paginationManager.setEndOfOlderMessages(true) + channelState.paginationManager.setEndOfNewerMessages(false) channelState.setRecoveryNeeded(true) channelState.setInsideSearch(true) channelState.setHidden(true) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt index 2f96fdace04..5efa9fbe8bd 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt @@ -108,22 +108,6 @@ internal class ChannelStateImplPendingMessagesTest : ChannelStateImplTestBase() assertEquals(listOf(regular.id, pending.id), ids) } - @Test - fun `multiple pending messages are merged in sorted order`() { - // Given - enablePendingMessages() - val r1 = createMessage(1, timestamp = 1000L) - val r2 = createMessage(4, timestamp = 4000L) - val p1 = createMessage(2, timestamp = 2000L) - val p2 = createMessage(3, timestamp = 3000L) - channelState.setMessages(listOf(r1, r2)) - // When - channelState.setPendingMessages(listOf(p2, p1)) // intentionally reversed - // Then - val ids = channelState.messages.value.map { it.id } - assertEquals(listOf(r1.id, p1.id, p2.id, r2.id), ids) - } - @Test fun `messages flow contains only regular messages when pending list is empty`() { // Given @@ -139,74 +123,65 @@ internal class ChannelStateImplPendingMessagesTest : ChannelStateImplTestBase() // endregion - // region date range filtering via ChannelStateImpl delegation + // region date range filtering via paginationManager state @Nested inner class DateRangeFiltering { @Test fun `pending message below oldest loaded date is not shown`() { - // Given enablePendingMessages() val pending = createMessage(1, timestamp = 500L) val floor = randomMessage(id = "floor", createdAt = Date(1000L), createdLocallyAt = null) channelState.setPendingMessages(listOf(pending)) - // When — floor = 1000, pending at 500 is below it - channelState.advanceOldestLoadedDate(listOf(floor)) - // Then + channelState.paginationManager.setOldestMessage(floor) assertFalse(channelState.messages.value.any { it.id == pending.id }) } @Test fun `pending message above newest loaded date ceiling is not shown`() { - // Given enablePendingMessages() val pending = createMessage(1, timestamp = 3000L) + val ceiling = randomMessage(id = "ceiling", createdAt = Date(2000L), createdLocallyAt = null) channelState.setPendingMessages(listOf(pending)) - // When — ceiling = 2000, pending at 3000 is above it - channelState.setNewestLoadedDate(Date(2000L)) - // Then + channelState.paginationManager.setNewestMessage(ceiling) assertFalse(channelState.messages.value.any { it.id == pending.id }) } @Test fun `pending message within both floor and ceiling is shown`() { - // Given enablePendingMessages() val pending = createMessage(1, timestamp = 2000L) - val floorMsg = randomMessage(id = "f", createdAt = Date(1000L), createdLocallyAt = null) + val floor = randomMessage(id = "f", createdAt = Date(1000L), createdLocallyAt = null) + val ceiling = randomMessage(id = "c", createdAt = Date(3000L), createdLocallyAt = null) channelState.setPendingMessages(listOf(pending)) - channelState.advanceOldestLoadedDate(listOf(floorMsg)) // floor = 1000 - channelState.setNewestLoadedDate(Date(3000L)) // ceiling = 3000 - // Then + channelState.paginationManager.setOldestMessage(floor) + channelState.paginationManager.setNewestMessage(ceiling) assertTrue(channelState.messages.value.any { it.id == pending.id }) } @Test - fun `setNewestLoadedDate null removes ceiling and reveals previously hidden pending messages`() { - // Given + fun `removing ceiling reveals previously hidden pending messages`() { enablePendingMessages() val pending = createMessage(1, timestamp = 5000L) + val ceiling = randomMessage(id = "c", createdAt = Date(1000L), createdLocallyAt = null) channelState.setPendingMessages(listOf(pending)) - channelState.setNewestLoadedDate(Date(1000L)) + channelState.paginationManager.setNewestMessage(ceiling) assertFalse(channelState.messages.value.any { it.id == pending.id }) - // When - channelState.setNewestLoadedDate(null) - // Then + channelState.paginationManager.setNewestMessage(null) assertTrue(channelState.messages.value.any { it.id == pending.id }) } @Test - fun `advanceNewestLoadedDate advances ceiling to reveal newer pending messages`() { - // Given + fun `advancing ceiling reveals newer pending messages`() { enablePendingMessages() val pending = createMessage(1, timestamp = 3000L) channelState.setPendingMessages(listOf(pending)) - channelState.advanceNewestLoadedDate(Date(2000L)) // hidden + val ceiling1 = randomMessage(id = "c", createdAt = Date(2000L), createdLocallyAt = null) + channelState.paginationManager.setNewestMessage(ceiling1) assertFalse(channelState.messages.value.any { it.id == pending.id }) - // When — advance to 4000 - channelState.advanceNewestLoadedDate(Date(4000L)) - // Then + val ceiling2 = randomMessage(id = "c", createdAt = Date(4000L), createdLocallyAt = null) + channelState.paginationManager.setNewestMessage(ceiling2) assertTrue(channelState.messages.value.any { it.id == pending.id }) } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplTestBase.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplTestBase.kt index b58fae3ceaa..9a28c319a40 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplTestBase.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplTestBase.kt @@ -17,6 +17,8 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.internal import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.User import io.getstream.chat.android.randomMessage import io.getstream.chat.android.test.TestCoroutineExtension @@ -87,6 +89,20 @@ internal abstract class ChannelStateImplTestBase { } } + protected fun createLocalOnlyMessage(index: Int, timestamp: Long): Message = + randomMessage( + id = "local_only_$index", + cid = CID, + createdAt = Date(timestamp), + createdLocallyAt = null, + syncStatus = SyncStatus.COMPLETED, + type = MessageType.EPHEMERAL, + parentId = null, + showInChannel = true, + shadowed = false, + deletedAt = null, + ) + companion object { @JvmField @RegisterExtension diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationManagerImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationManagerImplTest.kt new file mode 100644 index 00000000000..87d3054f6f7 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationManagerImplTest.kt @@ -0,0 +1,676 @@ +/* + * 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.client.api.models.Pagination +import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomMessage +import io.getstream.result.Error +import io.getstream.result.Result +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +internal class MessagesPaginationManagerImplTest { + + private lateinit var sut: MessagesPaginationManagerImpl + private val failure = Result.Failure(Error.ThrowableError("test", RuntimeException())) + + @BeforeEach + fun setUp() { + sut = MessagesPaginationManagerImpl() + } + + // region Initial state + + @Test + fun `initial state should have hasLoadedAllNextMessages = true`() { + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `initial state should have hasLoadedAllPreviousMessages = false`() { + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `initial state should have no loading flags set`() { + val state = sut.state.value + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `initial state should have null oldest and newest messages`() { + assertNull(sut.state.value.oldestMessage) + assertNull(sut.state.value.newestMessage) + } + + // endregion + + // region begin() + + @Nested + inner class Begin { + + @Test + fun `begin with no pagination should reset to initial state`() { + // given - dirty state + sut.setEndOfOlderMessages(true) + sut.setEndOfNewerMessages(false) + // when + sut.begin(QueryChannelRequest().withMessages(30)) + // then + val state = sut.state.value + assertTrue(state.hasLoadedAllNextMessages) + assertFalse(state.hasLoadedAllPreviousMessages) + assertFalse(state.isLoadingMessages) + } + + @Test + fun `begin with older pagination should set isLoadingPreviousMessages`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30)) + // then + assertTrue(sut.state.value.isLoadingPreviousMessages) + } + + @Test + fun `begin with older pagination should not touch other loading flags`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30)) + // then + val state = sut.state.value + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `begin with newer pagination should set isLoadingNextMessages`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30)) + // then + assertTrue(sut.state.value.isLoadingNextMessages) + } + + @Test + fun `begin with newer pagination should not touch other loading flags`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30)) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `begin with around pagination should set isLoadingMiddleMessages`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.AROUND_ID, "msgId", 30)) + // then + assertTrue(sut.state.value.isLoadingMiddleMessages) + } + + @Test + fun `begin with around pagination should set hasLoadedAllNextMessages to false`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.AROUND_ID, "msgId", 30)) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `begin with around pagination should not touch other loading flags`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.AROUND_ID, "msgId", 30)) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + } + } + + // endregion + + // region end() - failure + + @Nested + inner class EndFailure { + + @Test + fun `end with failure should clear all loading flags`() { + // given + sut.begin(QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30)) + // when + sut.end( + query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30), + result = failure, + ) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `end with failure should preserve hasLoadedAllPreviousMessages`() { + // given + sut.setEndOfOlderMessages(true) + // when + sut.end( + query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30), + result = failure, + ) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `end with failure should preserve hasLoadedAllNextMessages`() { + // given + sut.setEndOfNewerMessages(false) + // when + sut.end( + query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30), + result = failure, + ) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + } + + // endregion + + // region end() - success - older + + @Nested + inner class EndSuccessOlder { + + private val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30) + + @Test + fun `full page should set hasLoadedAllPreviousMessages to false`() { + // given - full page (30 messages == limit) + val messages = (1..30).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `partial page should set hasLoadedAllPreviousMessages to true`() { + // given - partial page (fewer messages than limit) + val messages = (1..10).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `empty page should set hasLoadedAllPreviousMessages to true`() { + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should set oldestMessage to first message in response`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertEquals("m1", sut.state.value.oldestMessage?.id) + } + + @Test + fun `should clear all loading flags on success`() { + // given + sut.begin(query) + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `should not change hasLoadedAllNextMessages`() { + // given - currently at the latest page + assertTrue(sut.state.value.hasLoadedAllNextMessages) + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then - not changed + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + } + + // endregion + + // region end() - success - newer + + @Nested + inner class EndSuccessNewer { + + private val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30) + + @Test + fun `full page should set hasLoadedAllNextMessages to false`() { + // given - full page + val messages = (1..30).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `full page should set newestMessage to last message in response`() { + // given + val messages = (1..30).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertEquals("m30", sut.state.value.newestMessage?.id) + } + + @Test + fun `partial page should set hasLoadedAllNextMessages to true`() { + // given - partial page + val messages = (1..10).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `partial page should clear newestMessage`() { + // given - partial page means we reached the end, no ceiling needed + val messages = (1..10).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertNull(sut.state.value.newestMessage) + } + + @Test + fun `should clear all loading flags on success`() { + // given + sut.begin(query) + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + } + + // endregion + + // region end() - success - around + + @Nested + inner class EndSuccessAround { + + private val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "msgId", 30) + + @Test + fun `should always set hasLoadedAllNextMessages to false`() { + // given - even a partial page + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `should always set hasLoadedAllPreviousMessages to false`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should set oldestMessage to first message in response`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertEquals("m1", sut.state.value.oldestMessage?.id) + } + + @Test + fun `should set newestMessage to last message in response`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertEquals("m5", sut.state.value.newestMessage?.id) + } + + @Test + fun `should clear all loading flags on success`() { + // given + sut.begin(query) + // when + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `should set both end flags to false even when previously true`() { + // given - both flags were set + sut.setEndOfOlderMessages(true) + sut.setEndOfNewerMessages(true) + // when + val messages = (1..10).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + } + + // endregion + + // region end() - success - no pagination + + @Nested + inner class EndSuccessNoPagination { + + private val query = QueryChannelRequest().withMessages(30) + + @Test + fun `should set hasLoadedAllNextMessages to true`() { + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `full page should set hasLoadedAllPreviousMessages to false`() { + // given - full page (30 messages == limit) + val messages = (1..30).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `partial page should set hasLoadedAllPreviousMessages to true`() { + // given - fewer than limit + val messages = (1..10).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should clear newestMessage ceiling`() { + // given - simulate a mid-page state with a ceiling + val olderQuery = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30) + sut.end(olderQuery, Result.Success(randomChannel(messages = (1..30).map { randomMessage(id = "m$it") }))) + // when - initial load resets state + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + assertNull(sut.state.value.newestMessage) + } + + @Test + fun `should set oldestMessage to first message in response`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertEquals("m1", sut.state.value.oldestMessage?.id) + } + } + + // endregion + + // region setOldestMessage + + @Nested + inner class SetOldestMessage { + + @Test + fun `setOldestMessage should update oldestMessage`() { + // given + val message = randomMessage(id = "old") + // when + sut.setOldestMessage(message) + // then + assertEquals("old", sut.state.value.oldestMessage?.id) + } + + @Test + fun `setOldestMessage with null should clear oldestMessage`() { + // given + sut.setOldestMessage(randomMessage(id = "old")) + // when + sut.setOldestMessage(null) + // then + assertNull(sut.state.value.oldestMessage) + } + } + + // endregion + + // region setNewestMessage + + @Nested + inner class SetNewestMessage { + + @Test + fun `setNewestMessage should update newestMessage`() { + // given + val message = randomMessage(id = "new") + // when + sut.setNewestMessage(message) + // then + assertEquals("new", sut.state.value.newestMessage?.id) + } + + @Test + fun `setNewestMessage with null should clear newestMessage`() { + // given + sut.setNewestMessage(randomMessage(id = "new")) + // when + sut.setNewestMessage(null) + // then + assertNull(sut.state.value.newestMessage) + } + } + + // endregion + + // region setEndOfOlderMessages + + @Nested + inner class SetEndOfOlderMessages { + + @Test + fun `should set hasLoadedAllPreviousMessages to true`() { + // when + sut.setEndOfOlderMessages(true) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should set hasLoadedAllPreviousMessages to false`() { + // given + sut.setEndOfOlderMessages(true) + // when + sut.setEndOfOlderMessages(false) + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should not affect hasLoadedAllNextMessages`() { + // given + val original = sut.state.value.hasLoadedAllNextMessages + // when + sut.setEndOfOlderMessages(true) + // then + assertEquals(original, sut.state.value.hasLoadedAllNextMessages) + } + } + + // endregion + + // region setEndOfNewerMessages + + @Nested + inner class SetEndOfNewerMessages { + + @Test + fun `should set hasLoadedAllNextMessages to false`() { + // when + sut.setEndOfNewerMessages(false) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `should set hasLoadedAllNextMessages to true`() { + // given + sut.setEndOfNewerMessages(false) + // when + sut.setEndOfNewerMessages(true) + // then + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `setting to true should clear newestMessage ceiling`() { + // given - a ceiling was set (mid-page state) + sut.setNewestMessage(randomMessage(id = "ceiling")) + // when + sut.setEndOfNewerMessages(true) + // then + assertNull(sut.state.value.newestMessage) + } + + @Test + fun `setting to false should preserve existing newestMessage`() { + // given + val ceiling = randomMessage(id = "ceiling") + sut.setNewestMessage(ceiling) + // when + sut.setEndOfNewerMessages(false) + // then + assertEquals("ceiling", sut.state.value.newestMessage?.id) + } + + @Test + fun `should not affect hasLoadedAllPreviousMessages`() { + // given + val original = sut.state.value.hasLoadedAllPreviousMessages + // when + sut.setEndOfNewerMessages(false) + // then + assertEquals(original, sut.state.value.hasLoadedAllPreviousMessages) + } + } + + // endregion + + // region reset + + @Nested + inner class Reset { + + @Test + fun `reset should restore hasLoadedAllNextMessages to true`() { + // given + sut.setEndOfNewerMessages(false) + // when + sut.reset() + // then + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `reset should restore hasLoadedAllPreviousMessages to false`() { + // given + sut.setEndOfOlderMessages(true) + // when + sut.reset() + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `reset should clear oldest and newest messages`() { + // given + sut.setOldestMessage(randomMessage()) + sut.setNewestMessage(randomMessage()) + // when + sut.reset() + // then + assertNull(sut.state.value.oldestMessage) + assertNull(sut.state.value.newestMessage) + } + + @Test + fun `reset should clear all loading flags`() { + // given - simulate loading state + sut.begin(QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30)) + // when + sut.reset() + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + } + + // endregion +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManagerTest.kt deleted file mode 100644 index f6e2cf63f8f..00000000000 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManagerTest.kt +++ /dev/null @@ -1,402 +0,0 @@ -/* - * 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.randomMessage -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import java.util.Date - -internal class PendingMessagesManagerTest { - - private lateinit var sut: PendingMessagesManager - - @BeforeEach - fun setUp() { - sut = PendingMessagesManager() - } - - // region initial state - - @Test - fun `pendingMessagesInRange is empty when disabled (initial state)`() { - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - // endregion - - // region setEnabled - - @Nested - inner class SetEnabled { - - @Test - fun `enabling makes pending messages visible`() { - // Given - val message = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(message)) - // When - sut.setEnabled(true) - // Then - assertEquals(listOf(message), sut.pendingMessagesInRange.value) - } - - @Test - fun `disabling returns empty list`() { - // Given - val message = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setEnabled(true) - sut.setPendingMessages(listOf(message)) - // When - sut.setEnabled(false) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - @Test - fun `disabling clears buffered messages so re-enabling starts empty`() { - // Given - val message = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setEnabled(true) - sut.setPendingMessages(listOf(message)) - // When - sut.setEnabled(false) - sut.setEnabled(true) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - } - - // endregion - - // region setPendingMessages - - @Nested - inner class SetPendingMessages { - - @Test - fun `messages are sorted by createdAt ascending`() { - // Given - sut.setEnabled(true) - val newer = randomMessage(id = "m1", createdAt = Date(2000L), createdLocallyAt = null) - val older = randomMessage(id = "m2", createdAt = Date(1000L), createdLocallyAt = null) - // When - sut.setPendingMessages(listOf(newer, older)) - // Then - assertEquals(listOf(older, newer), sut.pendingMessagesInRange.value) - } - - @Test - fun `replaces previously set messages`() { - // Given - sut.setEnabled(true) - val first = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(first)) - val second = randomMessage(id = "m2", createdAt = Date(2000L), createdLocallyAt = null) - // When - sut.setPendingMessages(listOf(second)) - // Then - assertEquals(listOf(second), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region removePendingMessage - - @Nested - inner class RemovePendingMessage { - - @Test - fun `removes existing message by id`() { - // Given - sut.setEnabled(true) - val m1 = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - val m2 = randomMessage(id = "m2", createdAt = Date(2000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(m1, m2)) - // When - sut.removePendingMessage("m1") - // Then - assertEquals(listOf(m2), sut.pendingMessagesInRange.value) - } - - @Test - fun `no-op when id is not found — list content is unchanged`() { - // Given - val m1 = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setEnabled(true) - sut.setPendingMessages(listOf(m1)) - // When - sut.removePendingMessage("does-not-exist") - // Then — message is still present, nothing was removed - assertEquals(listOf(m1), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region advanceOldestLoadedDate - - @Nested - inner class AdvanceOldestLoadedDate { - - @Test - fun `initializes floor on first call and shows messages at or after it`() { - // Given - sut.setEnabled(true) - val floor = Date(1000L) - val atFloor = randomMessage(id = "m1", createdAt = floor, createdLocallyAt = null) - val belowFloor = randomMessage(id = "m2", createdAt = Date(500L), createdLocallyAt = null) - sut.setPendingMessages(listOf(atFloor, belowFloor)) - // When — first call with a message whose createdAt = floor - sut.advanceOldestLoadedDate(listOf(atFloor)) - // Then - assertEquals(listOf(atFloor), sut.pendingMessagesInRange.value) - } - - @Test - fun `advances floor backward when new date is older`() { - // Given - sut.setEnabled(true) - val initial = randomMessage(id = "anchor", createdAt = Date(1000L), createdLocallyAt = null) - sut.advanceOldestLoadedDate(listOf(initial)) // floor = 1000 - val older = randomMessage(id = "m2", createdAt = Date(500L), createdLocallyAt = null) - sut.setPendingMessages(listOf(initial, older)) - // When — provide a message older than the current floor - sut.advanceOldestLoadedDate(listOf(older)) - // Then — older message is now in range - assertEquals(listOf(older, initial), sut.pendingMessagesInRange.value) - } - - @Test - fun `does NOT advance floor when new date is newer than current floor`() { - // Given - sut.setEnabled(true) - val floorMsg = randomMessage(id = "m1", createdAt = Date(500L), createdLocallyAt = null) - val outside = randomMessage(id = "m2", createdAt = Date(200L), createdLocallyAt = null) - sut.setPendingMessages(listOf(floorMsg, outside)) - sut.advanceOldestLoadedDate(listOf(floorMsg)) // floor = 500 - // When — try to advance with a newer date (1000 > 500) - val newer = randomMessage(id = "anchor2", createdAt = Date(1000L), createdLocallyAt = null) - sut.advanceOldestLoadedDate(listOf(newer)) - // Then — message at 200 still outside the floor - assertEquals(listOf(floorMsg), sut.pendingMessagesInRange.value) - } - - @Test - fun `no-op when message list is empty`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - // When — floor remains null - sut.advanceOldestLoadedDate(emptyList()) - // Then — floor is still null, so no filter applied - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region setNewestLoadedDate - - @Nested - inner class SetNewestLoadedDate { - - @Test - fun `sets ceiling and excludes messages above it`() { - // Given - sut.setEnabled(true) - val ceiling = Date(2000L) - val atCeiling = randomMessage(id = "m1", createdAt = ceiling, createdLocallyAt = null) - val aboveCeiling = randomMessage(id = "m2", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(atCeiling, aboveCeiling)) - // When - sut.setNewestLoadedDate(ceiling) - // Then - assertEquals(listOf(atCeiling), sut.pendingMessagesInRange.value) - } - - @Test - fun `null removes ceiling so all pending messages pass`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(5000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.setNewestLoadedDate(Date(1000L)) // ceiling blocks msg - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - // When - sut.setNewestLoadedDate(null) - // Then - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region advanceNewestLoadedDate - - @Nested - inner class AdvanceNewestLoadedDate { - - @Test - fun `first non-null call sets ceiling`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - // When — ceiling = 2000, msg at 3000 is above - sut.advanceNewestLoadedDate(Date(2000L)) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - @Test - fun `advances ceiling forward when date is newer`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.advanceNewestLoadedDate(Date(2000L)) // ceiling = 2000, msg hidden - // When — advance to 4000 - sut.advanceNewestLoadedDate(Date(4000L)) - // Then — msg at 3000 is now within range - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - - @Test - fun `does NOT advance ceiling backward`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.advanceNewestLoadedDate(Date(4000L)) // ceiling = 4000, msg visible - // When — try to retreat ceiling to 2000 - sut.advanceNewestLoadedDate(Date(2000L)) - // Then — msg still visible - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - - @Test - fun `null argument is a no-op`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.advanceNewestLoadedDate(Date(2000L)) // ceiling = 2000 - // When - sut.advanceNewestLoadedDate(null) - // Then — ceiling unchanged, msg still hidden - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - } - - // endregion - - // region reset - - @Nested - inner class Reset { - - @Test - fun `clears pending messages and date range`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.advanceOldestLoadedDate(listOf(msg)) - sut.setNewestLoadedDate(Date(5000L)) - // When - sut.reset() - // Then — messages cleared; null floor and ceiling means no messages to show anyway - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - @Test - fun `state can be repopulated after reset`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.reset() - // When - sut.setPendingMessages(listOf(msg)) - // Then - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region date filtering - - @Nested - inner class DateFiltering { - - @Test - fun `message at floor boundary is included`() { - // Given - sut.setEnabled(true) - val floor = Date(1000L) - val atFloor = randomMessage(id = "m1", createdAt = floor, createdLocallyAt = null) - sut.setPendingMessages(listOf(atFloor)) - sut.advanceOldestLoadedDate(listOf(randomMessage(id = "anchor", createdAt = floor, createdLocallyAt = null))) - // Then - assertEquals(listOf(atFloor), sut.pendingMessagesInRange.value) - } - - @Test - fun `message just below floor is excluded`() { - // Given - sut.setEnabled(true) - val justBelowFloor = randomMessage(id = "m1", createdAt = Date(999L), createdLocallyAt = null) - val floorMsg = randomMessage(id = "anchor", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(justBelowFloor)) - sut.advanceOldestLoadedDate(listOf(floorMsg)) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - @Test - fun `message at ceiling boundary is included`() { - // Given - sut.setEnabled(true) - val ceiling = Date(2000L) - val atCeiling = randomMessage(id = "m1", createdAt = ceiling, createdLocallyAt = null) - sut.setPendingMessages(listOf(atCeiling)) - sut.setNewestLoadedDate(ceiling) - // Then - assertEquals(listOf(atCeiling), sut.pendingMessagesInRange.value) - } - - @Test - fun `message just above ceiling is excluded`() { - // Given - sut.setEnabled(true) - val aboveCeiling = randomMessage(id = "m1", createdAt = Date(2001L), createdLocallyAt = null) - sut.setPendingMessages(listOf(aboveCeiling)) - sut.setNewestLoadedDate(Date(2000L)) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - } - - // endregion -} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt index 81dcaa1048c..4cdc2bb623d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt @@ -37,6 +37,8 @@ import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.util.Date @@ -581,6 +583,54 @@ internal class MessageUtilsTest { Assertions.assertTrue(UuidRegex.matches(draftWithId.id)) } + @Test + fun `isLocalOnly returns true for SyncStatus SYNC_NEEDED`() { + val message = randomMessage(syncStatus = SyncStatus.SYNC_NEEDED, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for SyncStatus IN_PROGRESS`() { + val message = randomMessage(syncStatus = SyncStatus.IN_PROGRESS, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for SyncStatus AWAITING_ATTACHMENTS`() { + val message = randomMessage(syncStatus = SyncStatus.AWAITING_ATTACHMENTS, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for SyncStatus FAILED_PERMANENTLY`() { + val message = randomMessage(syncStatus = SyncStatus.FAILED_PERMANENTLY, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for type ephemeral with COMPLETED syncStatus`() { + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.EPHEMERAL) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for type error with COMPLETED syncStatus`() { + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.ERROR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns false for SyncStatus COMPLETED with type regular`() { + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.REGULAR) + assertFalse(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns false for system message with COMPLETED`() { + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.SYSTEM) + assertFalse(message.isLocalOnly()) + } + private companion object { // Regex matching lowercase UUID format