From 3994d456d55f13a11cdaa2fc568db4a136f02f3b Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Sat, 30 Nov 2024 16:22:03 +0100 Subject: [PATCH] DROID-3044 Chats | Enhancement | Basics for chat replies (#1854) --- .../anytypeio/anytype/core_models/Command.kt | 7 +- .../anytype/core_models/chats/Chat.kt | 9 +- .../auth/repo/block/BlockDataRepository.kt | 4 + .../data/auth/repo/block/BlockRemote.kt | 1 + .../domain/block/repo/BlockRepository.kt | 1 + .../anytype/domain/chats/ChatContainer.kt | 38 ++++- .../domain/chats/GetChatMessagesByIds.kt | 18 +++ .../presentation/DiscussionView.kt | 9 +- .../presentation/DiscussionViewModel.kt | 87 ++++++++++- .../DiscussionViewModelFactory.kt | 7 +- .../ui/DiscussionPreviews.kt | 20 ++- .../ui/DiscussionScreen.kt | 145 ++++++++++++++++-- .../drawable/ic_chat_close_chat_box_reply.xml | 18 +++ localization/src/main/res/values/strings.xml | 1 + .../middleware/block/BlockMiddleware.kt | 4 + .../middleware/interactor/Middleware.kt | 12 ++ .../middleware/service/MiddlewareService.kt | 1 + .../MiddlewareServiceImplementation.kt | 13 ++ 18 files changed, 352 insertions(+), 43 deletions(-) create mode 100644 domain/src/main/java/com/anytypeio/anytype/domain/chats/GetChatMessagesByIds.kt create mode 100644 feature-discussions/src/main/res/drawable/ic_chat_close_chat_box_reply.xml 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 14fb5e84de..960a0bafeb 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 @@ -594,7 +594,7 @@ sealed class Command { sealed class ChatCommand { data class AddMessage( val chat: Id, - val message: Chat.Message + val message: Chat.Message, ) : ChatCommand() data class DeleteMessage( @@ -613,6 +613,11 @@ sealed class Command { val limit: Int ) : ChatCommand() + data class GetMessagesByIds( + val chat: Id, + val messages: List + ) : ChatCommand() + data class SubscribeLastMessages( val chat: Id, val limit: Int 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 37c5bd9b33..90340fc6f9 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 @@ -41,16 +41,17 @@ sealed class Chat { */ fun new( text: String, - attachments: List = emptyList() - ) : Message = Chat.Message( + attachments: List = emptyList(), + replyToMessageId: Id? = null + ) : Message = Message( id = "", createdAt = 0L, modifiedAt = 0L, attachments = attachments, reactions = emptyMap(), creator = "", - replyToMessageId = "", - content = Chat.Message.Content( + replyToMessageId = replyToMessageId, + content = Content( text = text, marks = emptyList(), style = Block.Content.Text.Style.P diff --git a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockDataRepository.kt b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockDataRepository.kt index f6d5a3ca15..70260ec1e5 100644 --- a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockDataRepository.kt +++ b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockDataRepository.kt @@ -1067,6 +1067,10 @@ class BlockDataRepository( return remote.getChatMessages(command) } + override suspend fun getChatMessagesByIds(command: Command.ChatCommand.GetMessagesByIds): List { + return remote.getChatMessagesByIds(command) + } + override suspend fun subscribeLastChatMessages( command: Command.ChatCommand.SubscribeLastMessages ): Command.ChatCommand.SubscribeLastMessages.Response { diff --git a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockRemote.kt b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockRemote.kt index 235a9da480..498e3650fd 100644 --- a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockRemote.kt +++ b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockRemote.kt @@ -452,6 +452,7 @@ interface BlockRemote { suspend fun editChatMessage(command: Command.ChatCommand.EditMessage) suspend fun deleteChatMessage(command: Command.ChatCommand.DeleteMessage) suspend fun getChatMessages(command: Command.ChatCommand.GetMessages): List + suspend fun getChatMessagesByIds(command: Command.ChatCommand.GetMessagesByIds): List suspend fun subscribeLastChatMessages(command: Command.ChatCommand.SubscribeLastMessages): Command.ChatCommand.SubscribeLastMessages.Response suspend fun toggleChatMessageReaction(command: Command.ChatCommand.ToggleMessageReaction) suspend fun unsubscribeChat(chat: Id) diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/block/repo/BlockRepository.kt b/domain/src/main/java/com/anytypeio/anytype/domain/block/repo/BlockRepository.kt index a589fc93b3..b794e7a7c4 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/block/repo/BlockRepository.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/block/repo/BlockRepository.kt @@ -495,6 +495,7 @@ interface BlockRepository { suspend fun editChatMessage(command: Command.ChatCommand.EditMessage) suspend fun deleteChatMessage(command: Command.ChatCommand.DeleteMessage) suspend fun getChatMessages(command: Command.ChatCommand.GetMessages): List + suspend fun getChatMessagesByIds(command: Command.ChatCommand.GetMessagesByIds): List suspend fun subscribeLastChatMessages(command: Command.ChatCommand.SubscribeLastMessages): Command.ChatCommand.SubscribeLastMessages.Response suspend fun toggleChatMessageReaction(command: Command.ChatCommand.ToggleMessageReaction) suspend fun unsubscribeChat(chat: Id) 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 84108e2f23..46452760d3 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 @@ -33,6 +33,7 @@ class ChatContainer @Inject constructor( private val payloads = MutableSharedFlow>() private val attachments = MutableSharedFlow>(replay = 0) + private val replies = MutableSharedFlow>(replay = 0) @Deprecated("Naive implementation. Add caching logic - maybe store for wrappers") fun fetchAttachments(space: Space) : Flow> { @@ -64,18 +65,39 @@ class ChatContainer @Inject constructor( .map { wrappers -> wrappers.associate { it.id to it } } } + @Deprecated("Naive implementation. Add caching logic") + fun fetchReplies(chat: Id) : Flow> { + return replies + .distinctUntilChanged() + .map { ids -> + if (ids.isNotEmpty()) { + repo.getChatMessagesByIds( + command = Command.ChatCommand.GetMessagesByIds( + chat = chat, + messages = ids.toList() + ) + ) + } else { + emptyList() + } + } + .distinctUntilChanged() + .map { messages -> messages.associate { it.id to it } } + } + fun watchWhileTrackingAttachments(chat: Id): Flow> { return watch(chat) .onEach { messages -> - val ids = messages - .map { msg -> - msg.attachments.map { - it.target - } + val repliesIds = mutableSetOf() + val attachmentsIds = mutableSetOf() + messages.forEach { msg -> + attachmentsIds.addAll(msg.attachments.map { it.target }) + if (!msg.replyToMessageId.isNullOrEmpty()) { + repliesIds.add(msg.replyToMessageId.orEmpty()) } - .flatten() - .toSet() - attachments.emit(ids) + } + attachments.emit(attachmentsIds) + replies.emit(repliesIds) } } diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/chats/GetChatMessagesByIds.kt b/domain/src/main/java/com/anytypeio/anytype/domain/chats/GetChatMessagesByIds.kt new file mode 100644 index 0000000000..2c44238aa9 --- /dev/null +++ b/domain/src/main/java/com/anytypeio/anytype/domain/chats/GetChatMessagesByIds.kt @@ -0,0 +1,18 @@ +package com.anytypeio.anytype.domain.chats + +import com.anytypeio.anytype.core_models.Command +import com.anytypeio.anytype.core_models.chats.Chat +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.domain.base.ResultInteractor +import com.anytypeio.anytype.domain.block.repo.BlockRepository +import javax.inject.Inject + +class GetChatMessagesByIds @Inject constructor( + private val repo: BlockRepository, + dispatchers: AppCoroutineDispatchers +) : ResultInteractor>(dispatchers.io) { + + override suspend fun doWork(params: Command.ChatCommand.GetMessagesByIds): List { + return repo.getChatMessagesByIds(params) + } +} \ No newline at end of file diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt index dfcefbf709..e4433cd729 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionView.kt @@ -16,7 +16,8 @@ sealed interface DiscussionView { val reactions: List = emptyList(), val isUserAuthor: Boolean = false, val isEdited: Boolean = false, - val avatar: Avatar = Avatar.Initials() + val avatar: Avatar = Avatar.Initials(), + val reply: Reply? = null ) : DiscussionView { data class Content(val msg: String, val parts: List) { @@ -32,6 +33,12 @@ sealed interface DiscussionView { } } + data class Reply( + val msg: Id, + val text: String, + val author: String + ) + sealed class Attachment { data class Image( val target: Id, diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt index 9f0a335c46..dac2b49c1f 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModel.kt @@ -11,6 +11,7 @@ import com.anytypeio.anytype.core_models.primitives.Space import com.anytypeio.anytype.core_ui.text.splitByMarks import com.anytypeio.anytype.core_utils.ext.withLatestFrom import com.anytypeio.anytype.domain.auth.interactor.GetAccount +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers import com.anytypeio.anytype.domain.base.fold import com.anytypeio.anytype.domain.base.onFailure import com.anytypeio.anytype.domain.base.onSuccess @@ -33,6 +34,7 @@ import javax.inject.Inject import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import timber.log.Timber @@ -48,7 +50,8 @@ class DiscussionViewModel @Inject constructor( private val members: ActiveSpaceMemberSubscriptionContainer, private val getAccount: GetAccount, private val urlBuilder: UrlBuilder, - private val spaceViews: SpaceViewSubscriptionContainer + private val spaceViews: SpaceViewSubscriptionContainer, + private val dispatchers: AppCoroutineDispatchers ) : BaseViewModel() { val name = MutableStateFlow(null) @@ -102,15 +105,20 @@ class DiscussionViewModel @Inject constructor( } } + // TODO move to IO thread. private suspend fun proceedWithObservingChatMessages( account: Id, chat: Id ) { chatContainer .watchWhileTrackingAttachments(chat = chat) - .withLatestFrom(chatContainer.fetchAttachments(vmParams.space)) { result, dependencies -> + .withLatestFrom( + chatContainer.fetchAttachments(vmParams.space), + chatContainer.fetchReplies(chat = chat) + ) { result, dependencies, replies -> result.map { msg -> - val member = members.get().let { type -> + val allMembers = members.get() + val member = allMembers.let { type -> when (type) { is Store.Data -> type.members.find { member -> member.identity == msg.creator @@ -121,6 +129,30 @@ class DiscussionViewModel @Inject constructor( val content = msg.content + val replyToId = msg.replyToMessageId + + val reply = if (replyToId.isNullOrEmpty()) { + null + } else { + val msg = replies[replyToId] + if (msg != null) { + DiscussionView.Message.Reply( + msg = msg.id, + text = msg.content?.text.orEmpty(), + author = allMembers.let { type -> + when (type) { + is Store.Data -> type.members.find { member -> + member.identity == msg.creator + }?.name.orEmpty() + is Store.Empty -> "" + } + } + ) + } else { + null + } + } + DiscussionView.Message( id = msg.id, timestamp = msg.createdAt * 1000, @@ -136,6 +168,7 @@ class DiscussionViewModel @Inject constructor( ) } ), + reply = reply, author = member?.name ?: msg.creator.takeLast(5), isUserAuthor = msg.creator == account, isEdited = msg.modifiedAt > msg.createdAt, @@ -182,6 +215,7 @@ class DiscussionViewModel @Inject constructor( ) }.reversed() } +// .flowOn(dispatchers.io) .collect { result -> messages.value = result } @@ -215,7 +249,6 @@ class DiscussionViewModel @Inject constructor( Timber.e(it, "Error while adding message") } } - is ChatBoxMode.EditMessage -> { editChatMessage.async( params = Command.ChatCommand.EditMessage( @@ -234,6 +267,31 @@ class DiscussionViewModel @Inject constructor( chatBoxMode.value = ChatBoxMode.Default } } + is ChatBoxMode.Reply -> { + addChatMessage.async( + params = Command.ChatCommand.AddMessage( + chat = chat, + message = Chat.Message.new( + text = msg, + replyToMessageId = mode.msg, + attachments = attachments.value.map { a -> + Chat.Message.Attachment( + target = a.id, + type = Chat.Message.Attachment.Type.Link + ) + } + ) + ) + ).onSuccess { (id, payload) -> + attachments.value = emptyList() + chatContainer.onPayload(payload) + delay(JUMP_TO_BOTTOM_DELAY) + commands.emit(UXCommand.JumpToBottom) + }.onFailure { + Timber.e(it, "Error while adding message") + } + chatBoxMode.value = ChatBoxMode.Default + } } } } @@ -272,6 +330,12 @@ class DiscussionViewModel @Inject constructor( attachments.value = emptyList() } + fun onClearReplyClicked() { + viewModelScope.launch { + chatBoxMode.value = ChatBoxMode.Default + } + } + fun onReacted(msg: Id, reaction: String) { Timber.d("onReacted") viewModelScope.launch { @@ -292,6 +356,16 @@ class DiscussionViewModel @Inject constructor( } } + fun onReplyMessage(msg: DiscussionView.Message) { + viewModelScope.launch { + chatBoxMode.value = ChatBoxMode.Reply( + msg = msg.id, + text = msg.content.msg, + author = msg.author + ) + } + } + fun onDeleteMessage(msg: DiscussionView.Message) { Timber.d("onDeleteMessageClicked") viewModelScope.launch { @@ -339,6 +413,11 @@ class DiscussionViewModel @Inject constructor( sealed class ChatBoxMode { data object Default : ChatBoxMode() data class EditMessage(val msg: Id) : ChatBoxMode() + data class Reply( + val msg: Id, + val text: String, + val author: String + ): ChatBoxMode() } sealed class Params { diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModelFactory.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModelFactory.kt index cd6b75926c..072fee6f20 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModelFactory.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/presentation/DiscussionViewModelFactory.kt @@ -3,6 +3,7 @@ package com.anytypeio.anytype.feature_discussions.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.anytypeio.anytype.domain.auth.interactor.GetAccount +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers import com.anytypeio.anytype.domain.chats.AddChatMessage import com.anytypeio.anytype.domain.chats.ChatContainer import com.anytypeio.anytype.domain.chats.DeleteChatMessage @@ -28,7 +29,8 @@ class DiscussionViewModelFactory @Inject constructor( private val members: ActiveSpaceMemberSubscriptionContainer, private val getAccount: GetAccount, private val urlBuilder: UrlBuilder, - private val spaceViews: SpaceViewSubscriptionContainer + private val spaceViews: SpaceViewSubscriptionContainer, + private val dispatchers: AppCoroutineDispatchers ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = DiscussionViewModel( @@ -43,6 +45,7 @@ class DiscussionViewModelFactory @Inject constructor( deleteChatMessage = deleteChatMessage, urlBuilder = urlBuilder, editChatMessage = editChatMessage, - spaceViews = spaceViews + spaceViews = spaceViews, + dispatchers = dispatchers ) as T } \ No newline at end of file diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionPreviews.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionPreviews.kt index 022e701160..745b64771b 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionPreviews.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionPreviews.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.anytypeio.anytype.core_models.chats.Chat import com.anytypeio.anytype.feature_discussions.R import com.anytypeio.anytype.feature_discussions.presentation.DiscussionView +import com.anytypeio.anytype.feature_discussions.presentation.DiscussionViewModel import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -66,9 +67,8 @@ fun DiscussionPreview() { onCopyMessage = {}, onAttachmentClicked = {}, onEditMessage = {}, - onMarkupLinkClicked = { - - } + onMarkupLinkClicked = {}, + onReplyMessage = {} ) } @@ -118,7 +118,10 @@ fun DiscussionScreenPreview() { onAttachFileClicked = {}, onUploadAttachmentClicked = {}, onAttachMediaClicked = {}, - onAttachObjectClicked = {} + onAttachObjectClicked = {}, + onReplyMessage = {}, + chatBoxMode = DiscussionViewModel.ChatBoxMode.Default, + onClearReplyClicked = {} ) } @@ -142,7 +145,8 @@ fun BubblePreview() { onCopyMessage = {}, onAttachmentClicked = {}, onEditMessage = {}, - onMarkupLinkClicked = {} + onMarkupLinkClicked = {}, + onReply = {} ) } @@ -167,7 +171,8 @@ fun BubbleEditedPreview() { onCopyMessage = {}, onAttachmentClicked = {}, onEditMessage = {}, - onMarkupLinkClicked = {} + onMarkupLinkClicked = {}, + onReply = {} ) } @@ -199,6 +204,7 @@ fun BubbleWithAttachmentPreview() { }, onAttachmentClicked = {}, onEditMessage = {}, - onMarkupLinkClicked = {} + onMarkupLinkClicked = {}, + onReply = {} ) } \ No newline at end of file diff --git a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt index 135e1cf5f6..7f3eae8b1d 100644 --- a/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt +++ b/feature-discussions/src/main/java/com/anytypeio/anytype/feature_discussions/ui/DiscussionScreen.kt @@ -93,10 +93,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import coil.compose.AsyncImage -import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import com.anytypeio.anytype.core_models.Id -import com.anytypeio.anytype.core_models.chats.Chat import com.anytypeio.anytype.core_ui.foundation.AlertConfig import com.anytypeio.anytype.core_ui.foundation.AlertIcon import com.anytypeio.anytype.core_ui.foundation.Divider @@ -158,6 +156,7 @@ fun DiscussionScreenWrapper( val clipboard = LocalClipboardManager.current val lazyListState = rememberLazyListState() DiscussionScreen( + chatBoxMode = vm.chatBoxMode.collectAsState().value, isSpaceLevelChat = isSpaceLevelChat, title = vm.name.collectAsState().value, messages = vm.messages.collectAsState().value, @@ -187,7 +186,9 @@ fun DiscussionScreenWrapper( }, onUploadAttachmentClicked = { - } + }, + onReplyMessage = vm::onReplyMessage, + onClearReplyClicked = vm::onClearReplyClicked ) LaunchedEffect(Unit) { vm.commands.collect { command -> @@ -211,6 +212,7 @@ fun DiscussionScreenWrapper( */ @Composable fun DiscussionScreen( + chatBoxMode: ChatBoxMode, isSpaceLevelChat: Boolean, isInEditMessageMode: Boolean = false, lazyListState: LazyListState, @@ -222,10 +224,12 @@ fun DiscussionScreen( onAttachClicked: () -> Unit, onBackButtonClicked: () -> Unit, onClearAttachmentClicked: () -> Unit, + onClearReplyClicked: () -> Unit, onReacted: (Id, String) -> Unit, onDeleteMessage: (DiscussionView.Message) -> Unit, onCopyMessage: (DiscussionView.Message) -> Unit, onEditMessage: (DiscussionView.Message) -> Unit, + onReplyMessage: (DiscussionView.Message) -> Unit, onAttachmentClicked: (DiscussionView.Message.Attachment) -> Unit, onExitEditMessageMode: () -> Unit, onMarkupLinkClicked: (String) -> Unit, @@ -284,6 +288,7 @@ fun DiscussionScreen( chatBoxFocusRequester.requestFocus() } }, + onReplyMessage = onReplyMessage, onMarkupLinkClicked = onMarkupLinkClicked ) // Jump to bottom button shows up when user scrolls past a threshold. @@ -324,6 +329,7 @@ fun DiscussionScreen( } ChatBox( + mode = chatBoxMode, modifier = Modifier .imePadding() .navigationBarsPadding(), @@ -349,7 +355,8 @@ fun DiscussionScreen( onAttachMediaClicked = onAttachMediaClicked, onUploadAttachmentClicked = onUploadAttachmentClicked, onAttachObjectClicked = onAttachObjectClicked, - onClearAttachmentClicked = onClearAttachmentClicked + onClearAttachmentClicked = onClearAttachmentClicked, + onClearReplyClicked = onClearReplyClicked ) } } @@ -482,6 +489,7 @@ private fun OldChatBox( @Composable private fun ChatBox( + mode: ChatBoxMode = ChatBoxMode.Default, modifier: Modifier = Modifier, onBackButtonClicked: () -> Unit, chatBoxFocusRequester: FocusRequester, @@ -497,7 +505,8 @@ private fun ChatBox( onAttachMediaClicked: () -> Unit, onAttachFileClicked: () -> Unit, onUploadAttachmentClicked: () -> Unit, - onClearAttachmentClicked: () -> Unit + onClearAttachmentClicked: () -> Unit, + onClearReplyClicked: () -> Unit ) { var showDropdownMenu by remember { mutableStateOf(false) } @@ -552,8 +561,57 @@ private fun ChatBox( ) } } - Row( - ) { + when(mode) { + is ChatBoxMode.Default -> { + + } + is ChatBoxMode.EditMessage -> { + + } + is ChatBoxMode.Reply -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(54.dp) + ) { + Text( + text = "Reply to ${mode.author}", + modifier = Modifier.padding( + start = 12.dp, + top = 8.dp, + end = 44.dp + ), + style = Caption1Medium, + color = colorResource(R.color.text_primary), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = mode.text, + modifier = Modifier.padding( + start = 12.dp, + top = 28.dp, + end = 44.dp + ), + style = Caption1Regular, + color = colorResource(R.color.text_primary), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Image( + painter = painterResource(R.drawable.ic_chat_close_chat_box_reply), + contentDescription = "Clear reply to icon", + modifier = Modifier + .padding(end = 12.dp) + .align(Alignment.CenterEnd) + .clickable { + onClearReplyClicked() + } + ) + } + } + } + Row { Box( modifier = Modifier .padding(horizontal = 4.dp, vertical = 8.dp) @@ -829,6 +887,7 @@ fun Messages( onCopyMessage: (DiscussionView.Message) -> Unit, onAttachmentClicked: (DiscussionView.Message.Attachment) -> Unit, onEditMessage: (DiscussionView.Message) -> Unit, + onReplyMessage: (DiscussionView.Message) -> Unit, onMarkupLinkClicked: (String) -> Unit ) { LazyColumn( @@ -879,7 +938,11 @@ fun Messages( onEditMessage = { onEditMessage(msg) }, - onMarkupLinkClicked = onMarkupLinkClicked + onMarkupLinkClicked = onMarkupLinkClicked, + onReply = { + onReplyMessage(msg) + }, + reply = msg.reply ) if (msg.isUserAuthor) { Spacer(modifier = Modifier.width(8.dp)) @@ -998,6 +1061,7 @@ val userMessageBubbleColor = Color(0x66000000) fun Bubble( modifier: Modifier = Modifier, name: String, + reply: DiscussionView.Message.Reply? = null, content: DiscussionView.Message.Content, timestamp: Long, attachments: List = emptyList(), @@ -1008,6 +1072,7 @@ fun Bubble( onDeleteMessage: () -> Unit, onCopyMessage: () -> Unit, onEditMessage: () -> Unit, + onReply: () -> Unit, onAttachmentClicked: (DiscussionView.Message.Attachment) -> Unit, onMarkupLinkClicked: (String) -> Unit ) { @@ -1020,13 +1085,54 @@ fun Bubble( userMessageBubbleColor else defaultBubbleColor, - shape = RoundedCornerShape(24.dp) + shape = RoundedCornerShape(20.dp) ) - .clip(RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(20.dp)) .clickable { showDropdownMenu = !showDropdownMenu } ) { + if (reply != null) { + Box( + modifier = Modifier + .padding(4.dp) + .fillMaxWidth() + .height(54.dp) + .background( + color = colorResource(R.color.navigation_panel_icon), + shape = RoundedCornerShape(16.dp) + ) + ) { + Text( + text = reply.author, + modifier = Modifier.padding( + start = 12.dp, + top = 8.dp, + end = 12.dp + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isUserAuthor) + colorResource(id = R.color.text_white) + else + colorResource(id = R.color.text_primary), + ) + Text( + modifier = Modifier.padding( + start = 12.dp, + top = 26.dp, + end = 12.dp + ), + text = reply.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isUserAuthor) + colorResource(id = R.color.text_white) + else + colorResource(id = R.color.text_primary), + ) + } + } Row( modifier = Modifier.padding( start = 16.dp, @@ -1100,12 +1206,7 @@ fun Bubble( if (isEdited) { withStyle( - style = SpanStyle( - color = if (isUserAuthor) - colorResource(id = R.color.text_white) - else - colorResource(id = R.color.text_primary), - ) + style = SpanStyle(color = colorResource(id = R.color.text_tertiary)) ) { append( " (${stringResource(R.string.chats_message_edited)})" @@ -1221,6 +1322,18 @@ fun Bubble( // Do nothing. } ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.chats_reply), + color = colorResource(id = R.color.text_primary) + ) + }, + onClick = { + onReply() + showDropdownMenu = false + } + ) DropdownMenuItem( text = { Text( diff --git a/feature-discussions/src/main/res/drawable/ic_chat_close_chat_box_reply.xml b/feature-discussions/src/main/res/drawable/ic_chat_close_chat_box_reply.xml new file mode 100644 index 0000000000..ae824312fc --- /dev/null +++ b/feature-discussions/src/main/res/drawable/ic_chat_close_chat_box_reply.xml @@ -0,0 +1,18 @@ + + + + diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 3327ca3d81..bc82489f79 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -1827,5 +1827,6 @@ Please provide specific details of your needs here. File Media Upload + Reply \ No newline at end of file diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/block/BlockMiddleware.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/block/BlockMiddleware.kt index e3b8275a1c..e977a12fb0 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/block/BlockMiddleware.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/block/BlockMiddleware.kt @@ -1038,6 +1038,10 @@ class BlockMiddleware( return middleware.chatGetMessages(command) } + override suspend fun getChatMessagesByIds(command: Command.ChatCommand.GetMessagesByIds): List { + return middleware.chatGetMessagesByIds(command) + } + override suspend fun subscribeLastChatMessages( command: Command.ChatCommand.SubscribeLastMessages ): Command.ChatCommand.SubscribeLastMessages.Response { 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 8494d0341b..f799bd421a 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 @@ -2744,6 +2744,18 @@ class Middleware @Inject constructor( return response.messages.map { it.core() } } + @Throws + fun chatGetMessagesByIds(command: Command.ChatCommand.GetMessagesByIds) : List { + val request = Rpc.Chat.GetMessagesByIds.Request( + chatObjectId = command.chat, + messageIds = command.messages + ) + logRequestIfDebug(request) + val (response, time) = measureTimedValue { service.chatGetMessagesByIds(request) } + logResponseIfDebug(response, time) + return response.messages.map { it.core() } + } + @Throws fun chatDeleteMessage(command: Command.ChatCommand.DeleteMessage) { val request = Rpc.Chat.DeleteMessage.Request( diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt index eda123801c..563577dd27 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt @@ -593,6 +593,7 @@ interface MiddlewareService { fun chatAddMessage(request: Rpc.Chat.AddMessage.Request): Rpc.Chat.AddMessage.Response fun chatEditMessage(request: Rpc.Chat.EditMessageContent.Request): Rpc.Chat.EditMessageContent.Response fun chatGetMessages(request: Rpc.Chat.GetMessages.Request): Rpc.Chat.GetMessages.Response + fun chatGetMessagesByIds(request: Rpc.Chat.GetMessagesByIds.Request): Rpc.Chat.GetMessagesByIds.Response fun chatDeleteMessage(request: Rpc.Chat.DeleteMessage.Request): Rpc.Chat.DeleteMessage.Response fun chatSubscribeLastMessages(request: Rpc.Chat.SubscribeLastMessages.Request): Rpc.Chat.SubscribeLastMessages.Response fun chatToggleMessageReaction(request: Rpc.Chat.ToggleMessageReaction.Request): Rpc.Chat.ToggleMessageReaction.Response diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt index 30415f5e02..71497b9d7b 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt @@ -2358,6 +2358,19 @@ class MiddlewareServiceImplementation @Inject constructor( } } + override fun chatGetMessagesByIds(request: Rpc.Chat.GetMessagesByIds.Request): Rpc.Chat.GetMessagesByIds.Response { + val encoded = Service.chatGetMessagesByIds( + Rpc.Chat.GetMessagesByIds.Request.ADAPTER.encode(request) + ) + val response = Rpc.Chat.GetMessagesByIds.Response.ADAPTER.decode(encoded) + val error = response.error + if (error != null && error.code != Rpc.Chat.GetMessagesByIds.Response.Error.Code.NULL) { + throw Exception(error.description) + } else { + return response + } + } + override fun chatSubscribeLastMessages(request: Rpc.Chat.SubscribeLastMessages.Request): Rpc.Chat.SubscribeLastMessages.Response { val encoded = Service.chatSubscribeLastMessages( Rpc.Chat.SubscribeLastMessages.Request.ADAPTER.encode(request)