From bdfc2734e06c0989d3a87860bb638d4362da817b Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Mon, 28 Apr 2025 11:23:19 +0200 Subject: [PATCH] DROID-2813 Chats | Enhancement | Infinite paging, first iteration (#2349) --- .../anytypeio/anytype/core_models/Command.kt | 4 +- .../anytype/core_models/chats/Chat.kt | 2 +- .../anytype/domain/chats/AddChatMessage.kt | 3 + .../anytype/domain/chats/ChatContainer.kt | 261 +++++++++++++----- .../anytype/domain/chats/ChatContainerTest.kt | 22 +- .../feature_chats/presentation/ChatView.kt | 8 +- .../presentation/ChatViewModel.kt | 76 +++-- .../anytype/feature_chats/ui/ChatPreviews.kt | 57 ++-- .../anytype/feature_chats/ui/ChatScreen.kt | 192 ++++++++++--- 9 files changed, 451 insertions(+), 174 deletions(-) diff --git a/core-models/src/main/java/com/anytypeio/anytype/core_models/Command.kt b/core-models/src/main/java/com/anytypeio/anytype/core_models/Command.kt index 761acc561c..8add863fc9 100644 --- a/core-models/src/main/java/com/anytypeio/anytype/core_models/Command.kt +++ b/core-models/src/main/java/com/anytypeio/anytype/core_models/Command.kt @@ -624,8 +624,8 @@ sealed class Command { data class GetMessages( val chat: Id, - val beforeOrderId: Id?, - val afterOrderId: Id?, + val beforeOrderId: Id? = null, + val afterOrderId: Id? = null, val limit: Int ) : ChatCommand() { data class Response( diff --git a/core-models/src/main/java/com/anytypeio/anytype/core_models/chats/Chat.kt b/core-models/src/main/java/com/anytypeio/anytype/core_models/chats/Chat.kt index 3f8ef304a0..f96d549e58 100644 --- a/core-models/src/main/java/com/anytypeio/anytype/core_models/chats/Chat.kt +++ b/core-models/src/main/java/com/anytypeio/anytype/core_models/chats/Chat.kt @@ -18,7 +18,7 @@ sealed class Chat { val modifiedAt: Long, val content: Content?, val attachments: List = emptyList(), - val reactions: Map>, + val reactions: Map> = emptyMap(), val replyToMessageId: Id? = null, val read: Boolean = false, val mentionRead: Boolean = false diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/chats/AddChatMessage.kt b/domain/src/main/java/com/anytypeio/anytype/domain/chats/AddChatMessage.kt index 50a25174ea..001b499330 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/chats/AddChatMessage.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/chats/AddChatMessage.kt @@ -8,6 +8,9 @@ import com.anytypeio.anytype.domain.base.ResultInteractor import com.anytypeio.anytype.domain.block.repo.BlockRepository import javax.inject.Inject +/** + * returns message ID and payload commands. + */ class AddChatMessage @Inject constructor( private val repo: BlockRepository, dispatchers: AppCoroutineDispatchers 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 3a5794145f..0623989dba 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 @@ -31,12 +31,16 @@ class ChatContainer @Inject constructor( private val channel: ChatEventChannel, private val logger: Logger ) { + + private val lastMessages = LinkedHashMap() + private val payloads = MutableSharedFlow>() private val commands = MutableSharedFlow(replay = 0) private val attachments = MutableStateFlow>(emptySet()) private val replies = MutableStateFlow>(emptySet()) + // TODO Naive implementation. Add caching logic fun fetchAttachments(space: Space) : Flow> { return attachments .map { ids -> @@ -65,7 +69,7 @@ class ChatContainer @Inject constructor( .map { wrappers -> wrappers.associate { it.id to it } } } - @Deprecated("Naive implementation. Add caching logic") + // TODO Naive implementation. Add caching logic fun fetchReplies(chat: Id) : Flow> { return replies .map { ids -> @@ -84,9 +88,10 @@ class ChatContainer @Inject constructor( .map { messages -> messages.associate { it.id to it } } } - fun watchWhileTrackingAttachments(chat: Id): Flow> { + fun watchWhileTrackingAttachments(chat: Id): Flow { return watch(chat) - .onEach { messages -> + .onEach { state -> + val messages = state.messages val repliesIds = mutableSetOf() val attachmentsIds = mutableSetOf() messages.forEach { msg -> @@ -100,14 +105,15 @@ class ChatContainer @Inject constructor( } } - fun watch(chat: Id): Flow> = flow { - + fun watch(chat: Id): Flow = flow { val initial = repo.subscribeLastChatMessages( command = Command.ChatCommand.SubscribeLastMessages( chat = chat, limit = DEFAULT_CHAT_PAGING_SIZE ) - ) + ).also { result -> + cacheLastMessages(result.messages) + } val inputs: Flow = merge( channel.observe(chat).map { Transformation.Events.Payload(it) }, @@ -116,62 +122,98 @@ class ChatContainer @Inject constructor( ) emitAll( - inputs.scan(initial = initial.messages) { state, transform -> - when(transform) { - Transformation.Commands.LoadBefore -> { - loadThePreviousPage(state, chat) + inputs.scan(initial = ChatStreamState(initial.messages)) { state, transform -> + when (transform) { + Transformation.Commands.LoadPrevious -> { + ChatStreamState( + messages = loadThePreviousPage(state.messages, chat), + intent = Intent.None + ) } - Transformation.Commands.LoadAfter -> { - loadTheNextPage(state, chat) + Transformation.Commands.LoadNext -> { + ChatStreamState( + messages = loadTheNextPage(state.messages, chat), + intent = Intent.None + ) } - is Transformation.Commands.LoadTo -> { - loadToMessage(chat, transform) + is Transformation.Commands.LoadAround -> { + val messages = try { + loadToMessage(chat, transform) + } catch (e: Exception) { + logger.logException(e, "DROID-2966 Error while loading reply context") + state.messages + } + ChatStreamState( + messages = messages, + intent = Intent.ScrollToMessage(transform.message) + ) + } + is Transformation.Commands.LoadEnd -> { + val messages = try { + loadToEnd(chat) + } catch (e: Exception) { + logger.logException(e, "DROID-2966 Error while scrolling to bottom") + state.messages + } + ChatStreamState( + messages = messages, + intent = Intent.ScrollToBottom + ) } is Transformation.Events.Payload -> { - state.reduce(transform.events) + ChatStreamState( + messages = state.messages.reduce(transform.events), + intent = Intent.None + ) } } - } + }.distinctUntilChanged() ) }.catch { e -> - emit(value = emptyList()).also { - logger.logException(e, "Exception occurred in the chat container: $chat") + emit( + value = ChatStreamState(emptyList()) + ).also { + logger.logException(e, "DROID-2966 Exception occurred in the chat container: $chat") } } + @Throws private suspend fun loadToMessage( chat: Id, - transform: Transformation.Commands.LoadTo + transform: Transformation.Commands.LoadAround ): List { + val replyMessage = repo.getChatMessagesByIds( Command.ChatCommand.GetMessagesByIds( chat = chat, messages = listOf(transform.message) ) - ) + ).firstOrNull() - val loadedMessagesBefore = repo.getChatMessages( - Command.ChatCommand.GetMessages( - chat = chat, - beforeOrderId = transform.message, - afterOrderId = null, - limit = DEFAULT_CHAT_PAGING_SIZE - ) - ).messages + if (replyMessage != null) { + val loadedMessagesBefore = repo.getChatMessages( + Command.ChatCommand.GetMessages( + chat = chat, + beforeOrderId = replyMessage.order, + limit = DEFAULT_CHAT_PAGING_SIZE + ) + ).messages - val loadedMessagesAfter = repo.getChatMessages( - Command.ChatCommand.GetMessages( - chat = chat, - beforeOrderId = null, - afterOrderId = transform.message, - limit = DEFAULT_CHAT_PAGING_SIZE - ) - ).messages + val loadedMessagesAfter = repo.getChatMessages( + Command.ChatCommand.GetMessages( + chat = chat, + afterOrderId = replyMessage.order, + limit = DEFAULT_CHAT_PAGING_SIZE + ) + ).messages - return buildList { - addAll(loadedMessagesBefore) - addAll(replyMessage) - addAll(loadedMessagesAfter) + return buildList { + addAll(loadedMessagesBefore) + add(replyMessage) + addAll(loadedMessagesAfter) + } + } else { + throw IllegalStateException("DROID-2966 Could not fetch replyMessage") } } @@ -184,7 +226,6 @@ class ChatContainer @Inject constructor( val next = repo.getChatMessages( Command.ChatCommand.GetMessages( chat = chat, - beforeOrderId = null, afterOrderId = last.order, limit = DEFAULT_CHAT_PAGING_SIZE ) @@ -192,12 +233,12 @@ class ChatContainer @Inject constructor( state + next.messages } else { state.also { - logger.logWarning("The last message not found in chat") + logger.logWarning("DROID-2966 The last message not found in chat") } } } catch (e: Exception) { state.also { - logger.logException(e, "Error while loading previous page in chat $chat") + logger.logException(e, "DROID-2966 Error while loading previous page in chat $chat") } } @@ -207,26 +248,37 @@ class ChatContainer @Inject constructor( ): List = try { val first = state.firstOrNull() if (first != null) { - val next = repo.getChatMessages( + val previous = repo.getChatMessages( Command.ChatCommand.GetMessages( chat = chat, beforeOrderId = first.order, - afterOrderId = null, limit = DEFAULT_CHAT_PAGING_SIZE ) ) - next.messages + state + previous.messages + state } else { state.also { - logger.logWarning("The first message not found in chat") + logger.logWarning("DROID-2966 The first message not found in chat") } } } catch (e: Exception) { state.also { - logger.logException(e, "Error while loading next page in chat: $chat") + logger.logException(e, "DROID-2966 Error while loading next page in chat: $chat") } } + @Throws + private suspend fun loadToEnd(chat: Id): List { + return repo.getChatMessages( + Command.ChatCommand.GetMessages( + chat = chat, + beforeOrderId = null, + afterOrderId = null, + limit = DEFAULT_CHAT_PAGING_SIZE + ) + ).messages + } + suspend fun onPayload(events: List) { payloads.emit(events) } @@ -236,7 +288,7 @@ class ChatContainer @Inject constructor( events.forEach { event -> when (event) { is Event.Command.Chats.Add -> { - if (result.none { it.id == event.message.id }) { + if (!result.isInCurrentWindow(event.message.id)) { val insertIndex = result.indexOfFirst { it.order > event.order } if (insertIndex >= 0) { result.add(insertIndex, event.message) @@ -244,55 +296,97 @@ class ChatContainer @Inject constructor( result.add(event.message) } } + // Tracking the last message in the chat tail + cacheLastMessage(event.message) } - is Event.Command.Chats.Delete -> { - val index = result.indexOfFirst { it.id == event.id } - if (index >= 0) result.removeAt(index) - } + is Event.Command.Chats.Update -> { - val index = result.indexOfFirst { it.id == event.id } - if (index >= 0 && result[index] != event.message) { + if (result.isInCurrentWindow(event.id)) { + val index = result.indexOfFirst { it.id == event.id } result[index] = event.message } + // Tracking the last message in the chat tail + cacheLastMessage(event.message) } + + is Event.Command.Chats.Delete -> { + if (result.isInCurrentWindow(event.id)) { + val index = result.indexOfFirst { it.id == event.id } + result.removeAt(index) + } + // Tracking the last message in the chat tail + lastMessages.remove(event.id) + } + is Event.Command.Chats.UpdateReactions -> { - val index = result.indexOfFirst { it.id == event.id } - if (index >= 0 && result[index].reactions != event.reactions) { - result[index] = result[index].copy(reactions = event.reactions) + if (result.isInCurrentWindow(event.id)) { + val index = result.indexOfFirst { it.id == event.id } + if (result[index].reactions != event.reactions) { + result[index] = result[index].copy(reactions = event.reactions) + } } } + is Event.Command.Chats.UpdateMentionReadStatus -> { - event.messages.forEach { id -> + val idsInWindow = event.messages.filter { result.isInCurrentWindow(it) } + idsInWindow.forEach { id -> val index = result.indexOfFirst { it.id == id } - if (index >= 0 && result[index].mentionRead != event.isRead) { + if (result[index].mentionRead != event.isRead) { result[index] = result[index].copy(mentionRead = event.isRead) } } } is Event.Command.Chats.UpdateMessageReadStatus -> { - event.messages.forEach { id -> + val idsInWindow = event.messages.filter { result.isInCurrentWindow(it) } + idsInWindow.forEach { id -> val index = result.indexOfFirst { it.id == id } - if (index >= 0 && result[index].read != event.isRead) { + if (result[index].read != event.isRead) { result[index] = result[index].copy(read = event.isRead) } } } is Event.Command.Chats.UpdateState -> { - // TODO handle event + // TODO handle later } } } + return result } - suspend fun onLoadNextPage() { - commands.emit(Transformation.Commands.LoadBefore) + suspend fun onLoadPrevious() { + commands.emit(Transformation.Commands.LoadPrevious) } - suspend fun onLoadPreviousPage() { - commands.emit(Transformation.Commands.LoadAfter) + suspend fun onLoadNext() { + commands.emit(Transformation.Commands.LoadNext) + } + + suspend fun onLoadToReply(replyMessage: Id) { + logger.logInfo("DROID-2966 emitting onLoadToReply") + commands.emit(Transformation.Commands.LoadAround(message = replyMessage)) + } + + suspend fun onLoadChatTail() { + logger.logInfo("DROID-2966 emitting onLoadEnd") + commands.emit(Transformation.Commands.LoadEnd) + } + + private fun cacheLastMessages(messages: List) { + messages.sortedByDescending { it.order } // Newest first + .take(LAST_MESSAGES_MAX_SIZE) + .forEach { cacheLastMessage(it) } + } + + private fun cacheLastMessage(message: Chat.Message) { + lastMessages[message.id] = ChatMessageMeta(message.id, message.order) + // Ensure insertion order is preserved while trimming old entries + if (lastMessages.size > LAST_MESSAGES_MAX_SIZE) { + val oldestEntry = lastMessages.entries.first() + lastMessages.remove(oldestEntry.key) + } } internal sealed class Transformation { @@ -304,22 +398,47 @@ class ChatContainer @Inject constructor( * Loading next — older — messages in history. * Loading the previous page if it exists. */ - data object LoadBefore : Commands() + data object LoadPrevious : Commands() /** * Loading next — more recent — messages in history. * Loading the next page if it exists. */ - data object LoadAfter : Commands() + data object LoadNext : Commands() /** * Loading message before and current given (reply) message. */ - data class LoadTo(val message: Id) : Commands() + data class LoadAround(val message: Id) : Commands() + + /** + * Scroll-to-bottom behavior. + */ + data object LoadEnd: Commands() } } companion object { - const val DEFAULT_CHAT_PAGING_SIZE = 100 + const val DEFAULT_CHAT_PAGING_SIZE = 10 + private const val MAX_CHAT_CACHE_SIZE = 1000 + private const val LAST_MESSAGES_MAX_SIZE = 10 } + + data class ChatMessageMeta(val id: Id, val order: String) + + data class ChatStreamState( + val messages: List, + val intent: Intent = Intent.None + ) + + sealed class Intent { + data class ScrollToMessage(val id: Id) : Intent() + data class Highlight(val id: Id) : Intent() + data object ScrollToBottom : Intent() + data object None : Intent() + } +} + +private fun List.isInCurrentWindow(id: Id): Boolean { + return this.any { it.id == id } } \ No newline at end of file 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 49c57f893e..26d84efde3 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 @@ -105,7 +105,7 @@ class ChatContainerTest { val first = awaitItem() assertEquals( expected = emptyList(), - actual = first + actual = first.messages ) advanceUntilIdle() val second = awaitItem() @@ -113,7 +113,7 @@ class ChatContainerTest { expected = listOf( msg ), - actual = second + actual = second.messages ) } } @@ -169,13 +169,13 @@ class ChatContainerTest { expected = listOf( initialMsg ), - actual = first + actual = first.messages ) advanceUntilIdle() val second = awaitItem() assertEquals( expected = emptyList(), - actual = second + actual = second.messages ) } } @@ -238,7 +238,7 @@ class ChatContainerTest { expected = listOf( initialMsg ), - actual = first + actual = first.messages ) advanceUntilIdle() val second = awaitItem() @@ -246,7 +246,7 @@ class ChatContainerTest { expected = listOf( msgAfterUpdate ), - actual = second + actual = second.messages ) } } @@ -312,7 +312,7 @@ class ChatContainerTest { expected = listOf( initialMsg ), - actual = first + actual = first.messages ) advanceUntilIdle() val second = awaitItem() @@ -321,7 +321,7 @@ class ChatContainerTest { newMsg, initialMsg ), - actual = second + actual = second.messages ) } } @@ -376,17 +376,17 @@ class ChatContainerTest { val initial = awaitItem() assertEquals( expected = listOf(firstMessage), - actual = initial + actual = initial.messages ) - container.onLoadNextPage() + container.onLoadPrevious() advanceUntilIdle() val updated = awaitItem() assertEquals( expected = listOf(nextMessage, firstMessage), - actual = updated + actual = updated.messages ) } } diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt index 43aa569f5e..f6f0e36750 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt @@ -5,6 +5,7 @@ import com.anytypeio.anytype.core_models.Hash import com.anytypeio.anytype.core_models.Id import com.anytypeio.anytype.core_models.ObjectWrapper import com.anytypeio.anytype.core_models.Url +import com.anytypeio.anytype.domain.chats.ChatContainer import com.anytypeio.anytype.presentation.confgs.ChatConfig import com.anytypeio.anytype.presentation.objects.ObjectIcon import com.anytypeio.anytype.presentation.search.GlobalSearchItemView @@ -145,4 +146,9 @@ sealed interface ChatView { data class Image(val hash: Hash): Avatar() } } -} \ No newline at end of file +} + +data class ChatViewState( + val messages: List = emptyList(), + val intent: ChatContainer.Intent = ChatContainer.Intent.None +) \ No newline at end of file diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt index 748884f1d7..7f7b915af0 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt @@ -50,8 +50,10 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -75,7 +77,7 @@ class ChatViewModel @Inject constructor( ) : BaseViewModel(), ExitToVaultDelegate by exitToVaultDelegate { val header = MutableStateFlow(HeaderView.Init) - val messages = MutableStateFlow>(emptyList()) + val uiState = MutableStateFlow(ChatViewState()) val chatBoxAttachments = MutableStateFlow>(emptyList()) val commands = MutableSharedFlow() val uXCommands = MutableSharedFlow() @@ -84,13 +86,12 @@ class ChatViewModel @Inject constructor( val mentionPanelState = MutableStateFlow(MentionPanelState.Hidden) private val dateFormatter = SimpleDateFormat("d MMMM YYYY") - private val data = MutableStateFlow>(emptyList()) private var account: Id = "" init { -// runDummyMessageGenerator() +// generateDummyChatHistory() viewModelScope.launch { spaceViews @@ -131,15 +132,15 @@ class ChatViewModel @Inject constructor( ) { combine( chatContainer - .watchWhileTrackingAttachments(chat = chat), - chatContainer.fetchAttachments(vmParams.space), - chatContainer.fetchReplies(chat = chat) + .watchWhileTrackingAttachments(chat = chat).distinctUntilChanged() + , + chatContainer.fetchAttachments(vmParams.space).distinctUntilChanged(), + chatContainer.fetchReplies(chat = chat).distinctUntilChanged() ) { result, dependencies, replies -> - Timber.d("Got chat results: ${result.size}") - data.value = result + Timber.d("DROID-2966 Got chat results: ${result.messages.size}") var previousDate: ChatView.DateSection? = null - buildList { - result.forEach { msg -> + val messageViews = buildList { + result.messages.forEach { msg -> val allMembers = members.get() val member = allMembers.let { type -> when (type) { @@ -294,8 +295,12 @@ class ChatViewModel @Inject constructor( add(view) } }.reversed() - }.flowOn(dispatchers.io).collect { - messages.value = it + ChatViewState( + messages = messageViews, + result.intent + ) + }.flowOn(dispatchers.io).distinctUntilChanged().collect { + uiState.value = it } } @@ -655,7 +660,7 @@ class ChatViewModel @Inject constructor( fun onReacted(msg: Id, reaction: String) { Timber.d("onReacted") viewModelScope.launch { - val message = messages.value.find { it is ChatView.Message && it.id == msg } + val message = uiState.value.messages.find { it is ChatView.Message && it.id == msg } if (message != null) { toggleChatMessageReaction.async( Command.ChatCommand.ToggleMessageReaction( @@ -891,17 +896,39 @@ class ChatViewModel @Inject constructor( } fun onChatScrolledToTop() { - Timber.d("onChatScrolledToTop") + Timber.d("DROID-2966 onChatScrolledToTop") viewModelScope.launch { - chatContainer.onLoadNextPage() + chatContainer.onLoadPrevious() } } fun onChatScrolledToBottom() { - Timber.d("onChatScrolledToBottom") - // TODO this behavior will be enabled later. + Timber.d("DROID-2966 onChatScrolledToBottom") viewModelScope.launch { - chatContainer.onLoadPreviousPage() + chatContainer.onLoadNext() + } + } + + fun onChatScrollToReply(replyMessage: Id) { + Timber.d("DROID-2966 onScrollToReply: $replyMessage") + viewModelScope.launch { + chatContainer.onLoadToReply(replyMessage = replyMessage) + } + } + + fun onScrollToBottomClicked() { + Timber.d("DROID-2966 onScrollToBottom") + viewModelScope.launch { + chatContainer.onLoadChatTail() + } + } + + fun onClearChatViewStateIntent() { + Timber.d("DROID-2966 onClearChatViewStateIntent") + viewModelScope.launch { + uiState.update { current -> + current.copy(intent = ChatContainer.Intent.None) + } } } @@ -910,15 +937,22 @@ class ChatViewModel @Inject constructor( */ private fun generateDummyChatHistory() { viewModelScope.launch { - repeat(100) { + var replyTo: Id? = null + repeat(100) { idx -> + addChatMessage.async( Command.ChatCommand.AddMessage( chat = vmParams.ctx, message = DummyMessageGenerator.generateMessage( - text = it.toString() + text = idx.toString(), + replyTo = if (idx == 99) replyTo else null ) ) - ) + ).onSuccess { (msg, payload) -> + if (idx == 0) { + replyTo = msg + } + } } } } diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatPreviews.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatPreviews.kt index 516eca61f9..37f365e3e4 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatPreviews.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatPreviews.kt @@ -9,6 +9,7 @@ import com.anytypeio.anytype.core_ui.common.DefaultPreviews import com.anytypeio.anytype.feature_chats.R import com.anytypeio.anytype.feature_chats.presentation.ChatView import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel +import com.anytypeio.anytype.feature_chats.presentation.ChatViewState import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -85,7 +86,8 @@ fun ChatPreview() { onAddReactionClicked = {}, onViewChatReaction = { a, b -> }, onMemberIconClicked = {}, - onMentionClicked = {} + onMentionClicked = {}, + onScrollToReplyClicked = {} ) } @@ -134,7 +136,8 @@ fun ChatPreview2() { onAddReactionClicked = {}, onViewChatReaction = { a, b -> }, onMemberIconClicked = {}, - onMentionClicked = {} + onMentionClicked = {}, + onScrollToReplyClicked = {} ) } @@ -143,29 +146,31 @@ fun ChatPreview2() { @Composable fun ChatScreenPreview() { ChatScreen( - messages = buildList { - repeat(30) { idx -> - add( - ChatView.Message( - id = idx.toString(), - content = ChatView.Message.Content( - msg = stringResource(id = R.string.default_text_placeholder), - parts = listOf( - ChatView.Message.Content.Part( - part = stringResource(id = R.string.default_text_placeholder) + uiMessageState = ChatViewState( + messages = buildList { + repeat(30) { idx -> + add( + ChatView.Message( + id = idx.toString(), + content = ChatView.Message.Content( + msg = stringResource(id = R.string.default_text_placeholder), + parts = listOf( + ChatView.Message.Content.Part( + part = stringResource(id = R.string.default_text_placeholder) + ) ) - ) - ), - author = "User ${idx.inc()}", - timestamp = - System.currentTimeMillis() - - 30.toDuration(DurationUnit.DAYS).inWholeMilliseconds - + idx.toDuration(DurationUnit.DAYS).inWholeMilliseconds, - creator = "random id" + ), + author = "User ${idx.inc()}", + timestamp = + System.currentTimeMillis() + - 30.toDuration(DurationUnit.DAYS).inWholeMilliseconds + + idx.toDuration(DurationUnit.DAYS).inWholeMilliseconds, + creator = "random id" + ) ) - ) - } - }.reversed(), + } + }.reversed() + ), onMessageSent = { a, b -> }, attachments = emptyList(), onClearAttachmentClicked = {}, @@ -189,7 +194,11 @@ fun ChatScreenPreview() { onMentionClicked = {}, mentionPanelState = ChatViewModel.MentionPanelState.Hidden, onTextChanged = {}, - onChatScrolledToTop = {} + onChatScrolledToTop = {}, + onChatScrolledToBottom = {}, + onScrollToReplyClicked = {}, + onClearIntent = {}, + onScrollToBottomClicked = {} ) } 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 d25f0c7a89..3588a035d7 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 @@ -6,6 +6,7 @@ import android.provider.OpenableColumns import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -77,15 +78,18 @@ import com.anytypeio.anytype.core_ui.views.Caption1Regular import com.anytypeio.anytype.core_ui.views.PreviewTitle2Regular import com.anytypeio.anytype.core_utils.common.DefaultFileInfo import com.anytypeio.anytype.core_utils.ext.parseImagePath +import com.anytypeio.anytype.domain.chats.ChatContainer import com.anytypeio.anytype.feature_chats.R import com.anytypeio.anytype.feature_chats.presentation.ChatView import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.ChatBoxMode import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.MentionPanelState import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.UXCommand +import com.anytypeio.anytype.feature_chats.presentation.ChatViewState +import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber @@ -120,7 +124,7 @@ fun ChatScreenWrapper( ChatScreen( chatBoxMode = vm.chatBoxMode.collectAsState().value, - messages = vm.messages.collectAsState().value, + uiMessageState = vm.uiState.collectAsState().value, attachments = vm.chatBoxAttachments.collectAsState().value, onMessageSent = { text, spans -> vm.onMessageSent( @@ -190,7 +194,11 @@ fun ChatScreenWrapper( text = value.text ) }, - onChatScrolledToTop = vm::onChatScrolledToTop + onChatScrolledToTop = vm::onChatScrolledToTop, + onChatScrolledToBottom = vm::onChatScrolledToBottom, + onScrollToReplyClicked = vm::onChatScrollToReply, + onClearIntent = vm::onClearChatViewStateIntent, + onScrollToBottomClicked = vm::onScrollToBottomClicked ) LaunchedEffect(Unit) { vm.uXCommands.collect { command -> @@ -227,6 +235,55 @@ fun ChatScreenWrapper( } } +@Composable +fun LazyListState.OnBottomReached( + thresholdItems: Int = 0, + onBottomReached: () -> Unit +) { + LaunchedEffect(this) { + var prevIndex = firstVisibleItemIndex + snapshotFlow { firstVisibleItemIndex } + .distinctUntilChanged() + .collect { index -> + val isDragging = isScrollInProgress + + // Are we scrolling *toward* the bottom edge? + val scrollingDown = isDragging && prevIndex > index + + // Have we crossed into the threshold zone? + val atBottom = index <= thresholdItems + + if (scrollingDown && atBottom) { + onBottomReached() + } + prevIndex = index + } + } +} + +@Composable +private fun LazyListState.OnTopReached( + thresholdItems: Int = 0, + onTopReached: () -> Unit +) { + val isReached = remember { + derivedStateOf { + val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() + if (lastVisibleItem != null) { + lastVisibleItem.index >= layoutInfo.totalItemsCount - 1 - thresholdItems + } else { + false + } + } + } + + LaunchedEffect(isReached) { + snapshotFlow { isReached.value } + .distinctUntilChanged() + .collect { if (it) onTopReached() } + } +} + /** * TODO: do date formating before rendering? */ @@ -235,7 +292,7 @@ fun ChatScreen( mentionPanelState: MentionPanelState, chatBoxMode: ChatBoxMode, lazyListState: LazyListState, - messages: List, + uiMessageState: ChatViewState, attachments: List, onMessageSent: (String, List) -> Unit, onClearAttachmentClicked: (ChatView.Message.ChatBoxAttachment) -> Unit, @@ -256,8 +313,15 @@ fun ChatScreen( onMemberIconClicked: (Id?) -> Unit, onMentionClicked: (Id) -> Unit, onTextChanged: (TextFieldValue) -> Unit, - onChatScrolledToTop: () -> Unit + onChatScrolledToTop: () -> Unit, + onChatScrolledToBottom: () -> Unit, + onScrollToReplyClicked: (Id) -> Unit, + onClearIntent: () -> Unit, + onScrollToBottomClicked: () -> Unit ) { + + Timber.d("DROID-2966 Render called with state") + var text by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } @@ -268,38 +332,68 @@ fun ChatScreen( val scope = rememberCoroutineScope() + LaunchedEffect(uiMessageState.intent) { + when (val intent = uiMessageState.intent) { + is ChatContainer.Intent.ScrollToMessage -> { + val index = uiMessageState.messages.indexOfFirst { + it is ChatView.Message && it.id == intent.id + } - // Scrolling to bottom when list size changes and we are at the bottom of the list - LaunchedEffect(messages.size) { - if (lazyListState.firstVisibleItemScrollOffset == 0) { - scope.launch { - lazyListState.animateScrollToItem(0) + if (index >= 0) { + Timber.d("DROID-2966 Waiting for layout to stabilize...") + + snapshotFlow { lazyListState.layoutInfo.totalItemsCount } + .first { it > index } + + lazyListState.scrollToItem(index) + + awaitFrame() + + val itemInfo = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + if (itemInfo != null) { + val viewportHeight = lazyListState.layoutInfo.viewportSize.height + + // Centering calculation: + // itemCenter relative to viewport top + val itemCenterFromTop = itemInfo.offset + (itemInfo.size / 2) + val viewportCenter = viewportHeight / 2 + + val delta = itemCenterFromTop - viewportCenter + + Timber.d("DROID-2966 Calculated delta for centering (reverseLayout-aware): $delta") + + // move negatively because reverseLayout flips + lazyListState.animateScrollBy(delta.toFloat()) + + Timber.d("DROID-2966 Scroll complete. Now clearing intent.") + + onClearIntent() + } else { + Timber.w("DROID-2966 Target item not found after scroll!") + } + } } + is ChatContainer.Intent.ScrollToBottom -> { + smoothScrollToBottom(lazyListState) + onClearIntent() + } + is ChatContainer.Intent.Highlight -> { + // maybe flash background, etc. + } + ChatContainer.Intent.None -> Unit } } + lazyListState.OnBottomReached( + thresholdItems = 3 + ) { + onChatScrolledToBottom() + } - var isAtTop by remember { mutableStateOf(false) } - - LaunchedEffect(lazyListState, messages.size) { - if (messages.isEmpty()) return@LaunchedEffect - snapshotFlow { - lazyListState.layoutInfo.visibleItemsInfo - .lastOrNull { it.key is String && !(it.key as String).startsWith(DATE_KEY_PREFIX) } - ?.key as? String - } - .distinctUntilChanged() - .collect { currentTopMessageId -> - val isNowAtTop = currentTopMessageId != null && - currentTopMessageId == (messages.lastOrNull { it is ChatView.Message } as? ChatView.Message)?.id - - if (isNowAtTop && !isAtTop) { - isAtTop = true - onChatScrolledToTop() - } else if (!isNowAtTop && isAtTop) { - isAtTop = false // reset for next entry - } - } + lazyListState.OnTopReached( + thresholdItems = 3 + ) { + onChatScrolledToTop() } Column( @@ -308,7 +402,7 @@ fun ChatScreen( Box(modifier = Modifier.weight(1f)) { Messages( modifier = Modifier.fillMaxSize(), - messages = messages, + messages = uiMessageState.messages, scrollState = lazyListState, onReacted = onReacted, onCopyMessage = onCopyMessage, @@ -331,7 +425,8 @@ fun ChatScreen( onAddReactionClicked = onAddReactionClicked, onViewChatReaction = onViewChatReaction, onMemberIconClicked = onMemberIconClicked, - onMentionClicked = onMentionClicked + onMentionClicked = onMentionClicked, + onScrollToReplyClicked = onScrollToReplyClicked ) // Jump to bottom button shows up when user scrolls past a threshold. // Convert to pixels: @@ -353,9 +448,7 @@ fun ChatScreen( .align(Alignment.BottomEnd) .padding(end = 12.dp), onGoToBottomClicked = { - scope.launch { - lazyListState.animateScrollToItem(index = 0) - } + onScrollToBottomClicked() }, enabled = jumpToBottomButtonEnabled ) @@ -393,7 +486,8 @@ fun ChatScreen( val replacementText = member.name + " " - val lengthDifference = replacementText.length - (query.range.last - query.range.first + 1) + val lengthDifference = + replacementText.length - (query.range.last - query.range.first + 1) val updatedText = input.replaceRange( query.range, @@ -404,7 +498,7 @@ fun ChatScreen( val updatedSpans = spans.map { span -> if (span.start > query.range.last) { - when(span) { + when (span) { is ChatBoxSpan.Mention -> { span.copy( start = span.start + lengthDifference, @@ -500,8 +594,10 @@ fun Messages( onAddReactionClicked: (String) -> Unit, onViewChatReaction: (Id, String) -> Unit, onMemberIconClicked: (Id?) -> Unit, - onMentionClicked: (Id) -> Unit + onMentionClicked: (Id) -> Unit, + onScrollToReplyClicked: (Id) -> Unit, ) { + Timber.d("DROID-2966 Messages composition: ${messages.map { if (it is ChatView.Message) it.content.msg else it }}") val scope = rememberCoroutineScope() LazyColumn( modifier = modifier, @@ -572,12 +668,11 @@ fun Messages( }, reply = msg.reply, onScrollToReplyClicked = { reply -> - // Naive implementation val idx = messages.indexOfFirst { it is ChatView.Message && it.id == reply.msg } if (idx != -1) { - scope.launch { - scrollState.animateScrollToItem(index = idx) - } + scope.launch { scrollState.animateScrollToItem(index = idx) } + } else { + onScrollToReplyClicked(reply.msg) } }, onAddReactionClicked = { @@ -687,6 +782,17 @@ fun TopDiscussionToolbar( } } +suspend fun smoothScrollToBottom(lazyListState: LazyListState) { + if (lazyListState.firstVisibleItemIndex > 0) { + lazyListState.scrollToItem(0) + return + } + while (lazyListState.firstVisibleItemScrollOffset > 0) { + val delta = (-lazyListState.firstVisibleItemScrollOffset).coerceAtLeast(-40) + lazyListState.animateScrollBy(delta.toFloat()) + } +} + @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Light Mode") @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Dark Mode") @Composable