From 617797eabf232c4da7274ac72b954a41b0c841c9 Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Sat, 24 May 2025 17:01:26 +0200 Subject: [PATCH] DROID-3183 Chats | Enhancement | Go-to-mention UX basics (#2457) --- .../anytypeio/anytype/ui/vault/VaultScreen.kt | 35 +++++++------- .../ui/widgets/types/SpaceChatWidget.kt | 46 +++++++++--------- .../anytypeio/anytype/core_models/Command.kt | 3 +- .../anytype/core_models/chats/Chat.kt | 4 ++ .../anytype/domain/chats/ChatContainer.kt | 47 ++++++++++++++++++ .../feature_chats/presentation/ChatView.kt | 3 +- .../presentation/ChatViewModel.kt | 10 +++- .../anytype/feature_chats/ui/ChatPreviews.kt | 3 +- .../anytype/feature_chats/ui/ChatScreen.kt | 45 +++++++++++++++-- .../anytype/feature_chats/ui/Toolbars.kt | 48 +++++++++++++++++++ .../res/drawable/ic_go_to_mention_button.xml | 9 ++++ .../middleware/interactor/Middleware.kt | 2 +- 12 files changed, 205 insertions(+), 50 deletions(-) create mode 100644 feature-chats/src/main/res/drawable/ic_go_to_mention_button.xml diff --git a/app/src/main/java/com/anytypeio/anytype/ui/vault/VaultScreen.kt b/app/src/main/java/com/anytypeio/anytype/ui/vault/VaultScreen.kt index cf1867d052..03fe70173a 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/vault/VaultScreen.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/vault/VaultScreen.kt @@ -370,24 +370,23 @@ fun VaultSpaceCard( .padding(end = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - //todo: uncomment when mention counters are fixed -// if (unreadMentionCount > 0) { -// Box( -// modifier = Modifier -// .background( -// color = colorResource(R.color.glyph_active), -// shape = CircleShape -// ) -// .size(18.dp), -// contentAlignment = Alignment.Center -// ) { -// Image( -// painter = painterResource(R.drawable.ic_chat_widget_mention), -// contentDescription = null -// ) -// } -// Spacer(modifier = Modifier.width(8.dp)) -// } + if (unreadMentionCount > 0) { + Box( + modifier = Modifier + .background( + color = colorResource(R.color.glyph_active), + shape = CircleShape + ) + .size(18.dp), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.ic_chat_widget_mention), + contentDescription = null + ) + } + Spacer(modifier = Modifier.width(8.dp)) + } if (unreadMessageCount > 0) { val shape = if (unreadMentionCount > 9) { diff --git a/app/src/main/java/com/anytypeio/anytype/ui/widgets/types/SpaceChatWidget.kt b/app/src/main/java/com/anytypeio/anytype/ui/widgets/types/SpaceChatWidget.kt index 008073cf26..ccc02d36e7 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/widgets/types/SpaceChatWidget.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/widgets/types/SpaceChatWidget.kt @@ -99,32 +99,30 @@ fun SpaceChatWidgetCard( color = colorResource(id = R.color.text_primary), ) - // Uncomment when go-to-bottom is ready -// if (unReadMentionCount > 0) { -// Box( -// modifier = Modifier -// .background( -// color = colorResource(R.color.transparent_active), -// shape = CircleShape -// ) -// .size(20.dp), -// contentAlignment = Alignment.Center -// ) { -// Image( -// painter = painterResource(R.drawable.ic_chat_widget_mention), -// contentDescription = null -// ) -// } -// if (unReadMessageCount == 0) { -// Spacer(modifier = Modifier.width(16.dp)) -// } -// } + if (unReadMentionCount > 0) { + Box( + modifier = Modifier + .background( + color = colorResource(R.color.transparent_active), + shape = CircleShape + ) + .size(20.dp), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.ic_chat_widget_mention), + contentDescription = null + ) + } + if (unReadMessageCount == 0) { + Spacer(modifier = Modifier.width(16.dp)) + } + } if (unReadMessageCount > 0) { - // Uncomment when go-to-bottom is ready -// if (unReadMentionCount > 0) { -// Spacer(modifier = Modifier.width(8.dp)) -// } + if (unReadMentionCount > 0) { + Spacer(modifier = Modifier.width(8.dp)) + } Box( modifier = Modifier .height(20.dp) 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 35ae02f2ce..6c25b54654 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 @@ -625,7 +625,8 @@ sealed class Command { val chat: Id, val afterOrderId: Id? = null, val beforeOrderId: Id? = null, - val lastStateId: Id? = null + val lastStateId: Id? = null, + val isMention: Boolean = false ) data class AddMessage( 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 aa6e056f81..c3e0b9d93e 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 @@ -109,6 +109,10 @@ sealed class Chat { return unreadMessages?.counter != null && unreadMessages.counter > 0 } + val hasUnReadMentions: Boolean get() { + return unreadMentions?.counter != null && unreadMentions.counter > 0 + } + val oldestMessageOrderId: Id? = unreadMessages?.olderOrderId val oldestMentionMessageOrderId: Id? = unreadMentions?.olderOrderId } 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 776e842f88..d3632b27c9 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 @@ -273,6 +273,46 @@ class ChatContainer @Inject constructor( state } } + is Transformation.Commands.GoToMention -> { + if (state.state.hasUnReadMentions) { + val oldestMentionOrderId = state.state.oldestMentionMessageOrderId + val messages = try { + loadAroundMessageOrder( + chat = chat, + order = oldestMentionOrderId.orEmpty() + ) + } catch (e: Exception) { + state.messages.also { + logger.logException(e, "DROID-2966 Error while loading mention context") + } + } + runCatching { + repo.readChatMessages( + command = Command.ChatCommand.ReadMessages( + chat = chat, + beforeOrderId = oldestMentionOrderId, + lastStateId = state.state.lastStateId, + isMention = true + ) + ) + }.onFailure { + logger.logException(it, "DROID-2966 Error while reading mentions") + }.onSuccess { + logger.logInfo("DROID-2966 Read mentions with success") + } + val target = messages.find { it.order == oldestMentionOrderId } + ChatStreamState( + messages = messages, + intent = if (target != null) + Intent.ScrollToMessage(target.id) + else + Intent.None, + state = state.state + ) + } else { + state + } + } is Transformation.Commands.ClearIntent -> { state.copy( intent = Intent.None @@ -560,6 +600,11 @@ class ChatContainer @Inject constructor( commands.emit(Transformation.Commands.UpdateVisibleRange(from, to)) } + suspend fun onGoToMention() { + logger.logInfo("DROID-2966 onGoToMention") + commands.emit(Transformation.Commands.GoToMention) + } + private fun cacheLastMessages(messages: List) { messages.sortedByDescending { it.order } // Newest first .take(LAST_MESSAGES_MAX_SIZE) @@ -610,6 +655,8 @@ class ChatContainer @Inject constructor( data class UpdateVisibleRange(val from: Id, val to: Id) : Commands() data object ClearIntent : Commands() + + data object GoToMention : Commands() } } 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 35f3e29418..0d262f9f3e 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 @@ -170,6 +170,7 @@ data class ChatViewState( val counter: Counter = Counter() ) { data class Counter( - val count: Int = 0 + val messages: Int = 0, + val mentions: Int = 0 ) } \ 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 8a3bdc612d..d8fd8d7564 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 @@ -345,7 +345,8 @@ class ChatViewModel @Inject constructor( messages = messageViews, intent = result.intent, counter = ChatViewState.Counter( - count = result.state.unreadMessages?.counter ?: 0 + messages = result.state.unreadMessages?.counter ?: 0, + mentions = result.state.unreadMentions?.counter ?: 0 ) ) }.flowOn(dispatchers.io).distinctUntilChanged().collect { @@ -1036,6 +1037,13 @@ class ChatViewModel @Inject constructor( } } + fun onGoToMentionClicked() { + Timber.d("DROID-2966 onGoToMentionClicked") + viewModelScope.launch { + chatContainer.onGoToMention() + } + } + fun onChatScrollToReply(replyMessage: Id) { Timber.d("DROID-2966 onScrollToReply: $replyMessage") viewModelScope.launch { 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 debf0a22e2..f92c519317 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 @@ -201,7 +201,8 @@ fun ChatScreenPreview() { onClearIntent = {}, onScrollToBottomClicked = {}, onVisibleRangeChanged = { _, _ -> }, - onUrlInserted = {} + onUrlInserted = {}, + onGoToMentionClicked = {} ) } 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 a92253f0ba..4f988bfcd7 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 @@ -227,7 +227,8 @@ fun ChatScreenWrapper( onClearIntent = vm::onClearChatViewStateIntent, onScrollToBottomClicked = vm::onScrollToBottomClicked, onVisibleRangeChanged = vm::onVisibleRangeChanged, - onUrlInserted = vm::onUrlPasted + onUrlInserted = vm::onUrlPasted, + onGoToMentionClicked = vm::onGoToMentionClicked ) LaunchedEffect(Unit) { vm.uXCommands.collect { command -> @@ -341,6 +342,7 @@ fun ChatScreen( onScrollToBottomClicked: (Id?) -> Unit, onVisibleRangeChanged: (Id, Id) -> Unit, onUrlInserted: (Url) -> Unit, + onGoToMentionClicked: () -> Unit ) { Timber.d("DROID-2966 Render called with state, number of messages: ${messages.size}") @@ -519,6 +521,43 @@ fun ChatScreen( onScrollToReplyClicked = onScrollToReplyClicked ) + GoToMentionButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = 12.dp, + bottom = if (jumpToBottomButtonEnabled) 60.dp else 0.dp + ), + onClick = onGoToMentionClicked, + enabled = counter.mentions > 0 + ) + + if (counter.mentions > 0) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .defaultMinSize(minWidth = 20.dp, minHeight = 20.dp) + .padding( + bottom = if (jumpToBottomButtonEnabled) 106.dp else 46.dp, + end = 2.dp + ) + .background( + color = colorResource(R.color.transparent_active), + shape = CircleShape + ) + ) { + Text( + text = counter.mentions.toString(), + modifier = Modifier.align(Alignment.Center).padding( + horizontal = 5.dp, + vertical = 2.dp + ), + color = colorResource(R.color.glyph_white), + style = Caption1Regular + ) + } + } + GoToBottomButton( modifier = Modifier .align(Alignment.BottomEnd) @@ -541,7 +580,7 @@ fun ChatScreen( enabled = jumpToBottomButtonEnabled ) - if (counter.count > 0) { + if (counter.messages > 0) { Box( modifier = Modifier .align(Alignment.BottomEnd) @@ -553,7 +592,7 @@ fun ChatScreen( ) ) { Text( - text = counter.count.toString(), + text = counter.messages.toString(), modifier = Modifier.align(Alignment.Center).padding( horizontal = 5.dp, vertical = 2.dp diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Toolbars.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Toolbars.kt index 84e53496df..73e054e2a7 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Toolbars.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Toolbars.kt @@ -206,4 +206,52 @@ fun GoToBottomButton( ) } } +} + +@Composable +fun GoToMentionButton( + enabled: Boolean, + modifier: Modifier, + onClick: () -> Unit +) { + val transition = updateTransition( + enabled, + label = "JumpToBottom visibility animation" + ) + val bottomOffset by transition.animateDp(label = "JumpToBottom offset animation") { + if (it) { + (12).dp + } else { + (-12).dp + } + } + if (bottomOffset > 0.dp) { + Box( + modifier = modifier + .offset(x = 0.dp, y = -bottomOffset) + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(color = colorResource(id = R.color.navigation_panel)) + .clickable { + onClick() + } + + ) { + Image( + painter = painterResource(id = R.drawable.ic_go_to_mention_button), + contentDescription = "Arrow icon", + modifier = Modifier.align(Alignment.Center) + ) + } + } +} + +@DefaultPreviews +@Composable +fun GoToMentionButtonPreview() { + GoToMentionButton( + enabled = true, + modifier = Modifier, + onClick = {} + ) } \ No newline at end of file diff --git a/feature-chats/src/main/res/drawable/ic_go_to_mention_button.xml b/feature-chats/src/main/res/drawable/ic_go_to_mention_button.xml new file mode 100644 index 0000000000..eb24595bfe --- /dev/null +++ b/feature-chats/src/main/res/drawable/ic_go_to_mention_button.xml @@ -0,0 +1,9 @@ + + + diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt index 2ddb42a1f6..7b4f54d241 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt @@ -2832,7 +2832,7 @@ class Middleware @Inject constructor( afterOrderId = command.afterOrderId.orEmpty(), beforeOrderId = command.beforeOrderId.orEmpty(), lastStateId = command.lastStateId.orEmpty(), - type = ReadType.Messages + type = if (command.isMention) ReadType.Mentions else ReadType.Messages ) logRequestIfDebug(request) val (response, time) = measureTimedValue { service.chatReadMessages(request) }