From f02822a011c8de141e89e6b20f2a9172f01efddf Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sat, 29 Mar 2025 02:51:49 +0300 Subject: [PATCH] a shit ton features, improvements and fixes in messages history screen and others --- .../main/kotlin/dev/meloda/fast/data/State.kt | 4 +- .../data/api/messages/MessagesRepository.kt | 23 +- .../api/messages/MessagesRepositoryImpl.kt | 36 +- .../dev/meloda/fast/domain/MessagesUseCase.kt | 13 + .../meloda/fast/domain/MessagesUseCaseImpl.kt | 38 +- .../meloda/fast/model/api/domain/VkMessage.kt | 5 + .../model/api/requests/MessagesRequest.kt | 2 +- .../service/messages/MessagesService.kt | 22 +- .../fast/ui/components/MaterialDialog.kt | 13 +- .../dev/meloda/fast/ui/theme/AppTheme.kt | 2 +- .../dev/meloda/fast/ui/util/ImmutableList.kt | 2 + .../res/drawable/round_content_copy_24.xml | 11 + .../src/main/res/drawable/round_create_24.xml | 11 + ...ine_create_24.xml => round_forward_24.xml} | 5 +- .../res/drawable/round_mark_email_read_24.xml | 11 + .../round_report_gmailerrorred_24.xml | 19 + .../main/res/drawable/round_report_off_24.xml | 11 + .../src/main/res/drawable/round_star_24.xml | 11 + .../res/drawable/round_star_outline_24.xml | 11 + core/ui/src/main/res/values-ru/strings.xml | 20 + core/ui/src/main/res/values/strings.xml | 27 + .../presentation/ConversationsScreen.kt | 2 +- .../MessagesHistoryViewModel.kt | 814 +++++++++++------- .../messageshistory/model/MessageDialog.kt | 21 + .../messageshistory/model/MessageOption.kt | 84 ++ .../model/MessagesHistoryScreenState.kt | 2 - .../fast/messageshistory/model/UiItem.kt | 3 +- .../presentation/IncomingMessageBubble.kt | 4 +- .../presentation/MessageBubble.kt | 62 +- .../presentation/MessagesHistoryScreen.kt | 411 +++++++-- .../presentation/MessagesList.kt | 6 +- .../presentation/OutgoingMessageBubble.kt | 4 +- .../presentation/PinnedMessageContainer.kt | 88 ++ .../messageshistory/util/MessageMapper.kt | 9 +- .../presentation/item/TextFieldItem.kt | 10 +- 35 files changed, 1341 insertions(+), 476 deletions(-) create mode 100644 core/ui/src/main/res/drawable/round_content_copy_24.xml create mode 100644 core/ui/src/main/res/drawable/round_create_24.xml rename core/ui/src/main/res/drawable/{ic_baseline_create_24.xml => round_forward_24.xml} (51%) create mode 100644 core/ui/src/main/res/drawable/round_mark_email_read_24.xml create mode 100644 core/ui/src/main/res/drawable/round_report_gmailerrorred_24.xml create mode 100644 core/ui/src/main/res/drawable/round_report_off_24.xml create mode 100644 core/ui/src/main/res/drawable/round_star_24.xml create mode 100644 core/ui/src/main/res/drawable/round_star_outline_24.xml create mode 100644 feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageDialog.kt create mode 100644 feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageOption.kt create mode 100644 feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/PinnedMessageContainer.kt diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/State.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/State.kt index ca3d40a9..ab5daa6f 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/State.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/State.kt @@ -46,8 +46,8 @@ inline fun State.processState( ) { when (this) { is State.Error -> { - error(this) any() + error(this) } State.Idle -> idle() @@ -55,8 +55,8 @@ inline fun State.processState( State.Loading -> loading() is State.Success -> { - success(data) any() + success(data) } } } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt index c02ad58c..2eb008c3 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepository.kt @@ -56,15 +56,22 @@ interface MessagesRepository { peerId: Int ): ApiResult - suspend fun storeMessages(messages: List) + suspend fun markAsImportant( + peerId: Int, + messageIds: List?, + conversationMessageIds: List?, + important: Boolean + ): ApiResult, RestApiErrorDomain> -// suspend fun markAsImportant( -// params: MessagesMarkAsImportantRequest -// ): ApiResult, RestApiErrorDomain> -// -// suspend fun delete( -// params: MessagesDeleteRequest -// ): ApiResult + suspend fun delete( + peerId: Int, + messageIds: List?, + conversationMessageIds: List?, + spam: Boolean, + deleteForAll: Boolean + ): ApiResult, RestApiErrorDomain> + + suspend fun storeMessages(messages: List) // // suspend fun edit( // params: MessagesEditRequest diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt index d7ab97b4..901e3ae1 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/messages/MessagesRepositoryImpl.kt @@ -16,13 +16,15 @@ import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest +import dev.meloda.fast.model.api.requests.MessagesDeleteRequest import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest +import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest import dev.meloda.fast.model.api.requests.MessagesMarkAsReadRequest import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest import dev.meloda.fast.model.api.requests.MessagesSendRequest -import dev.meloda.fast.model.api.requests.MessagesUnPinMessageRequest +import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiResult @@ -240,10 +242,40 @@ class MessagesRepositoryImpl( override suspend fun unpin( peerId: Int ): ApiResult = withContext(Dispatchers.IO) { - val requestModel = MessagesUnPinMessageRequest(peerId = peerId) + val requestModel = MessagesUnpinMessageRequest(peerId = peerId) messagesService.unpin(requestModel.map).mapApiDefault() } + override suspend fun markAsImportant( + peerId: Int, + messageIds: List?, + conversationMessageIds: List?, + important: Boolean + ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { + val requestModel = MessagesMarkAsImportantRequest( + messagesIds = messageIds.orEmpty(), + important = important + ) + messagesService.markAsImportant(requestModel.map).mapApiDefault() + } + + override suspend fun delete( + peerId: Int, + messageIds: List?, + conversationMessageIds: List?, + spam: Boolean, + deleteForAll: Boolean + ): ApiResult, RestApiErrorDomain> = withContext(Dispatchers.IO) { + val requestModel = MessagesDeleteRequest( + peerId = peerId, + messagesIds = messageIds, + conversationsMessagesIds = conversationMessageIds, + isSpam = spam, + deleteForAll = deleteForAll + ) + messagesService.delete(requestModel.map).mapApiDefault() + } + override suspend fun storeMessages(messages: List) { messageDao.insertAll(messages.map(VkMessage::asEntity)) } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt index 65b602a8..aa120a6b 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCase.kt @@ -57,6 +57,19 @@ interface MessagesUseCase { peerId: Int ): Flow> + fun markAsImportant( + peerId: Int, + messageIds: List, + important: Boolean + ): Flow>> + + fun delete( + peerId: Int, + messageIds: List, + spam: Boolean = false, + deleteForAll: Boolean = false + ): Flow>> + suspend fun storeMessage(message: VkMessage) suspend fun storeMessages(messages: List) } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt index b6a8da52..b6e77697 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/MessagesUseCaseImpl.kt @@ -102,9 +102,7 @@ class MessagesUseCaseImpl( override fun createChat(userIds: List?, title: String?): Flow> = flow { emit(State.Loading) - val newState = repository.createChat(userIds, title).mapToState() - emit(newState) } @@ -126,8 +124,42 @@ class MessagesUseCaseImpl( override fun unpin(peerId: Int): Flow> = flow { emit(State.Loading) - val newState = repository.unpin(peerId = peerId).mapToState() + emit(newState) + } + + override fun markAsImportant( + peerId: Int, + messageIds: List, + important: Boolean + ): Flow>> = flow { + emit(State.Loading) + + val newState = repository.markAsImportant( + peerId = peerId, + messageIds = messageIds, + conversationMessageIds = null, + important = important + ).mapToState() + + emit(newState) + } + + override fun delete( + peerId: Int, + messageIds: List, + spam: Boolean, + deleteForAll: Boolean + ): Flow>> = flow { + emit(State.Loading) + + val newState = repository.delete( + peerId = peerId, + messageIds = messageIds, + conversationMessageIds = null, + spam = spam, + deleteForAll = deleteForAll + ).mapToState() emit(newState) } diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt index 29643ff1..a1d46800 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt @@ -1,7 +1,9 @@ package dev.meloda.fast.model.api.domain +import androidx.compose.runtime.Immutable import dev.meloda.fast.model.database.VkMessageEntity +@Immutable data class VkMessage( val id: Int, val conversationMessageId: Int, @@ -21,6 +23,7 @@ data class VkMessage( val pinnedAt: Int?, val isPinned: Boolean, val isImportant: Boolean = false, + val isSpam: Boolean = false, val forwards: List?, val attachments: List?, @@ -55,6 +58,8 @@ data class VkMessage( fun isUpdated(): Boolean = updateTime != null && updateTime > 0 + fun isFailed(): Boolean = id <= -500_000 + enum class Action(val value: String) { CHAT_CREATE("chat_create"), CHAT_PHOTO_UPDATE("chat_photo_update"), diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt index 6bc6f569..d1ad9855 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/MessagesRequest.kt @@ -119,7 +119,7 @@ data class MessagesPinMessageRequest( } -data class MessagesUnPinMessageRequest(val peerId: Int) { +data class MessagesUnpinMessageRequest(val peerId: Int) { val map: Map get() = mapOf("peer_id" to peerId.toString()) } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt index 4ed126af..45e5d517 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/service/messages/MessagesService.kt @@ -69,17 +69,17 @@ interface MessagesService { @FieldMap params: Map ): ApiResult, RestApiError> -// @FormUrlEncoded -// @POST(MessagesUrls.MarkAsImportant) -// suspend fun markAsImportant( -// @FieldMap params: Map -// ): ApiResult>, RestApiError> -// -// @FormUrlEncoded -// @POST(MessagesUrls.Delete) -// suspend fun delete( -// @FieldMap params: Map -// ): ApiResult, RestApiError> + @FormUrlEncoded + @POST(MessagesUrls.MARK_AS_IMPORTANT) + suspend fun markAsImportant( + @FieldMap params: Map + ): ApiResult>, RestApiError> + + @FormUrlEncoded + @POST(MessagesUrls.DELETE) + suspend fun delete( + @FieldMap params: Map + ): ApiResult>, RestApiError> // // @FormUrlEncoded // @POST(MessagesUrls.Edit) diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt index 5273e8e0..357cde96 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/components/MaterialDialog.kt @@ -110,13 +110,12 @@ fun MaterialDialog( .verticalScroll(scrollState) .onPlaced { isPlaced = true } ) { - Spacer(modifier = Modifier.height(8.dp)) - if (text != null && title == null) { Spacer(modifier = Modifier.height(20.dp)) } if (text != null) { + Spacer(modifier = Modifier.height(8.dp)) Row { Spacer(modifier = Modifier.width(24.dp)) Text( @@ -137,8 +136,6 @@ fun MaterialDialog( selectionType = selectionType, items = alertItems, onItemClick = { index -> - onItemClick?.invoke(index) - if (selectionType == SelectionType.None) { onDismissRequest.invoke() } else { @@ -149,6 +146,8 @@ fun MaterialDialog( alertItems = newItems } + + onItemClick?.invoke(index) }, onItemCheckedChanged = { index -> val newItems = alertItems.toMutableList() @@ -161,11 +160,7 @@ fun MaterialDialog( ) Spacer(modifier = Modifier.height(10.dp)) } else { - if (customContent != null) { - Spacer(modifier = Modifier.height(4.dp)) - customContent.invoke(this) - Spacer(modifier = Modifier.height(10.dp)) - } + customContent?.invoke(this) } } diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt index 31f89b32..88fe8c74 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt @@ -183,7 +183,7 @@ fun AppTheme( bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = robotoFonts), labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = robotoFonts), labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = robotoFonts), - labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = robotoFonts) + labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = robotoFonts), ) } diff --git a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt index 65128318..500882a1 100644 --- a/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt +++ b/core/ui/src/main/kotlin/dev/meloda/fast/ui/util/ImmutableList.kt @@ -65,3 +65,5 @@ class ImmutableList(val values: List) : Iterable { override fun iterator(): Iterator = values.listIterator() } + +fun emptyImmutableList(): ImmutableList = ImmutableList(emptyList()) diff --git a/core/ui/src/main/res/drawable/round_content_copy_24.xml b/core/ui/src/main/res/drawable/round_content_copy_24.xml new file mode 100644 index 00000000..1ab5dfd5 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_content_copy_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_create_24.xml b/core/ui/src/main/res/drawable/round_create_24.xml new file mode 100644 index 00000000..f143a80b --- /dev/null +++ b/core/ui/src/main/res/drawable/round_create_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_baseline_create_24.xml b/core/ui/src/main/res/drawable/round_forward_24.xml similarity index 51% rename from core/ui/src/main/res/drawable/ic_baseline_create_24.xml rename to core/ui/src/main/res/drawable/round_forward_24.xml index efc5ae47..5b1d5707 100644 --- a/core/ui/src/main/res/drawable/ic_baseline_create_24.xml +++ b/core/ui/src/main/res/drawable/round_forward_24.xml @@ -1,9 +1,12 @@ + + android:pathData="M12,8V6.41c0,-0.89 1.08,-1.34 1.71,-0.71l5.59,5.59c0.39,0.39 0.39,1.02 0,1.41l-5.59,5.59c-0.63,0.63 -1.71,0.19 -1.71,-0.7V16H5c-0.55,0 -1,-0.45 -1,-1V9c0,-0.55 0.45,-1 1,-1h7z" /> + diff --git a/core/ui/src/main/res/drawable/round_mark_email_read_24.xml b/core/ui/src/main/res/drawable/round_mark_email_read_24.xml new file mode 100644 index 00000000..07c70bbd --- /dev/null +++ b/core/ui/src/main/res/drawable/round_mark_email_read_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_report_gmailerrorred_24.xml b/core/ui/src/main/res/drawable/round_report_gmailerrorred_24.xml new file mode 100644 index 00000000..eed3ce7e --- /dev/null +++ b/core/ui/src/main/res/drawable/round_report_gmailerrorred_24.xml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/round_report_off_24.xml b/core/ui/src/main/res/drawable/round_report_off_24.xml new file mode 100644 index 00000000..885457a6 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_report_off_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_star_24.xml b/core/ui/src/main/res/drawable/round_star_24.xml new file mode 100644 index 00000000..64f6840d --- /dev/null +++ b/core/ui/src/main/res/drawable/round_star_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/round_star_outline_24.xml b/core/ui/src/main/res/drawable/round_star_outline_24.xml new file mode 100644 index 00000000..81204117 --- /dev/null +++ b/core/ui/src/main/res/drawable/round_star_outline_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/ui/src/main/res/values-ru/strings.xml b/core/ui/src/main/res/values-ru/strings.xml index 2c4b90a7..7236dc0e 100644 --- a/core/ui/src/main/res/values-ru/strings.xml +++ b/core/ui/src/main/res/values-ru/strings.xml @@ -5,7 +5,9 @@ При выходе из учётной записи с устройства будут удалены все связанные с ней данные. Продолжить? Да Нет + Повторить Ответить + Переслать сюда Переслать Пометить как важное Пометить как не важное @@ -15,6 +17,7 @@ Открепить Изменить Удалить + Прочитать Скопировать Удалить сообщение? Для всех @@ -31,6 +34,8 @@ Закрепить чат? Закрепить Открепить + Пометить + Убрать пометку Исходящий вызов Входящий вызов Закончился @@ -233,4 +238,19 @@ Файлы Ссылки Пометить как спам + Вы уверены, что хотите закрепить это сообщение? Это изменение увидят все участники чата. + Открепить сообщение + Вы уверены, что хотите открепить это сообщение? Все участники чата увидят это изменение. + Удалить сообщение? + Для всех + Пометить как важное + Вы уверены, что хотите пометить это сообщение как важное? + Вы уверены, что хотите убрать пометку избранного у этого сообщения? + Пометить как спам + Вы уверены, что хотите пометить это сообщение как спам? + Убрать пометку избранного + Убрать пометку спама + Вы уверены, что хотите убрать пометку спама у этого сообщения? + Закрепить сообщение + Скопировано в буфер обмена diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 38f44095..db5bdafc 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -126,7 +126,9 @@ No Time: %1$s + Retry Reply + Forward here Forward Mark as important Unmark as important @@ -136,6 +138,7 @@ Unpin Edit Delete + Read Copy Delete the message? @@ -156,6 +159,8 @@ Pin the conversation? Pin Unpin + Mark + Unmark Outgoing call Incoming call Ended @@ -301,4 +306,26 @@ Music Files Links + + Pin message + Are you sure you want to pin this message? All chat members will see this change. + + Unpin message + Are you sure you want to unpin this message? All chat members will see this change. + + Delete the message? + For everyone + + Mark as important + Are you sure you want to mark this message as important? + + Unmark as important + Are you sure you want to unmark this message as important? + + Mark as spam + Are you sure you want to mark this message as spam? + + Unmark as spam + Are you sure you want to unmark this message as spam? + Copied to clipboard diff --git a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt index c66f4e4f..2f05e6f6 100644 --- a/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt +++ b/feature/conversations/src/main/kotlin/dev/meloda/fast/conversations/presentation/ConversationsScreen.kt @@ -286,7 +286,7 @@ fun ConversationsScreen( } ) { Icon( - painter = painterResource(id = UiR.drawable.ic_baseline_create_24), + painter = painterResource(id = UiR.drawable.round_create_24), contentDescription = "Add chat button" ) } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt index 5fc8e4d1..3b95d6d7 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt @@ -1,6 +1,12 @@ package dev.meloda.fast.messageshistory +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.Bundle import android.util.Log +import android.widget.Toast import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.TextFieldValue @@ -8,7 +14,6 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.conena.nanokt.collections.indexOfFirstOrNull -import com.conena.nanokt.collections.indexOfOrNull import com.conena.nanokt.text.isEmptyOrBlank import com.conena.nanokt.text.isNotEmptyOrBlank import dev.meloda.fast.common.extensions.listenValue @@ -26,14 +31,14 @@ import dev.meloda.fast.domain.LoadConversationsByIdUseCase import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.messageshistory.model.ActionMode +import dev.meloda.fast.messageshistory.model.MessageDialog +import dev.meloda.fast.messageshistory.model.MessageOption import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState -import dev.meloda.fast.messageshistory.model.SendingStatus import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.navigation.MessagesHistory import dev.meloda.fast.messageshistory.util.asPresentation import dev.meloda.fast.messageshistory.util.extractAvatar import dev.meloda.fast.messageshistory.util.extractTitle -import dev.meloda.fast.messageshistory.util.findMessageById import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.api.domain.VkAttachment @@ -47,21 +52,29 @@ import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.random.Random +import dev.meloda.fast.ui.R as UiR + interface MessagesHistoryViewModel { val screenState: StateFlow - val selectedMessages: StateFlow> + val messages: StateFlow> + val uiMessages: StateFlow> + val messageDialog: StateFlow + val selectedMessages: StateFlow> val isNeedToScrollToIndex: StateFlow val baseError: StateFlow val imagesToPreload: StateFlow> - val showMessageOptions: StateFlow - val currentOffset: StateFlow val canPaginate: StateFlow + fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) + fun onDialogCancelled(dialog: MessageDialog) + fun onDialogDismissed(dialog: MessageDialog) + fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) + fun onScrolledToIndex() fun onCloseButtonClicked() @@ -75,12 +88,15 @@ interface MessagesHistoryViewModel { fun onMessageClicked(messageId: Int) fun onMessageLongClicked(messageId: Int) - fun onMessageOptionsDialogDismissed() + fun onPinnedMessageClicked(messageId: Int) fun onUnpinMessageClicked() + + fun onDeleteSelectedMessagesClicked() } class MessagesHistoryViewModelImpl( + private val applicationContext: Context, private val messagesUseCase: MessagesUseCase, private val conversationsUseCase: ConversationsUseCase, private val resourceProvider: ResourceProvider, @@ -91,24 +107,25 @@ class MessagesHistoryViewModelImpl( ) : MessagesHistoryViewModel, ViewModel() { override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY) - override val selectedMessages = MutableStateFlow>(emptyList()) + override val messageDialog = MutableStateFlow(null) + override val selectedMessages = MutableStateFlow>(emptyList()) override val isNeedToScrollToIndex = MutableStateFlow(null) override val baseError = MutableStateFlow(null) override val imagesToPreload = MutableStateFlow>(emptyList()) - override val showMessageOptions = MutableStateFlow(null) - override val currentOffset = MutableStateFlow(0) override val canPaginate = MutableStateFlow(false) - private val messages = MutableStateFlow>(emptyList()) + override val messages = MutableStateFlow>(emptyList()) + override val uiMessages = MutableStateFlow>(emptyList()) private var lastMessageText: String? = null private val sendingMessages: MutableList = mutableListOf() + private val failedMessages: MutableList = mutableListOf() init { val arguments = MessagesHistory.from(savedStateHandle).arguments @@ -122,11 +139,160 @@ class MessagesHistoryViewModelImpl( updatesParser.onMessageEdited(::handleEditedMessage) updatesParser.onMessageIncomingRead(::handleReadIncomingEvent) updatesParser.onMessageOutgoingRead(::handleReadOutgoingEvent) + updatesParser.onMessageDeleted(::handleMessageDeleted) + updatesParser.onMessageRestored(::handleMessageRestored) + updatesParser.onMessageMarkedAsImportant(::handleMessageMarkedAsImportant) + updatesParser.onMessageMarkedAsSpam(::handleMessageMarkedAsSpam) + updatesParser.onMessageMarkedAsNotSpam(::handleMessageMarkedAsNotSpam) - userSettings.showTimeInActionMessages.listenValue( - viewModelScope, - ::toggleShowTimeInActionMessages - ) + userSettings.showTimeInActionMessages.listenValue(viewModelScope) { + syncUiMessages() + } + } + + override fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) { + messageDialog.setValue { null } + + when (dialog) { + is MessageDialog.MessageOptions -> Unit + + is MessageDialog.MessageDelete -> { + val deleteForEveryone = bundle.getBoolean("everyone") + deleteMessage( + messageIds = listOf(dialog.message.id), + deleteForAll = deleteForEveryone + ) + } + + is MessageDialog.MessagesDelete -> { + val deleteForEveryone = bundle.getBoolean("everyone") + + val failedMessages = dialog.messages.filter { it.id <= 0 } + val messageIdsToDelete = + dialog.messages + .filter { it.id > 0 } + .map(VkMessage::id) + + deleteMessage( + messageIds = messageIdsToDelete, + deleteForAll = deleteForEveryone, + onSuccess = { + val newMessages = messages.value.toMutableList() + newMessages.removeAll(failedMessages) + messages.setValue { newMessages } + selectedMessages.setValue { emptyList() } + syncUiMessages() + } + ) + } + + is MessageDialog.MessagePin -> { + pinMessage(dialog.messageId) + } + + is MessageDialog.MessageUnpin -> { + unpinMessage(dialog.messageId) + } + + is MessageDialog.MessageMarkImportance -> { + markAsImportant( + messageIds = listOf(dialog.message.id), + important = dialog.isImportant + ) + } + + is MessageDialog.MessageSpam -> { + if (dialog.isSpam) { + deleteMessage( + messageIds = listOf(dialog.message.id), + spam = true + ) + } else { + // TODO: 29-Mar-25, Danil Nikolaev: report as not spam + } + } + } + } + + override fun onDialogCancelled(dialog: MessageDialog) { + messageDialog.setValue { null } + } + + override fun onDialogDismissed(dialog: MessageDialog) { + messageDialog.setValue { null } + } + + override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) { + when (dialog) { + is MessageDialog.MessageOptions -> { + when (val option = bundle.getParcelable("option")) { + null -> Unit + + MessageOption.Retry -> { + // TODO: 28-Mar-25, Danil Nikolaev: retry sending + } + + MessageOption.Reply -> {} + MessageOption.ForwardHere -> {} + MessageOption.Forward -> {} + + MessageOption.Pin -> { + messageDialog.setValue { + MessageDialog.MessagePin(dialog.message.id) + } + } + + MessageOption.Unpin -> { + messageDialog.setValue { + MessageDialog.MessageUnpin(dialog.message.id) + } + } + + MessageOption.Read -> { + readMessage(dialog.message) + } + + MessageOption.Copy -> { + copyMessage(dialog.message) + } + + MessageOption.MarkAsImportant, + MessageOption.UnmarkAsImportant -> { + messageDialog.setValue { + MessageDialog.MessageMarkImportance( + message = dialog.message, + isImportant = option is MessageOption.MarkAsImportant + ) + } + } + + MessageOption.MarkAsSpam, + MessageOption.UnmarkAsSpam -> { + messageDialog.setValue { + MessageDialog.MessageSpam( + message = dialog.message, + isSpam = option is MessageOption.MarkAsSpam + ) + } + } + + MessageOption.Edit -> {} + + MessageOption.Delete -> { + messageDialog.setValue { + MessageDialog.MessageDelete(dialog.message) + } + } + } + } + + is MessageDialog.MessageDelete -> Unit + is MessageDialog.MessageUnpin -> Unit + is MessageDialog.MessageMarkImportance -> Unit + is MessageDialog.MessageSpam -> Unit + is MessageDialog.MessagePin -> Unit + is MessageDialog.MessagesDelete -> Unit + } } override fun onScrolledToIndex() { @@ -134,15 +300,8 @@ class MessagesHistoryViewModelImpl( } override fun onCloseButtonClicked() { - screenState.setValue { old -> - old.copy( - messages = old.messages.map { - if (it is UiItem.Message) it.copy(isSelected = false) - else it - } - ) - } selectedMessages.setValue { emptyList() } + syncUiMessages() } override fun onRefresh() { @@ -195,72 +354,50 @@ class MessagesHistoryViewModelImpl( } override fun onPaginationConditionsMet() { - currentOffset.update { screenState.value.messages.size } + currentOffset.update { messages.value.size } loadMessagesHistory() } override fun onMessageClicked(messageId: Int) { - val messageIndex = screenState.value.messages.indexOfFirstOrNull { - it is UiItem.Message && it.id == messageId - } ?: return - - val newMessages = screenState.value.messages.toMutableList() - val currentMessage: UiItem.Message = newMessages[messageIndex] as UiItem.Message + val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return if (selectedMessages.value.isNotEmpty()) { - val isSelected = selectedMessages.value.contains(currentMessage.id) + val isSelected = selectedMessages.value.contains(currentMessage) - newMessages[messageIndex] = currentMessage.copy( - isSelected = !isSelected - ) - screenState.setValue { old -> old.copy(messages = newMessages) } selectedMessages.setValue { old -> old.toMutableList().also { if (isSelected) { - it.remove(currentMessage.id) + it.remove(currentMessage) } else { - it.add(currentMessage.id) + it.add(currentMessage) } } } + syncUiMessages() } else { - messages.value.firstOrNull { it.id == currentMessage.id }?.let { message -> - showMessageOptions.setValue { message } + messageDialog.setValue { + MessageDialog.MessageOptions(currentMessage) } } } override fun onMessageLongClicked(messageId: Int) { - val messageIndex = screenState.value.messages.indexOfFirstOrNull { - it is UiItem.Message && it.id == messageId - } ?: return + val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return - val newMessages = screenState.value.messages.toMutableList() - val currentMessage: UiItem.Message = newMessages[messageIndex] as UiItem.Message + val isSelected = selectedMessages.value.contains(currentMessage) + if (isSelected) return - val isSelected = selectedMessages.value.contains(currentMessage.id) - - newMessages[messageIndex] = currentMessage.copy( - isSelected = !isSelected - ) - screenState.setValue { old -> old.copy(messages = newMessages) } selectedMessages.setValue { old -> old.toMutableList().also { - if (isSelected) { - it.remove(currentMessage.id) - } else { - it.add(currentMessage.id) - } + it.add(currentMessage) } } - } - - override fun onMessageOptionsDialogDismissed() { - showMessageOptions.setValue { null } + syncUiMessages() } override fun onPinnedMessageClicked(messageId: Int) { - val messageIndex = screenState.value.messages.indexOfFirstOrNull { + val uiMessages = uiMessages.value + val messageIndex = uiMessages.indexOfFirstOrNull { it is UiItem.Message && it.id == messageId } @@ -272,43 +409,16 @@ class MessagesHistoryViewModelImpl( } override fun onUnpinMessageClicked() { - // TODO: 27.03.2025, Danil Nikolaev: confirmation alert val pinnedMessageId = screenState.value.pinnedMessage?.id ?: return - unpinMessage(pinnedMessageId) + messageDialog.setValue { + MessageDialog.MessageUnpin(pinnedMessageId) + } } - private fun unpinMessage(messageId: Int) { - val messageIndex = screenState.value.messages.indexOfFirstOrNull { - it is UiItem.Message && it.id == messageId + override fun onDeleteSelectedMessagesClicked() { + messageDialog.setValue { + MessageDialog.MessagesDelete(selectedMessages.value) } - - messagesUseCase.unpin(screenState.value.conversationId) - .listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { - var newState = screenState.value.copy( - pinnedMessage = null, - conversation = screenState.value.conversation.copy( - pinnedMessage = null, - pinnedMessageId = null - ), - pinnedSummary = null, - pinnedTitle = null - ) - - if (messageIndex != null) { - val newMessages = screenState.value.messages.toMutableList() - val currentMessage: UiItem.Message = - newMessages[messageIndex] as UiItem.Message - newMessages[messageIndex] = currentMessage.copy(isPinned = false) - newState = newState.copy(messages = newMessages) - } - - screenState.setValue { old -> newState } - } - ) - } } private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { @@ -317,96 +427,52 @@ class MessagesHistoryViewModelImpl( Log.d("MessagesHistoryViewModel", "handleNewMessage: $message") if (message.peerId != screenState.value.conversationId) return - if (screenState.value.messages.findMessageById(message.id) != null) return + if (messages.value.indexOfFirstOrNull { it.id == message.id } != null) return val randomIds = messages.value.map(VkMessage::randomId) if (message.randomId != 0 && message.randomId in randomIds) return - val newMessages = screenState.value.messages.toMutableList() - val prevMessage = messages.value.firstOrNull() + val newMessages = messages.value.toMutableList() + newMessages.add(0, message) - messages.setValue { old -> - old.toMutableList().also { it.add(0, message) } - } + messages.setValue { newMessages } - val newMessage = message.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = prevMessage, - nextMessage = null, - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation, - ) - newMessages.add(0, newMessage) - - prevMessage?.let { prev -> - newMessages[1] = prev.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = prevMessage, - nextMessage = messages.value.first(), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation - ) - } - - screenState.setValue { old -> old.copy(messages = newMessages) } + syncUiMessages() } private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) { val message = event.message if (message.peerId != screenState.value.conversationId) return - screenState.value.messages - .indexOfFirstOrNull { it.id == message.id } - ?.let { index -> - val newMessage = message.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.value.getOrNull(index + 1), - nextMessage = messages.value.getOrNull(index - 1), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation - ) - - val newMessages = screenState.value.messages.toMutableList() - newMessages[index] = newMessage - - screenState.setValue { old -> old.copy(messages = newMessages) } - } + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.id == message.id } + if (index == null) { // сообщения нет в списке + // pizdets + } else { + newMessages[index] = message + messages.setValue { newMessages } + syncUiMessages() + } } private fun handleReadIncomingEvent(event: LongPollParsedEvent.IncomingMessageRead) { if (event.peerId != screenState.value.conversationId) return val messages = messages.value - val messageIndex = - messages.indexOfFirstOrNull { it.id == event.messageId } + val index = messages.indexOfFirstOrNull { it.id == event.messageId } - if (messageIndex == null) { // диалога нет в списке + if (index == null) { // диалога нет в списке // pizdets } else { val newConversation = screenState.value.conversation.copy( inRead = event.messageId ) - val uiMessages = messages.mapIndexed { index, item -> - item.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.getOrNull(index + 1), - nextMessage = messages.getOrNull(index - 1), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = newConversation - ) + screenState.setValue { old -> + old.copy(conversation = newConversation) } - screenState.setValue { old -> - old.copy( - conversation = newConversation, - messages = uiMessages, - ) - } + syncUiMessages() } } @@ -414,36 +480,97 @@ class MessagesHistoryViewModelImpl( if (event.peerId != screenState.value.conversationId) return val messages = messages.value - val messageIndex = - messages.indexOfFirstOrNull { it.id == event.messageId } + val index = messages.indexOfFirstOrNull { it.id == event.messageId } - if (messageIndex == null) { // диалога нет в списке + if (index == null) { // сообщения нет в списке // pizdets } else { val newConversation = screenState.value.conversation.copy( outRead = event.messageId ) - val uiMessages = messages.mapIndexed { index, item -> - item.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.getOrNull(index + 1), - nextMessage = messages.getOrNull(index - 1), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = newConversation - ) + screenState.setValue { old -> + old.copy(conversation = newConversation) } - screenState.setValue { old -> - old.copy( - conversation = newConversation, - messages = uiMessages, - ) - } + syncUiMessages() } } + private fun handleMessageDeleted(event: LongPollParsedEvent.MessageDeleted) { + if (event.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.id == event.messageId } + + if (index == null) { // сообщения нет в списке + // pizdets + } else { + newMessages.removeAt(index) + messages.setValue { newMessages } + syncUiMessages() + } + } + + private fun handleMessageRestored(event: LongPollParsedEvent.MessageRestored) { + if (event.message.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val maxDate = newMessages.maxOf(VkMessage::date) + val minDate = newMessages.minOf(VkMessage::date) + + if (event.message.date !in minDate..maxDate) return + + newMessages.add(event.message) + messages.setValue { newMessages.sorted() } + syncUiMessages() + } + + private fun handleMessageMarkedAsImportant(event: LongPollParsedEvent.MessageMarkedAsImportant) { + if (event.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.id == event.messageId } + + if (index == null) { // сообщения нет в списке + // pizdets + } else { + val newMessage = newMessages[index].copy(isImportant = event.marked) + newMessages[index] = newMessage + messages.setValue { newMessages } + syncUiMessages() + } + } + + private fun handleMessageMarkedAsSpam(event: LongPollParsedEvent.MessageMarkedAsSpam) { + if (event.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.id == event.messageId } + + if (index == null) { // сообщения нет в списке + // pizdets + } else { + newMessages.removeAt(index) + messages.setValue { newMessages } + syncUiMessages() + } + } + + private fun handleMessageMarkedAsNotSpam(event: LongPollParsedEvent.MessageMarkedAsNotSpam) { + if (event.message.peerId != screenState.value.conversationId) return + + val newMessages = messages.value.toMutableList() + val maxDate = newMessages.maxOf(VkMessage::date) + val minDate = newMessages.minOf(VkMessage::date) + + if (event.message.date !in minDate..maxDate) return + + newMessages.add(event.message) + messages.setValue { newMessages.sorted() } + syncUiMessages() + } + private fun loadConversation() { Log.d("MessagesHistoryViewModelImpl", "loadConversation()") @@ -458,32 +585,58 @@ class MessagesHistoryViewModelImpl( resources = resourceProvider.resources ) val avatar = conversation.extractAvatar() - val pinnedMessage = conversation.pinnedMessage - val pinnedUser = if (pinnedMessage == null) null else - VkMemoryCache.getUser(pinnedMessage.fromId) - val pinnedGroup = if (pinnedMessage == null) null else - VkMemoryCache.getGroup(abs(pinnedMessage.fromId)) - val pinnedTitle = pinnedUser?.fullName ?: pinnedGroup?.name - - val pinnedSummary = buildAnnotatedString { - pinnedMessage?.text?.let(::append) ?: append("...") - } screenState.setValue { old -> old.copy( conversation = conversation, title = title, - avatar = avatar, - pinnedMessage = pinnedMessage, - pinnedTitle = pinnedTitle.orDots(), - pinnedSummary = pinnedSummary + avatar = avatar ) } + + conversation.pinnedMessage?.let(::handlePinnedMessage) } ) } } + private fun handlePinnedMessage(pinnedMessage: VkMessage?) { + if (pinnedMessage == null) { + screenState.setValue { old -> + old.copy( + pinnedMessage = null, + conversation = old.conversation.copy( + pinnedMessage = null, + pinnedMessageId = null + ), + pinnedSummary = null, + pinnedTitle = null + ) + } + return + } + + val pinnedUser = VkMemoryCache.getUser(pinnedMessage.fromId) + val pinnedGroup = VkMemoryCache.getGroup(abs(pinnedMessage.fromId)) + val pinnedTitle = pinnedUser?.fullName ?: pinnedGroup?.name + + val pinnedSummary = buildAnnotatedString { + pinnedMessage.text?.let(::append) ?: append("...") + } + + screenState.setValue { old -> + old.copy( + pinnedMessage = pinnedMessage, + conversation = old.conversation.copy( + pinnedMessage = pinnedMessage, + pinnedMessageId = pinnedMessage.id + ), + pinnedSummary = pinnedSummary, + pinnedTitle = pinnedTitle.orDots() + ) + } + } + private fun loadMessagesHistory(offset: Int = currentOffset.value) { Log.d("MessagesHistoryViewModel", "loadMessagesHistory: $offset") @@ -511,28 +664,16 @@ class MessagesHistoryViewModelImpl( messagesUseCase.storeMessages(messages) conversationsUseCase.storeConversations(conversations) - val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT val paginationExhausted = !itemsCountSufficient && - screenState.value.messages.isNotEmpty() - val newState = screenState.value.copy( - isPaginationExhausted = paginationExhausted, - ) - - val loadedMessages = fullMessages.mapIndexed { index, message -> - message.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.getOrNull(index + 1), - nextMessage = messages.getOrNull(index - 1), - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation - ) + this.messages.value.isNotEmpty() + screenState.setValue { old -> + old.copy(isPaginationExhausted = paginationExhausted) } this.messages.emit(fullMessages) - screenState.setValue { newState.copy(messages = loadedMessages) } + syncUiMessages() canPaginate.setValue { itemsCountSufficient } } ) @@ -627,23 +768,13 @@ class MessagesHistoryViewModelImpl( pinnedAt = null ) sendingMessages += newMessage - - val newMessages = screenState.value.messages.toMutableList() - val newUiMessage = newMessage.asPresentation( - resourceProvider = resourceProvider, - showName = false, - prevMessage = messages.value.firstOrNull(), - nextMessage = null, - showTimeInActionMessages = userSettings.showTimeInActionMessages.value, - conversation = screenState.value.conversation - ) - newMessages.add(0, newUiMessage) + messages.setValue { old -> listOf(newMessage).plus(old) } + syncUiMessages() screenState.setValue { old -> old.copy( message = TextFieldValue(), - actionMode = ActionMode.Record, - messages = listOf(newUiMessage).plus(old.messages) + actionMode = ActionMode.Record ) } @@ -655,119 +786,122 @@ class MessagesHistoryViewModelImpl( attachments = null ).listenValue(viewModelScope) { state -> state.processState( + any = { sendingMessages.remove(newMessage) }, error = { error -> - sendingMessages -= newMessage + val failedId = -500_000 - failedMessages.size + val newFailedMessage = newMessage.copy(id = failedId) + failedMessages += newFailedMessage - val uiMessages = screenState.value.messages.toMutableList() - - uiMessages.indexOfOrNull(newUiMessage)?.let { index -> - (uiMessages[index] as? UiItem.Message)?.let { message -> - uiMessages[index] = message.copy(sendingStatus = SendingStatus.FAILED) - } - } - - screenState.setValue { old -> old.copy(messages = uiMessages) } + val newMessages = messages.value.toMutableList() + newMessages[newMessages.indexOf(newMessage)] = newFailedMessage + messages.setValue { newMessages } + syncUiMessages() }, success = { messageId -> - sendingMessages -= newMessage + val newMessages = messages.value.toMutableList() + newMessages[newMessages.indexOf(newMessage)] = newMessage.copy(id = messageId) + messages.setValue { newMessages } - val uiMessages = screenState.value.messages.toMutableList() - messages.setValue { old -> - listOf(newMessage.copy(id = messageId)).plus(old) - } - - uiMessages.indexOfOrNull(newUiMessage)?.let { index -> - (uiMessages[index] as? UiItem.Message)?.let { message -> - uiMessages[index] = message - .copy( - id = messageId, - sendingStatus = SendingStatus.SENT - ) - .copy(isRead = newMessage.isRead(screenState.value.conversation)) - } - } - - screenState.setValue { old -> old.copy(messages = uiMessages) } + syncUiMessages() } ) } } - fun markAsImportant( - messagesIds: List, + private fun markAsImportant( + messageIds: List, important: Boolean, ) { - viewModelScope.launch(Dispatchers.IO) { -// sendRequest( -// request = { -// messagesRepository.markAsImportant( -// MessagesMarkAsImportantRequest( -// messagesIds = messagesIds, -// important = important -// ) -// ) -// }, -// onResponse = { response -> -// val markedIds = response.response ?: emptyList() -// // TODO: 25.08.2023, Danil Nikolaev: update messages -// } -// ) + messagesUseCase.markAsImportant( + peerId = screenState.value.conversationId, + messageIds = messageIds, + important = important + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + val newMessages = messages.value + .toMutableList() + .map { message -> + if (message.id in messageIds) { + message.copy(isImportant = important) + } else { + message + } + } + messages.setValue { newMessages } + syncUiMessages() + } + ) } } - fun pinMessage( - peerId: Int, - messageId: Int? = null, - conversationMessageId: Int? = null, - pin: Boolean, + private fun deleteMessage( + messageIds: List, + spam: Boolean = false, + deleteForAll: Boolean = false, + onSuccess: () -> Unit = {} ) { - viewModelScope.launch(Dispatchers.IO) { -// if (pin) { -// val pinnedMessage = sendRequest { -// messagesRepository.pin( -// MessagesPinMessageRequest( -// peerId = peerId, -// messageId = messageId, -// conversationMessageId = conversationMessageId -// ) -// ) -// } ?: return@launch -// -// // TODO: 25.08.2023, Danil Nikolaev: update message -// } else { -// val unpinnedMessage = sendRequest { -// messagesRepository.unpin(MessagesUnPinMessageRequest(peerId = peerId)) -// } ?: return@launch -// -// // TODO: 25.08.2023, Danil Nikolaev: update message -// } + messagesUseCase.delete( + peerId = screenState.value.conversationId, + messageIds = messageIds, + spam = spam, + deleteForAll = deleteForAll + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + onSuccess() + val newMessages = messages.value.toMutableList() + val messagesToDelete = newMessages.filter { it.id in messageIds } + newMessages.removeAll(messagesToDelete) + messages.setValue { newMessages } + syncUiMessages() + } + ) } } - fun deleteMessage( - peerId: Int, - messagesIds: List? = null, - conversationsMessagesIds: List? = null, - isSpam: Boolean? = null, - deleteForAll: Boolean? = null, - ) { - viewModelScope.launch(Dispatchers.IO) { -// sendRequest { -// messagesRepository.delete( -// MessagesDeleteRequest( -// peerId = peerId, -// messagesIds = messagesIds, -// conversationsMessagesIds = conversationsMessagesIds, -// isSpam = isSpam, -// deleteForAll = deleteForAll -// ) -// ) -// } ?: return@launch - - // TODO: 25.08.2023, Danil Nikolaev: handle deleting + private fun pinMessage(messageId: Int) { + messagesUseCase.pin( + peerId = screenState.value.conversationId, + messageId = messageId, + conversationMessageId = null + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { pinnedMessage -> + handlePinnedMessage(pinnedMessage) + val newMessages = messages.value + .toMutableList() + .map { message -> + message.copy(isPinned = message.id == messageId) + } + messages.setValue { newMessages } + syncUiMessages() + } + ) } } + private fun unpinMessage(messageId: Int) { + messagesUseCase.unpin(screenState.value.conversationId) + .listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirst { it.id == messageId } + newMessages[index] = newMessages[index].copy(isPinned = false) + messages.setValue { newMessages } + syncUiMessages() + + handlePinnedMessage(null) + } + ) + } + } + fun editMessage( originalMessage: VkMessage, peerId: Int, @@ -791,32 +925,67 @@ class MessagesHistoryViewModelImpl( } } - fun readMessage(peerId: Int, messageId: Int) { - viewModelScope.launch(Dispatchers.IO) { -// sendRequest { -// messagesRepository.markAsRead(peerId, startMessageId = messageId) -// } ?: return@launch + private fun readMessage(message: VkMessage) { + messagesUseCase.markAsRead( + peerId = screenState.value.conversationId, + startMessageId = message.id + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + val oldConversation = screenState.value.conversation + val newConversation = oldConversation.copy( + inRead = + if (!message.isOut) message.id + else oldConversation.inRead, + outRead = + if (message.isOut) message.id + else oldConversation.outRead + ) - // TODO: 25.08.2023, Danil Nikolaev: update messages + screenState.setValue { old -> + old.copy(conversation = newConversation) + } + + syncUiMessages() + } + ) } } - private fun toggleShowTimeInActionMessages(show: Boolean) { + private fun copyMessage(message: VkMessage) { + val contentToCopy = message.text.orEmpty().trim() + if (contentToCopy.isEmpty()) return + + val clipboardManager = + applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + clipboardManager.setPrimaryClip(ClipData.newPlainText("Message", contentToCopy)) + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) { + Toast.makeText(applicationContext, UiR.string.copied_to_clipboard, Toast.LENGTH_SHORT) + .show() + } + } + + private fun syncUiMessages(): List { val messages = messages.value - val uiMessages = messages.mapIndexed { index, item -> - item.asPresentation( + val selectedMessages = selectedMessages.value + + val newUiMessages = messages.mapIndexed { index, message -> + message.asPresentation( resourceProvider = resourceProvider, showName = false, prevMessage = messages.getOrNull(index + 1), nextMessage = messages.getOrNull(index - 1), - showTimeInActionMessages = show, - conversation = screenState.value.conversation + showTimeInActionMessages = userSettings.showTimeInActionMessages.value, + conversation = screenState.value.conversation, + isSelected = selectedMessages.indexOfFirstOrNull { it.id == message.id } != null ) } + uiMessages.setValue { newUiMessages } - screenState.setValue { old -> - old.copy(messages = uiMessages) - } + return newUiMessages } companion object { @@ -826,6 +995,7 @@ class MessagesHistoryViewModelImpl( // TODO: 25.08.2023, Danil Nikolaev: this and down below - rewrite + // suspend fun uploadPhoto( // peerId: Int, // photo: File, diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageDialog.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageDialog.kt new file mode 100644 index 00000000..4071c451 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageDialog.kt @@ -0,0 +1,21 @@ +package dev.meloda.fast.messageshistory.model + +import dev.meloda.fast.model.api.domain.VkMessage + +sealed class MessageDialog { + data class MessageOptions(val message: VkMessage) : MessageDialog() + data class MessagePin(val messageId: Int) : MessageDialog() + data class MessageUnpin(val messageId: Int) : MessageDialog() + data class MessageDelete(val message: VkMessage) : MessageDialog() + data class MessagesDelete(val messages: List) : MessageDialog() + + data class MessageSpam( + val message: VkMessage, + val isSpam: Boolean + ) : MessageDialog() + + data class MessageMarkImportance( + val message: VkMessage, + val isImportant: Boolean + ) : MessageDialog() +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageOption.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageOption.kt new file mode 100644 index 00000000..b8374345 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessageOption.kt @@ -0,0 +1,84 @@ +package dev.meloda.fast.messageshistory.model + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import dev.meloda.fast.ui.R +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class MessageOption( + @StringRes val titleResId: Int, + @DrawableRes val iconResId: Int +) : Parcelable { + + data object Retry : MessageOption( + titleResId = R.string.message_context_action_retry, + iconResId = R.drawable.round_restart_alt_24 + ) + + data object Reply : MessageOption( + titleResId = R.string.message_context_action_reply, + iconResId = R.drawable.round_reply_24 + ) + + data object ForwardHere : MessageOption( + titleResId = R.string.message_context_action_forward_here, + iconResId = R.drawable.round_reply_all_24 + ) + + data object Forward : MessageOption( + titleResId = R.string.message_context_action_forward, + iconResId = R.drawable.round_forward_24 + ) + + data object Pin : MessageOption( + titleResId = R.string.message_context_action_pin, + iconResId = R.drawable.pin_outline_24 + ) + + data object Unpin : MessageOption( + titleResId = R.string.message_context_action_unpin, + iconResId = R.drawable.pin_off_outline_24 + ) + + data object Read : MessageOption( + titleResId = R.string.message_context_action_read, + iconResId = R.drawable.round_mark_email_read_24 + ) + + data object Copy : MessageOption( + titleResId = R.string.message_context_action_copy, + iconResId = R.drawable.round_content_copy_24 + ) + + data object MarkAsImportant : MessageOption( + titleResId = R.string.message_context_action_mark_as_important, + iconResId = R.drawable.round_star_24 + ) + + data object UnmarkAsImportant : MessageOption( + titleResId = R.string.message_context_action_unmark_as_important, + iconResId = R.drawable.round_star_outline_24 + ) + + data object MarkAsSpam : MessageOption( + titleResId = R.string.message_context_action_mark_as_spam, + iconResId = R.drawable.round_report_gmailerrorred_24 + ) + + data object UnmarkAsSpam : MessageOption( + titleResId = R.string.message_context_action_unmark_as_spam, + iconResId = R.drawable.round_report_off_24 + ) + + data object Edit : MessageOption( + titleResId = R.string.message_context_action_edit, + iconResId = R.drawable.round_create_24 + ) + + data object Delete : MessageOption( + titleResId = R.string.message_context_action_delete, + iconResId = R.drawable.round_delete_outline_24 + ) +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt index c316a11b..14162f0b 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt @@ -14,7 +14,6 @@ data class MessagesHistoryScreenState( val title: String, val status: String?, val avatar: UiImage, - val messages: List, val message: TextFieldValue, val attachments: List, val isLoading: Boolean, @@ -34,7 +33,6 @@ data class MessagesHistoryScreenState( title = "", status = null, avatar = UiImage.Color(0), - messages = emptyList(), message = TextFieldValue(), attachments = emptyList(), isLoading = true, diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt index ae764c40..ac01f266 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/UiItem.kt @@ -26,7 +26,8 @@ sealed class UiItem( val isRead: Boolean, val sendingStatus: SendingStatus, val isSelected: Boolean, - val isPinned: Boolean + val isPinned: Boolean, + val isImportant: Boolean ) : UiItem(id, conversationMessageId) data class ActionMessage( diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt index 68f16ca4..6f67315c 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/IncomingMessageBubble.kt @@ -90,7 +90,9 @@ fun IncomingMessageBubble( edited = message.isEdited, isRead = message.isRead, sendingStatus = message.sendingStatus, - pinned = message.isPinned + pinned = message.isPinned, + important = message.isImportant, + isSelected = message.isSelected ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt index 7ca250e6..046b36d6 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessageBubble.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Create import androidx.compose.material3.Icon @@ -20,6 +21,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -42,7 +44,9 @@ fun MessageBubble( edited: Boolean, isRead: Boolean, sendingStatus: SendingStatus, - pinned: Boolean + pinned: Boolean, + important: Boolean, + isSelected: Boolean ) { val theme = LocalThemeConfig.current val backgroundColor = if (!isOut) { @@ -68,12 +72,15 @@ fun MessageBubble( ) .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), ) { - val minDateContainerWidth = remember(edited, isOut) { - val mainPart = if (edited) 50.dp else 30.dp - val readIndicatorPart = if (isOut) 14.dp else 0.dp - val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp + val minDateContainerWidth by remember(edited, isOut, pinned, important) { + derivedStateOf { + val mainPart = if (edited) 50.dp else 30.dp + val readIndicatorPart = if (isOut) 14.dp else 0.dp + val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp + val importantIndicatorPart = if (important) 14.dp else 0.dp - mainPart + readIndicatorPart + pinnedIndicatorPart + mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart + } } val dateContainerWidth by animateDpAsState( @@ -82,17 +89,29 @@ fun MessageBubble( ) if (text != null) { - Text( - text = text, - modifier = Modifier - .padding(2.dp) - .align(Alignment.Center) - .padding(end = 4.dp) - .padding(end = dateContainerWidth) - .padding(end = 4.dp) - .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), - color = textColor - ) + val textLambda: @Composable () -> Unit = remember { + { + Text( + text = text, + modifier = Modifier + .padding(2.dp) + .align(Alignment.Center) + .padding(end = 4.dp) + .padding(end = dateContainerWidth) + .padding(end = 4.dp) + .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), + color = textColor + ) + } + } + + if (isSelected) { + SelectionContainer { + textLambda.invoke() + } + } else { + textLambda.invoke() + } } Row( @@ -101,6 +120,14 @@ fun MessageBubble( .defaultMinSize(minWidth = dateContainerWidth) .then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier), ) { + if (important) { + Icon( + painter = painterResource(UiR.drawable.round_star_24), + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } if (pinned) { Icon( painter = painterResource(UiR.drawable.ic_round_push_pin_24), @@ -119,6 +146,7 @@ fun MessageBubble( ) Spacer(modifier = Modifier.width(4.dp)) } + Text( text = date.orEmpty(), style = MaterialTheme.typography.labelSmall, diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt index dfdbf582..9d5c9fff 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt @@ -1,5 +1,6 @@ package dev.meloda.fast.messageshistory.presentation +import android.os.Bundle import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -39,6 +40,7 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -53,6 +55,7 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -64,7 +67,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color @@ -74,12 +76,12 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf import androidx.core.view.HapticFeedbackConstantsCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage @@ -93,22 +95,28 @@ import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.messageshistory.MessagesHistoryViewModel import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl import dev.meloda.fast.messageshistory.model.ActionMode +import dev.meloda.fast.messageshistory.model.MessageDialog +import dev.meloda.fast.messageshistory.model.MessageOption import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState +import dev.meloda.fast.messageshistory.model.UiItem import dev.meloda.fast.messageshistory.util.firstMessage import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId import dev.meloda.fast.model.BaseError +import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.components.MaterialDialog -import dev.meloda.fast.ui.components.SelectionType import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.util.ImmutableList +import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList +import dev.meloda.fast.ui.util.emptyImmutableList import dev.meloda.fast.ui.util.getImage import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject +import java.util.concurrent.TimeUnit import dev.meloda.fast.ui.R as UiR @Composable @@ -119,10 +127,12 @@ fun MessagesHistoryRoute( viewModel: MessagesHistoryViewModel = koinViewModel() ) { val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val messages by viewModel.messages.collectAsStateWithLifecycle() + val uiMessages by viewModel.uiMessages.collectAsStateWithLifecycle() + val messageDialog by viewModel.messageDialog.collectAsStateWithLifecycle() val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() - val showMessageOptions by viewModel.showMessageOptions.collectAsStateWithLifecycle() val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle() val userSettings: UserSettings = koinInject() @@ -130,8 +140,10 @@ fun MessagesHistoryRoute( MessagesHistoryScreen( screenState = screenState, + messages = messages.toImmutableList(), + uiMessages = uiMessages.toImmutableList(), scrollIndex = scrollIndex, - selectedMessages = ImmutableList.copyOf(selectedMessages), + selectedMessages = selectedMessages.toImmutableList(), baseError = baseError, canPaginate = canPaginate, showEmojiButton = showEmojiButton, @@ -149,43 +161,300 @@ fun MessagesHistoryRoute( onMessageClicked = viewModel::onMessageClicked, onMessageLongClicked = viewModel::onMessageLongClicked, onPinnedMessageClicked = viewModel::onPinnedMessageClicked, - onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked + onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked, + onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked ) - if (showMessageOptions != null) { - val message = showMessageOptions!! + HandleDialogs( + screenState = screenState, + messageDialog = messageDialog, + onConfirmed = viewModel::onDialogConfirmed, + onCancelled = viewModel::onDialogCancelled, + onDismissed = viewModel::onDialogDismissed, + onItemPicked = viewModel::onDialogItemPicked + ) +} - val messageOptions = mutableListOf( - stringResource(UiR.string.message_context_action_reply), - stringResource(UiR.string.message_context_action_forward) - ) +@Composable +fun HandleDialogs( + screenState: MessagesHistoryScreenState, + messageDialog: MessageDialog?, + onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> }, + onCancelled: (MessageDialog) -> Unit = {}, + onDismissed: (MessageDialog) -> Unit = {}, + onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> } +) { + when (messageDialog) { + null -> Unit - if (message.isPeerChat() && screenState.conversation.canChangePin) { - messageOptions += stringResource( - if (message.isPinned) UiR.string.message_context_action_unpin - else UiR.string.message_context_action_pin + is MessageDialog.MessageOptions -> { + MessageOptionsDialog( + screenState = screenState, + message = messageDialog.message, + onDismissed = { onDismissed(messageDialog) }, + onItemPicked = { bundle -> onItemPicked(messageDialog, bundle) } ) } - messageOptions += stringResource(UiR.string.message_context_action_copy) - messageOptions += stringResource( - if (message.isImportant) UiR.string.message_context_action_unmark_as_important - else UiR.string.message_context_action_mark_as_important - ) + is MessageDialog.MessageDelete -> { + MessageDeleteDialog( + messages = listOf(messageDialog.message), + onConfirmed = { onConfirmed(messageDialog, it) }, + onDismissed = { onDismissed(messageDialog) } + ) + } -// if (!message.isOut) { -// messageOptions += "Mark as spam" -// } + is MessageDialog.MessagesDelete -> { + MessageDeleteDialog( + messages = messageDialog.messages, + onConfirmed = { onConfirmed(messageDialog, it) }, + onDismissed = { onDismissed(messageDialog) } + ) + } - messageOptions += stringResource(UiR.string.message_context_action_delete) + is MessageDialog.MessagePin, + is MessageDialog.MessageUnpin -> { + MessagePinStateDialog( + pin = messageDialog is MessageDialog.MessagePin, + onConfirmed = { onConfirmed(messageDialog, bundleOf()) }, + onDismissed = { onDismissed(messageDialog) } + ) + } - MaterialDialog( - onDismissRequest = viewModel::onMessageOptionsDialogDismissed, - selectionType = SelectionType.None, - items = ImmutableList.copyOf(messageOptions), - confirmText = stringResource(UiR.string.ok) + is MessageDialog.MessageMarkImportance -> { + MessageImportanceDialog( + important = messageDialog.isImportant, + onConfirmed = { onConfirmed(messageDialog, bundleOf()) }, + onDismissed = { onDismissed(messageDialog) } + ) + } + + is MessageDialog.MessageSpam -> { + MessageSpamDialog( + spam = messageDialog.isSpam, + onConfirmed = { onConfirmed(messageDialog, bundleOf()) }, + onDismissed = { onDismissed(messageDialog) } + ) + } + } +} + + +@Composable +fun MessageOptionsDialog( + screenState: MessagesHistoryScreenState, + message: VkMessage, + onDismissed: () -> Unit = {}, + onItemPicked: (Bundle) -> Unit +) { + val options = mutableListOf() + if (message.isFailed()) { + options += MessageOption.Retry + } else { + options += MessageOption.Reply + options += MessageOption.ForwardHere + options += MessageOption.Forward + + if (message.isPeerChat() && screenState.conversation.canChangePin) { + options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin + } + + if (!message.isRead(screenState.conversation)) { + options += MessageOption.Read + } + + options += MessageOption.Copy + + if (message.isOut) { + val diff = System.currentTimeMillis() - message.date * 1000L + if (diff - TimeUnit.DAYS.toMillis(1) <= 0) { + options += MessageOption.Edit + } + } + + options += if (message.isImportant) MessageOption.UnmarkAsImportant + else MessageOption.MarkAsImportant + + + if (!message.isOut) { + options += if (message.isSpam) MessageOption.UnmarkAsSpam + else MessageOption.MarkAsSpam + } + } + + options += MessageOption.Delete + + val messageOptions = options.map { option -> + Triple( + stringResource(option.titleResId), + painterResource(option.iconResId), + when { + option in listOf( + MessageOption.Delete, + MessageOption.MarkAsSpam + ) -> MaterialTheme.colorScheme.error + + else -> MaterialTheme.colorScheme.primary + } ) } + + MaterialDialog(onDismissRequest = onDismissed) { + messageOptions + .forEachIndexed { index, (title, painter, tintColor) -> + DropdownMenuItem( + text = { + Row { + Text(text = title) + Spacer(modifier = Modifier.width(8.dp)) + } + }, + leadingIcon = { + Row { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painter, + contentDescription = null, + tint = tintColor + ) + } + }, + onClick = { + onDismissed() + val pickedOption = options[index] + onItemPicked(bundleOf("option" to pickedOption)) + } + ) + } + } +} + +@Composable +fun MessageDeleteDialog( + messages: List, + onConfirmed: (Bundle) -> Unit = {}, + onDismissed: () -> Unit = {}, +) { + var forEveryone by remember { + mutableStateOf(messages.all(VkMessage::isOut)) + } + + val shouldBeDisabled by remember(messages) { + mutableStateOf(messages.any(VkMessage::isFailed) || !messages.all(VkMessage::isOut)) + } + + MaterialDialog( + onDismissRequest = onDismissed, + title = stringResource(UiR.string.delete_message_title), + confirmText = stringResource(UiR.string.action_delete), + confirmAction = { + onConfirmed( + bundleOf("everyone" to if (messages.all(VkMessage::isOut)) forEveryone else false) + ) + }, + cancelText = stringResource(UiR.string.cancel), + ) { + Row( + modifier = Modifier + .then( + if (!shouldBeDisabled) { + Modifier.clickable { forEveryone = !forEveryone } + } else Modifier) + .fillMaxWidth() + .minimumInteractiveComponentSize() + .padding(start = 24.dp, end = 16.dp) + ) { + Checkbox( + checked = forEveryone, + onCheckedChange = null, + enabled = !shouldBeDisabled + ) + + Spacer(modifier = Modifier.width(8.dp)) + + LocalContentAlpha( + alpha = if (shouldBeDisabled) ContentAlpha.disabled + else ContentAlpha.high + ) { + Text(text = stringResource(UiR.string.delete_message_for_everyone)) + } + } + } +} + +@Composable +fun MessagePinStateDialog( + pin: Boolean, + onConfirmed: () -> Unit = {}, + onDismissed: () -> Unit = {}, +) { + MaterialDialog( + onDismissRequest = onDismissed, + title = stringResource( + if (pin) UiR.string.pin_message_title + else UiR.string.unpin_message_title + ), + text = stringResource( + if (pin) UiR.string.pin_message_text + else UiR.string.unpin_message_text + ), + confirmText = stringResource( + if (pin) UiR.string.action_pin + else UiR.string.action_unpin + ), + confirmAction = onConfirmed, + cancelText = stringResource(UiR.string.cancel) + ) +} + +@Composable +fun MessageImportanceDialog( + important: Boolean, + onConfirmed: () -> Unit = {}, + onDismissed: () -> Unit = {}, +) { + MaterialDialog( + onDismissRequest = onDismissed, + title = stringResource( + if (important) UiR.string.important_message_title + else UiR.string.unimportant_message_title + ), + text = stringResource( + if (important) UiR.string.important_message_text + else UiR.string.unimportant_message_text + ), + confirmText = stringResource( + if (important) UiR.string.action_mark + else UiR.string.action_unmark + ), + confirmAction = onConfirmed, + cancelText = stringResource(UiR.string.cancel) + ) +} + +@Composable +fun MessageSpamDialog( + spam: Boolean, + onConfirmed: () -> Unit = {}, + onDismissed: () -> Unit = {}, +) { + MaterialDialog( + onDismissRequest = onDismissed, + title = stringResource( + if (spam) UiR.string.spam_message_title + else UiR.string.unspam_message_title + ), + text = stringResource( + if (spam) UiR.string.spam_message_text + else UiR.string.unspam_message_text + ), + confirmText = stringResource( + if (spam) UiR.string.action_mark + else UiR.string.action_unmark + ), + confirmAction = onConfirmed, + cancelText = stringResource(UiR.string.cancel) + ) } @OptIn( @@ -196,8 +465,10 @@ fun MessagesHistoryRoute( @Composable fun MessagesHistoryScreen( screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY, + messages: ImmutableList = emptyImmutableList(), + uiMessages: ImmutableList = emptyImmutableList(), scrollIndex: Int? = null, - selectedMessages: ImmutableList = ImmutableList.empty(), + selectedMessages: ImmutableList = emptyImmutableList(), baseError: BaseError? = null, canPaginate: Boolean = false, showEmojiButton: Boolean = false, @@ -215,7 +486,8 @@ fun MessagesHistoryScreen( onMessageClicked: (Int) -> Unit = {}, onMessageLongClicked: (Int) -> Unit = {}, onPinnedMessageClicked: (Int) -> Unit = {}, - onUnpinMessageButtonClicked: () -> Unit = {} + onUnpinMessageButtonClicked: () -> Unit = {}, + onDeleteSelectedButtonClicked: () -> Unit = {} ) { val view = LocalView.current val coroutineScope = rememberCoroutineScope() @@ -288,8 +560,8 @@ fun MessagesHistoryScreen( val density = LocalDensity.current - val showReplyAction by remember(screenState) { - mutableStateOf(selectedMessages.size == 1) + val showReplyAction by remember(selectedMessages) { + derivedStateOf { selectedMessages.size == 1 } } Scaffold( @@ -414,6 +686,12 @@ fun MessagesHistoryScreen( } } ) { + Icon( + painter = painterResource(UiR.drawable.round_forward_24), + contentDescription = null + ) + } + IconButton(onClick = onDeleteSelectedButtonClicked) { Icon( painter = painterResource(UiR.drawable.round_delete_outline_24), contentDescription = null @@ -449,7 +727,7 @@ fun MessagesHistoryScreen( // TODO: 23-Mar-25, Danil Nikolaev: crash if no messages (ex. new chat) onChatMaterialsDropdownItemClicked( screenState.conversationId, - screenState.messages.firstMessage().conversationMessageId + uiMessages.values.firstMessage().conversationMessageId ) }, text = { @@ -483,7 +761,7 @@ fun MessagesHistoryScreen( ) val showHorizontalProgressBar by remember(screenState) { - derivedStateOf { screenState.isLoading && screenState.messages.isNotEmpty() } + derivedStateOf { screenState.isLoading && messages.isNotEmpty() } } if (showHorizontalProgressBar) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) @@ -493,51 +771,15 @@ fun MessagesHistoryScreen( } if (!screenState.isLoading && pinnedMessage != null) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .clickable { onPinnedMessageClicked(pinnedMessage!!.id) } - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - modifier = Modifier - .rotate(45f) - .alpha(0.5f), - painter = painterResource(UiR.drawable.ic_round_push_pin_24), - contentDescription = null - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = screenState.pinnedTitle.orDots(), - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary - ) - screenState.pinnedSummary?.let { summary -> - LocalContentAlpha(alpha = ContentAlpha.medium) { - Text(text = summary) - } - } - } - - if (screenState.conversation.canChangePin) { - Spacer(modifier = Modifier.width(16.dp)) - - IconButton(onClick = onUnpinMessageButtonClicked) { - Icon( - modifier = Modifier.alpha(0.5f), - imageVector = Icons.Rounded.Close, - contentDescription = null - ) - } - } - } + PinnedMessageContainer( + modifier = Modifier, + pinnedMessage = requireNotNull(pinnedMessage), + title = screenState.pinnedTitle.orDots(), + summary = screenState.pinnedSummary, + canChangePin = screenState.conversation.canChangePin, + onPinnedMessageClicked = onPinnedMessageClicked, + onUnpinMessageButtonClicked = onUnpinMessageButtonClicked + ) HorizontalDivider() } } @@ -551,16 +793,17 @@ fun MessagesHistoryScreen( .padding(bottom = padding.calculateBottomPadding()), ) { MessagesList( + modifier = Modifier.align(Alignment.BottomStart), hazeState = hazeState, listState = listState, hasPinnedMessage = pinnedMessage != null, - immutableMessages = ImmutableList.copyOf(screenState.messages), + uiMessages = uiMessages, isPaginating = screenState.isPaginating, messageBarHeight = messageBarHeight, onRequestScrollToCmId = { cmId -> coroutineScope.launch { listState.animateScrollToItem( - index = screenState.messages.indexOfMessageByCmId(cmId) + index = uiMessages.values.indexOfMessageByCmId(cmId) ) } }, @@ -775,7 +1018,7 @@ fun MessagesHistoryScreen( } when { - screenState.isLoading && screenState.messages.isEmpty() -> { + screenState.isLoading && messages.values.isEmpty() -> { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt index 16c88a72..549d7dee 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt @@ -42,15 +42,15 @@ fun MessagesList( hasPinnedMessage: Boolean, hazeState: HazeState, listState: LazyListState, - immutableMessages: ImmutableList, + uiMessages: ImmutableList, isPaginating: Boolean, messageBarHeight: Dp, onRequestScrollToCmId: (cmId: Int) -> Unit = {}, onMessageClicked: (Int) -> Unit = {}, onMessageLongClicked: (Int) -> Unit = {} ) { - val messages = remember(immutableMessages) { - immutableMessages.toList() + val messages = remember(uiMessages) { + uiMessages.toList() } val theme = LocalThemeConfig.current val view = LocalView.current diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt index 945b9c69..04343e70 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/OutgoingMessageBubble.kt @@ -44,7 +44,9 @@ fun OutgoingMessageBubble( edited = message.isEdited, isRead = message.isRead, sendingStatus = message.sendingStatus, - pinned = message.isPinned + pinned = message.isPinned, + important = message.isImportant, + isSelected = message.isSelected ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/PinnedMessageContainer.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/PinnedMessageContainer.kt new file mode 100644 index 00000000..27cd9289 --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/PinnedMessageContainer.kt @@ -0,0 +1,88 @@ +package dev.meloda.fast.messageshistory.presentation + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.ui.R +import dev.meloda.fast.ui.basic.ContentAlpha +import dev.meloda.fast.ui.basic.LocalContentAlpha +import dev.meloda.fast.ui.components.IconButton + +@Composable +fun PinnedMessageContainer( + modifier: Modifier = Modifier, + pinnedMessage: VkMessage, + title: String, + summary: AnnotatedString?, + canChangePin: Boolean, + onPinnedMessageClicked: (Int) -> Unit = {}, + onUnpinMessageButtonClicked: () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .clickable { onPinnedMessageClicked(pinnedMessage.id) } + .padding(start = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .rotate(45f) + .alpha(0.5f), + painter = painterResource(R.drawable.ic_round_push_pin_24), + contentDescription = null + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + summary?.let { summary -> + LocalContentAlpha(alpha = ContentAlpha.medium) { + Text(text = summary) + } + } + } + + if (canChangePin) { + Spacer(modifier = Modifier.width(16.dp)) + + IconButton(onClick = onUnpinMessageButtonClicked) { + Icon( + modifier = Modifier.alpha(0.5f), + imageVector = Icons.Rounded.Close, + contentDescription = null + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + } + } +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt index 966901d5..238c6f7f 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/util/MessageMapper.kt @@ -96,7 +96,8 @@ fun VkMessage.asPresentation( showName: Boolean, prevMessage: VkMessage?, nextMessage: VkMessage?, - showTimeInActionMessages: Boolean + showTimeInActionMessages: Boolean, + isSelected: Boolean ): UiItem = when { action != null -> UiItem.ActionMessage( id = id, @@ -126,11 +127,13 @@ fun VkMessage.asPresentation( isEdited = updateTime != null, isRead = isRead(conversation), sendingStatus = when { + isFailed() -> SendingStatus.FAILED id <= 0 -> SendingStatus.SENDING else -> SendingStatus.SENT }, - isSelected = false, - isPinned = isPinned + isSelected = isSelected, + isPinned = isPinned, + isImportant = isImportant ) } diff --git a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/item/TextFieldItem.kt b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/item/TextFieldItem.kt index 698382a7..ef5dabeb 100644 --- a/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/item/TextFieldItem.kt +++ b/feature/settings/src/main/kotlin/dev/meloda/fast/settings/presentation/item/TextFieldItem.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -139,8 +140,11 @@ fun EditTextAlert( cancelText = stringResource(id = R.string.cancel), actionInvokeDismiss = ActionInvokeDismiss.Always ) { - Row(modifier = Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.width(20.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { TextField( modifier = Modifier .fillMaxWidth() @@ -155,8 +159,8 @@ fun EditTextAlert( placeholder = { Text(text = "Value") }, shape = RoundedCornerShape(10.dp), ) - Spacer(modifier = Modifier.width(20.dp)) } + Spacer(modifier = Modifier.height(8.dp)) } LaunchedEffect(Unit) {