diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatContainer.kt b/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatContainer.kt index 5571ebc660..6002d23382 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatContainer.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/chats/ChatContainer.kt @@ -169,7 +169,10 @@ class ChatContainer @Inject constructor( } is Transformation.Commands.LoadAround -> { val messages = try { - loadAroundMessage(chat, transform.message) + loadAroundMessage( + chat = chat, + msg = transform.message + ) } catch (e: Exception) { logger.logException(e, "DROID-2966 Error while loading reply context") state.messages @@ -182,26 +185,78 @@ class ChatContainer @Inject constructor( } is Transformation.Commands.LoadEnd -> { if (state.messages.isNotEmpty()) { - val lastShown = state.messages.last() - val lastTracked = lastMessages.entries.first().value - if (lastShown.id == lastTracked.id) { - // No need to paginate, just scroll to bottom. - state.copy( - intent = Intent.ScrollToBottom - ) - } else { - val messages = try { - loadToEnd(chat) - } catch (e: Exception) { - state.messages.also { - logger.logException(e, "DROID-2966 Error while scrolling to bottom") - } + if (state.state.hasUnReadMessages) { + // Check if above the unread messages + val oldestReadOrderId = state.state.oldestMessageOrderId + val bottomMessage = state.messages.find { + it.id == transform.lastVisibleMessage + } + if (bottomMessage != null && oldestReadOrderId != null) { + if (bottomMessage.order < oldestReadOrderId) { + // Scroll to the first unread message + val messages = try { + loadAroundMessageOrder( + chat = chat, + order = oldestReadOrderId + ) + } catch (e: Exception) { + logger.logException(e, "DROID-2966 Error while loading reply context") + state.messages + } + val target = messages.find { it.order == oldestReadOrderId } + ChatStreamState( + messages = messages, + intent = if (target != null) Intent.ScrollToMessage(target.id) else Intent.ScrollToBottom, + state = state.state + ) + } else { + val messages = try { + loadToEnd(chat) + } catch (e: Exception) { + state.messages.also { + logger.logException(e, "DROID-2966 Error while scrolling to bottom") + } + } + ChatStreamState( + messages = messages, + intent = Intent.ScrollToBottom, + state = state.state + ) + } + } else { + val messages = try { + loadToEnd(chat) + } catch (e: Exception) { + state.messages.also { + logger.logException(e, "DROID-2966 Error while scrolling to bottom") + } + } + ChatStreamState( + messages = messages, + intent = Intent.ScrollToBottom, + state = state.state + ) + } + } else { + if (lastMessages.contains(transform.lastVisibleMessage)) { + // No need to paginate, just scroll to bottom. + state.copy( + intent = Intent.ScrollToBottom + ) + } else { + val messages = try { + loadToEnd(chat) + } catch (e: Exception) { + state.messages.also { + logger.logException(e, "DROID-2966 Error while scrolling to bottom") + } + } + ChatStreamState( + messages = messages, + intent = Intent.ScrollToBottom, + state = state.state + ) } - ChatStreamState( - messages = messages, - intent = Intent.ScrollToBottom, - state = state.state - ) } } else { state @@ -477,9 +532,9 @@ class ChatContainer @Inject constructor( commands.emit(Transformation.Commands.LoadAround(message = replyMessage)) } - suspend fun onLoadChatTail() { + suspend fun onLoadChatTail(msg: Id?) { logger.logInfo("DROID-2966 emitting onLoadEnd") - commands.emit(Transformation.Commands.LoadEnd) + commands.emit(Transformation.Commands.LoadEnd(msg)) } suspend fun onVisibleRangeChanged(from: Id, to: Id) { @@ -527,7 +582,7 @@ class ChatContainer @Inject constructor( /** * Scroll-to-bottom behavior. */ - data object LoadEnd: Commands() + data class LoadEnd(val lastVisibleMessage: Id?): Commands() data class UpdateVisibleRange(val from: Id, val to: Id) : Commands() } diff --git a/domain/src/test/java/com/anytypeio/anytype/domain/chats/ChatContainerTest.kt b/domain/src/test/java/com/anytypeio/anytype/domain/chats/ChatContainerTest.kt index 26d84efde3..0a818a7d85 100644 --- a/domain/src/test/java/com/anytypeio/anytype/domain/chats/ChatContainerTest.kt +++ b/domain/src/test/java/com/anytypeio/anytype/domain/chats/ChatContainerTest.kt @@ -15,6 +15,7 @@ import com.anytypeio.anytype.test_utils.MockDataFactory import kotlin.test.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -391,6 +392,100 @@ class ChatContainerTest { } } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should scroll to the first unread message when scroll-to-bottom is clicked when subscribing chat`() = runTest { + + val container = ChatContainer( + repo = repo, + channel = channel, + logger = logger + ) + + val messages = buildList { + repeat(100) { + add( + StubChatMessage( + id = it.toString(), + order = it.toString() + ) + ) + } + } + + val state = Chat.State.UnreadState( + counter = 10, + olderOrderId = "90" + ) + + repo.stub { + onBlocking { + subscribeLastChatMessages( + Command.ChatCommand.SubscribeLastMessages( + chat = givenChatID, + limit = ChatContainer.DEFAULT_CHAT_PAGING_SIZE + ) + ) + } doReturn Command.ChatCommand.SubscribeLastMessages.Response( + messages = messages.takeLast(10), + messageCountBefore = 0, + chatState = Chat.State(unreadMessages = state) + ) + + onBlocking { + getChatMessages( + Command.ChatCommand.GetMessages( + chat = givenChatID, + beforeOrderId = state.olderOrderId, + limit = ChatContainer.DEFAULT_CHAT_PAGING_SIZE, + afterOrderId = null, + includeBoundary = false + ) + ) + } doReturn Command.ChatCommand.GetMessages.Response( + messages = messages.slice(80..89) + ) + + onBlocking { + getChatMessages( + Command.ChatCommand.GetMessages( + chat = givenChatID, + afterOrderId = state.olderOrderId, + limit = ChatContainer.DEFAULT_CHAT_PAGING_SIZE, + includeBoundary = true + ) + ) + } doReturn Command.ChatCommand.GetMessages.Response( + messages = messages.slice(90..99) + ) + } + + channel.stub { + on { observe(chat = givenChatID) } doReturn emptyFlow() + } + + container.watch(givenChatID).test { + + val initial = awaitItem() + + assertEquals( + expected = messages.slice(80..89) + messages.slice(90..99), + actual = initial.messages, + ) + + assertEquals( + expected = ChatContainer.Intent.ScrollToMessage(id = "90"), + actual = initial.intent, + ) + + container.onLoadChatTail( + msg = "80" + ) + + // New state is not emitted, since it does not change. + } + } + // TODO move to test-utils fun StubChatMessage( id: Id = MockDataFactory.randomUuid(), diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt index 9e704dfada..1b6fb2efe8 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt @@ -313,7 +313,7 @@ fun ChatScreen( onChatScrolledToBottom: () -> Unit, onScrollToReplyClicked: (Id) -> Unit, onClearIntent: () -> Unit, - onScrollToBottomClicked: () -> Unit, + onScrollToBottomClicked: (Id?) -> Unit, onVisibleRangeChanged: (Id, Id) -> Unit ) { @@ -382,7 +382,7 @@ fun ChatScreen( .mapNotNull { item -> latestMessages.getOrNull(item.index) } .filterIsInstance() - if (visibleMessages.isNotEmpty()) { + if (visibleMessages.isNotEmpty() && !isPerformingScrollIntent.value) { // TODO could be optimised by passing order ID visibleMessages.first().id to visibleMessages.last().id } else null @@ -468,7 +468,19 @@ fun ChatScreen( .align(Alignment.BottomEnd) .padding(end = 12.dp), onGoToBottomClicked = { - onScrollToBottomClicked() + val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + val lastVisibleView = if (lastVisibleIndex != null) { + latestMessages.getOrNull(lastVisibleIndex) + } else { + null + } + if (lastVisibleView is ChatView.Message) { + onScrollToBottomClicked( + lastVisibleView.id + ) + } else { + onScrollToBottomClicked(null) + } }, enabled = jumpToBottomButtonEnabled )