Update API version (#147)
* Bump VK Api version to 5.238 * Implemented new authorization flow (at the moment, without auto re-requesting token) * Add support for sticker pack preview attachments * Bump LongPoll to version 19 * Improved messages handling * Fixed coloring issues * Cache improvements * Archive screen with full functionality * Recomposition fixes * Markdown support for messages bubbles * Adjust app name font size based on screen width * Navigation related improvements * Add logout functionality
This commit is contained in:
+126
-84
@@ -16,6 +16,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.conena.nanokt.collections.indexOfFirstOrNull
|
||||
import com.conena.nanokt.text.isEmptyOrBlank
|
||||
import com.conena.nanokt.text.isNotEmptyOrBlank
|
||||
import dev.meloda.fast.common.VkConstants
|
||||
import dev.meloda.fast.common.extensions.listenValue
|
||||
import dev.meloda.fast.common.extensions.orDots
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
@@ -32,6 +33,7 @@ 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.MessageNavigation
|
||||
import dev.meloda.fast.messageshistory.model.MessageOption
|
||||
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
@@ -51,15 +53,15 @@ import kotlinx.coroutines.flow.update
|
||||
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<MessagesHistoryScreenState>
|
||||
val navigation: StateFlow<MessageNavigation?>
|
||||
val messages: StateFlow<List<VkMessage>>
|
||||
val uiMessages: StateFlow<List<UiItem>>
|
||||
val messageDialog: StateFlow<MessageDialog?>
|
||||
val dialog: StateFlow<MessageDialog?>
|
||||
val selectedMessages: StateFlow<List<VkMessage>>
|
||||
|
||||
val isNeedToScrollToIndex: StateFlow<Int?>
|
||||
@@ -70,6 +72,10 @@ interface MessagesHistoryViewModel {
|
||||
val currentOffset: StateFlow<Int>
|
||||
val canPaginate: StateFlow<Boolean>
|
||||
|
||||
fun onNavigationConsumed()
|
||||
|
||||
fun onTopBarClicked()
|
||||
|
||||
fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle)
|
||||
fun onDialogDismissed(dialog: MessageDialog)
|
||||
fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle)
|
||||
@@ -85,10 +91,10 @@ interface MessagesHistoryViewModel {
|
||||
|
||||
fun onPaginationConditionsMet()
|
||||
|
||||
fun onMessageClicked(messageId: Int)
|
||||
fun onMessageLongClicked(messageId: Int)
|
||||
fun onMessageClicked(messageId: Long)
|
||||
fun onMessageLongClicked(messageId: Long)
|
||||
|
||||
fun onPinnedMessageClicked(messageId: Int)
|
||||
fun onPinnedMessageClicked(messageId: Long)
|
||||
fun onUnpinMessageClicked()
|
||||
|
||||
fun onDeleteSelectedMessagesClicked()
|
||||
@@ -106,7 +112,8 @@ class MessagesHistoryViewModelImpl(
|
||||
) : MessagesHistoryViewModel, ViewModel() {
|
||||
|
||||
override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY)
|
||||
override val messageDialog = MutableStateFlow<MessageDialog?>(null)
|
||||
override val navigation = MutableStateFlow<MessageNavigation?>(null)
|
||||
override val dialog = MutableStateFlow<MessageDialog?>(null)
|
||||
override val selectedMessages = MutableStateFlow<List<VkMessage>>(emptyList())
|
||||
|
||||
override val isNeedToScrollToIndex = MutableStateFlow<Int?>(null)
|
||||
@@ -149,6 +156,21 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNavigationConsumed() {
|
||||
navigation.setValue { null }
|
||||
}
|
||||
|
||||
override fun onTopBarClicked() {
|
||||
val cmId = messages.value.firstOrNull()?.cmId ?: return
|
||||
|
||||
navigation.setValue {
|
||||
MessageNavigation.ChatMaterials(
|
||||
peerId = screenState.value.conversationId,
|
||||
cmId = cmId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) {
|
||||
onDialogDismissed(dialog)
|
||||
|
||||
@@ -223,7 +245,7 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
|
||||
override fun onDialogDismissed(dialog: MessageDialog) {
|
||||
messageDialog.setValue { null }
|
||||
this.dialog.setValue { null }
|
||||
}
|
||||
|
||||
override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) {
|
||||
@@ -241,13 +263,13 @@ class MessagesHistoryViewModelImpl(
|
||||
MessageOption.Forward -> {}
|
||||
|
||||
MessageOption.Pin -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessagePin(dialog.message.id)
|
||||
}
|
||||
}
|
||||
|
||||
MessageOption.Unpin -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessageUnpin(dialog.message.id)
|
||||
}
|
||||
}
|
||||
@@ -262,7 +284,7 @@ class MessagesHistoryViewModelImpl(
|
||||
|
||||
MessageOption.MarkAsImportant,
|
||||
MessageOption.UnmarkAsImportant -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessageMarkImportance(
|
||||
message = dialog.message,
|
||||
isImportant = option is MessageOption.MarkAsImportant
|
||||
@@ -272,7 +294,7 @@ class MessagesHistoryViewModelImpl(
|
||||
|
||||
MessageOption.MarkAsSpam,
|
||||
MessageOption.UnmarkAsSpam -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessageSpam(
|
||||
message = dialog.message,
|
||||
isSpam = option is MessageOption.MarkAsSpam
|
||||
@@ -283,7 +305,7 @@ class MessagesHistoryViewModelImpl(
|
||||
MessageOption.Edit -> {}
|
||||
|
||||
MessageOption.Delete -> {
|
||||
messageDialog.setValue {
|
||||
this.dialog.setValue {
|
||||
MessageDialog.MessageDelete(dialog.message)
|
||||
}
|
||||
}
|
||||
@@ -362,7 +384,7 @@ class MessagesHistoryViewModelImpl(
|
||||
loadMessagesHistory()
|
||||
}
|
||||
|
||||
override fun onMessageClicked(messageId: Int) {
|
||||
override fun onMessageClicked(messageId: Long) {
|
||||
val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return
|
||||
|
||||
if (selectedMessages.value.isNotEmpty()) {
|
||||
@@ -379,13 +401,13 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
syncUiMessages()
|
||||
} else {
|
||||
messageDialog.setValue {
|
||||
dialog.setValue {
|
||||
MessageDialog.MessageOptions(currentMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageLongClicked(messageId: Int) {
|
||||
override fun onMessageLongClicked(messageId: Long) {
|
||||
val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return
|
||||
|
||||
val isSelected = selectedMessages.value.contains(currentMessage)
|
||||
@@ -399,7 +421,7 @@ class MessagesHistoryViewModelImpl(
|
||||
syncUiMessages()
|
||||
}
|
||||
|
||||
override fun onPinnedMessageClicked(messageId: Int) {
|
||||
override fun onPinnedMessageClicked(messageId: Long) {
|
||||
val uiMessages = uiMessages.value
|
||||
val messageIndex = uiMessages.indexOfFirstOrNull {
|
||||
it is UiItem.Message && it.id == messageId
|
||||
@@ -414,13 +436,13 @@ class MessagesHistoryViewModelImpl(
|
||||
|
||||
override fun onUnpinMessageClicked() {
|
||||
val pinnedMessageId = screenState.value.pinnedMessage?.id ?: return
|
||||
messageDialog.setValue {
|
||||
dialog.setValue {
|
||||
MessageDialog.MessageUnpin(pinnedMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleteSelectedMessagesClicked() {
|
||||
messageDialog.setValue {
|
||||
dialog.setValue {
|
||||
MessageDialog.MessagesDelete(selectedMessages.value)
|
||||
}
|
||||
}
|
||||
@@ -434,7 +456,7 @@ class MessagesHistoryViewModelImpl(
|
||||
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
|
||||
if (message.randomId != 0L && message.randomId in randomIds) return
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
newMessages.add(0, message)
|
||||
@@ -463,13 +485,13 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val messages = messages.value
|
||||
val index = messages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = messages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // диалога нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
val newConversation = screenState.value.conversation.copy(
|
||||
inRead = event.messageId
|
||||
inReadCmId = event.cmId
|
||||
)
|
||||
|
||||
screenState.setValue { old ->
|
||||
@@ -484,13 +506,13 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val messages = messages.value
|
||||
val index = messages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = messages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
val newConversation = screenState.value.conversation.copy(
|
||||
outRead = event.messageId
|
||||
outReadCmId = event.cmId
|
||||
)
|
||||
|
||||
screenState.setValue { old ->
|
||||
@@ -505,7 +527,7 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
@@ -520,10 +542,12 @@ class MessagesHistoryViewModelImpl(
|
||||
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
|
||||
if (event.message.date < minDate) { // сообщения не должно быть в списке
|
||||
// pizdets
|
||||
return
|
||||
}
|
||||
|
||||
newMessages.add(event.message)
|
||||
messages.setValue { newMessages.sorted() }
|
||||
@@ -534,7 +558,7 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
@@ -550,7 +574,7 @@ class MessagesHistoryViewModelImpl(
|
||||
if (event.peerId != screenState.value.conversationId) return
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == event.messageId }
|
||||
val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
@@ -578,30 +602,33 @@ class MessagesHistoryViewModelImpl(
|
||||
private fun loadConversation() {
|
||||
Log.d("MessagesHistoryViewModelImpl", "loadConversation()")
|
||||
|
||||
loadConversationsByIdUseCase(listOf(screenState.value.conversationId))
|
||||
.listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = ::handleError,
|
||||
success = { response ->
|
||||
val conversation = response.firstOrNull() ?: return@listenValue
|
||||
val title = conversation.extractTitle(
|
||||
useContactName = AppSettings.General.useContactNames,
|
||||
resources = resourceProvider.resources
|
||||
loadConversationsByIdUseCase(
|
||||
peerIds = listOf(screenState.value.conversationId),
|
||||
extended = true,
|
||||
fields = VkConstants.ALL_FIELDS
|
||||
).listenValue(viewModelScope) { state ->
|
||||
state.processState(
|
||||
error = ::handleError,
|
||||
success = { response ->
|
||||
val conversation = response.firstOrNull() ?: return@listenValue
|
||||
val title = conversation.extractTitle(
|
||||
useContactName = AppSettings.General.useContactNames,
|
||||
resources = resourceProvider.resources
|
||||
)
|
||||
val avatar = conversation.extractAvatar()
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
conversation = conversation,
|
||||
title = title,
|
||||
avatar = avatar
|
||||
)
|
||||
val avatar = conversation.extractAvatar()
|
||||
|
||||
screenState.setValue { old ->
|
||||
old.copy(
|
||||
conversation = conversation,
|
||||
title = title,
|
||||
avatar = avatar
|
||||
)
|
||||
}
|
||||
|
||||
conversation.pinnedMessage?.let(::handlePinnedMessage)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
conversation.pinnedMessage?.let(::handlePinnedMessage)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePinnedMessage(pinnedMessage: VkMessage?) {
|
||||
@@ -736,7 +763,7 @@ class MessagesHistoryViewModelImpl(
|
||||
dateDiff
|
||||
} else {
|
||||
val idDiff = m2.id - m1.id
|
||||
idDiff
|
||||
idDiff.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -745,14 +772,14 @@ class MessagesHistoryViewModelImpl(
|
||||
lastMessageText = screenState.value.message.text
|
||||
|
||||
val newMessage = VkMessage(
|
||||
id = -1 - sendingMessages.size,
|
||||
conversationMessageId = -1,
|
||||
id = -1L - sendingMessages.size,
|
||||
cmId = -1L - sendingMessages.size,
|
||||
text = lastMessageText,
|
||||
isOut = true,
|
||||
peerId = screenState.value.conversationId,
|
||||
fromId = UserConfig.userId,
|
||||
date = (System.currentTimeMillis() / 1000).toInt(),
|
||||
randomId = Random.nextInt(),
|
||||
randomId = Random.nextInt().toLong(),
|
||||
action = null,
|
||||
actionMemberId = null,
|
||||
actionText = null,
|
||||
@@ -769,7 +796,11 @@ class MessagesHistoryViewModelImpl(
|
||||
actionUser = null,
|
||||
actionGroup = null,
|
||||
isPinned = false,
|
||||
pinnedAt = null
|
||||
isSpam = false,
|
||||
pinnedAt = null,
|
||||
|
||||
// TODO: 04-Apr-25, Danil Nikolaev: implement
|
||||
formatData = null,
|
||||
)
|
||||
sendingMessages += newMessage
|
||||
messages.setValue { old -> listOf(newMessage).plus(old) }
|
||||
@@ -792,7 +823,7 @@ class MessagesHistoryViewModelImpl(
|
||||
state.processState(
|
||||
any = { sendingMessages.remove(newMessage) },
|
||||
error = { error ->
|
||||
val failedId = -500_000 - failedMessages.size
|
||||
val failedId = -500_000L - failedMessages.size
|
||||
val newFailedMessage = newMessage.copy(id = failedId)
|
||||
failedMessages += newFailedMessage
|
||||
|
||||
@@ -801,11 +832,13 @@ class MessagesHistoryViewModelImpl(
|
||||
messages.setValue { newMessages }
|
||||
syncUiMessages()
|
||||
},
|
||||
success = { messageId ->
|
||||
success = { response ->
|
||||
val newMessages = messages.value.toMutableList()
|
||||
newMessages[newMessages.indexOf(newMessage)] = newMessage.copy(id = messageId)
|
||||
newMessages[newMessages.indexOf(newMessage)] = newMessage.copy(
|
||||
id = response.messageId,
|
||||
cmId = response.cmId
|
||||
)
|
||||
messages.setValue { newMessages }
|
||||
|
||||
syncUiMessages()
|
||||
}
|
||||
)
|
||||
@@ -813,7 +846,7 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
|
||||
private fun markAsImportant(
|
||||
messageIds: List<Int>,
|
||||
messageIds: List<Long>,
|
||||
important: Boolean,
|
||||
) {
|
||||
messagesUseCase.markAsImportant(
|
||||
@@ -841,7 +874,7 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
|
||||
private fun deleteMessage(
|
||||
messageIds: List<Int>,
|
||||
messageIds: List<Long>,
|
||||
spam: Boolean = false,
|
||||
deleteForAll: Boolean = false,
|
||||
onSuccess: () -> Unit = {}
|
||||
@@ -866,39 +899,48 @@ class MessagesHistoryViewModelImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private fun pinMessage(messageId: Int) {
|
||||
private fun pinMessage(messageId: Long) {
|
||||
messagesUseCase.pin(
|
||||
peerId = screenState.value.conversationId,
|
||||
messageId = messageId,
|
||||
conversationMessageId = null
|
||||
cmId = 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()
|
||||
|
||||
val newMessages = messages.value.toMutableList()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == messageId }
|
||||
|
||||
if (index == null) {// сообщения нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
newMessages[index] = pinnedMessage
|
||||
messages.setValue { newMessages }
|
||||
syncUiMessages()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unpinMessage(messageId: Int) {
|
||||
private fun unpinMessage(messageId: Long) {
|
||||
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()
|
||||
val index = newMessages.indexOfFirstOrNull { it.id == messageId }
|
||||
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
newMessages[index] = newMessages[index].copy(isPinned = false)
|
||||
messages.setValue { newMessages }
|
||||
syncUiMessages()
|
||||
}
|
||||
|
||||
handlePinnedMessage(null)
|
||||
}
|
||||
@@ -908,8 +950,8 @@ class MessagesHistoryViewModelImpl(
|
||||
|
||||
fun editMessage(
|
||||
originalMessage: VkMessage,
|
||||
peerId: Int,
|
||||
messageId: Int,
|
||||
peerid: Long,
|
||||
messageid: Long,
|
||||
newText: String? = null,
|
||||
attachments: List<VkAttachment>? = null,
|
||||
) {
|
||||
@@ -1001,7 +1043,7 @@ class MessagesHistoryViewModelImpl(
|
||||
// TODO: 25.08.2023, Danil Nikolaev: this and down below - rewrite
|
||||
|
||||
// suspend fun uploadPhoto(
|
||||
// peerId: Int,
|
||||
// peerid: Long,
|
||||
// photo: File,
|
||||
// name: String,
|
||||
// ) {
|
||||
@@ -1021,7 +1063,7 @@ class MessagesHistoryViewModelImpl(
|
||||
// }
|
||||
// }
|
||||
|
||||
// private suspend fun getPhotoMessageUploadServer(peerId: Int) {
|
||||
// private suspend fun getPhotoMessageUploadServer(peerid: Long) {
|
||||
// suspendCoroutine { continuation ->
|
||||
// viewModelScope.launch {
|
||||
// sendRequestNotNull(
|
||||
@@ -1218,7 +1260,7 @@ class MessagesHistoryViewModelImpl(
|
||||
// }
|
||||
|
||||
// suspend fun uploadFile(
|
||||
// peerId: Int,
|
||||
// peerid: Long,
|
||||
// file: File,
|
||||
// name: String,
|
||||
// type: FilesRepository.FileType,
|
||||
@@ -1235,7 +1277,7 @@ class MessagesHistoryViewModelImpl(
|
||||
// }
|
||||
|
||||
// private suspend fun getFileMessageUploadServer(
|
||||
// peerId: Int,
|
||||
// peerid: Long,
|
||||
// type: FilesRepository.FileType,
|
||||
// ) {
|
||||
// suspendCoroutine { continuation ->
|
||||
@@ -1314,14 +1356,14 @@ class MessagesHistoryViewModelImpl(
|
||||
//
|
||||
//object MessagesUnpinEvent : VkEvent()
|
||||
//
|
||||
//data class MessagesDeleteEvent(val peerId: Int, val messagesIds: List<Int>) : VkEvent()
|
||||
//data class MessagesDeleteEvent(val peerid: Long, val messagesIds: List<Int>) : VkEvent()
|
||||
//
|
||||
//data class MessagesEditEvent(val message: VkMessageDomain) : VkEvent()
|
||||
//
|
||||
//data class MessagesReadEvent(
|
||||
// val isOut: Boolean,
|
||||
// val peerId: Int,
|
||||
// val messageId: Int,
|
||||
// val peerid: Long,
|
||||
// val messageid: Long,
|
||||
//) : VkEvent()
|
||||
//
|
||||
//data class MessagesNewEvent(
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ import dev.meloda.fast.model.api.domain.VkMessage
|
||||
@Immutable
|
||||
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 MessagePin(val messageId: Long) : MessageDialog()
|
||||
data class MessageUnpin(val messageId: Long) : MessageDialog()
|
||||
data class MessageDelete(val message: VkMessage) : MessageDialog()
|
||||
data class MessagesDelete(val messages: List<VkMessage>) : MessageDialog()
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.meloda.fast.messageshistory.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class MessageNavigation {
|
||||
|
||||
data class ChatMaterials(
|
||||
val peerId: Long,
|
||||
val cmId: Long
|
||||
) : MessageNavigation()
|
||||
}
|
||||
+1
-1
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class MessagesHistoryArguments(val conversationId: Int) : Parcelable
|
||||
data class MessagesHistoryArguments(val conversationId: Long) : Parcelable
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import dev.meloda.fast.model.api.domain.VkMessage
|
||||
|
||||
@Immutable
|
||||
data class MessagesHistoryScreenState(
|
||||
val conversationId: Int,
|
||||
val conversationId: Long,
|
||||
val title: String,
|
||||
val status: String?,
|
||||
val avatar: UiImage,
|
||||
|
||||
+10
-10
@@ -4,18 +4,18 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import dev.meloda.fast.common.model.UiImage
|
||||
|
||||
sealed class UiItem(
|
||||
open val id: Int,
|
||||
val cmId: Int
|
||||
open val id: Long,
|
||||
val cmId: Long
|
||||
) {
|
||||
|
||||
data class Message(
|
||||
override val id: Int,
|
||||
val conversationMessageId: Int,
|
||||
val text: String?,
|
||||
override val id: Long,
|
||||
val conversationMessageId: Long,
|
||||
val text: AnnotatedString?,
|
||||
val isOut: Boolean,
|
||||
val fromId: Int,
|
||||
val fromId: Long,
|
||||
val date: String,
|
||||
val randomId: Int,
|
||||
val randomId: Long,
|
||||
val isInChat: Boolean,
|
||||
val name: String,
|
||||
val showDate: Boolean,
|
||||
@@ -31,9 +31,9 @@ sealed class UiItem(
|
||||
) : UiItem(id, conversationMessageId)
|
||||
|
||||
data class ActionMessage(
|
||||
override val id: Int,
|
||||
val conversationMessageId: Int,
|
||||
override val id: Long,
|
||||
val conversationMessageId: Long,
|
||||
val text: AnnotatedString,
|
||||
val actionCmId: Int?
|
||||
val actionCmId: Long?
|
||||
) : UiItem(id, conversationMessageId)
|
||||
}
|
||||
|
||||
+3
-3
@@ -27,17 +27,17 @@ data class MessagesHistory(val arguments: MessagesHistoryArguments) {
|
||||
fun NavGraphBuilder.messagesHistoryScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit
|
||||
onNavigateToChatMaterials: (peerId: Long, cmId: Long) -> Unit
|
||||
) {
|
||||
composable<MessagesHistory>(typeMap = MessagesHistory.typeMap) {
|
||||
MessagesHistoryRoute(
|
||||
onError = onError,
|
||||
onBack = onBack,
|
||||
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
|
||||
onNavigateToChatMaterials = onNavigateToChatMaterials
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToMessagesHistory(conversationId: Int) {
|
||||
fun NavController.navigateToMessagesHistory(conversationId: Long) {
|
||||
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
|
||||
}
|
||||
|
||||
+140
-104
@@ -21,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.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -30,6 +31,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.messageshistory.model.SendingStatus
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
@@ -38,7 +40,7 @@ import dev.meloda.fast.ui.R as UiR
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String?,
|
||||
text: AnnotatedString?,
|
||||
isOut: Boolean,
|
||||
date: String?,
|
||||
edited: Boolean,
|
||||
@@ -55,122 +57,156 @@ fun MessageBubble(
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
|
||||
val textColor = if (!isOut) {
|
||||
val contentColor = if (!isOut) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 6.dp
|
||||
)
|
||||
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
|
||||
) {
|
||||
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 + importantIndicatorPart
|
||||
}
|
||||
}
|
||||
|
||||
val dateContainerWidth by animateDpAsState(
|
||||
targetValue = minDateContainerWidth,
|
||||
label = "dateContainerWidth"
|
||||
)
|
||||
|
||||
if (text != null) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.defaultMinSize(minWidth = dateContainerWidth)
|
||||
CompositionLocalProvider(LocalContentColor provides contentColor) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
horizontal = 8.dp,
|
||||
vertical = 6.dp
|
||||
)
|
||||
.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),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.rotate(45f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (edited) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Create,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.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 + importantIndicatorPart
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = date.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
val dateContainerWidth by animateDpAsState(
|
||||
targetValue = minDateContainerWidth,
|
||||
label = "dateContainerWidth"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
if (isOut) {
|
||||
Icon(
|
||||
modifier = Modifier.size(14.dp),
|
||||
painter = painterResource(
|
||||
when (sendingStatus) {
|
||||
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
|
||||
SendingStatus.SENT -> {
|
||||
if (isRead) UiR.drawable.round_done_all_24
|
||||
else UiR.drawable.ic_round_done_24
|
||||
}
|
||||
if (text != null) {
|
||||
val textLambda: @Composable () -> Unit = remember(text, theme, dateContainerWidth) {
|
||||
{
|
||||
Text(
|
||||
text = kotlin.run {
|
||||
val builder = AnnotatedString.Builder(text)
|
||||
|
||||
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
|
||||
}
|
||||
),
|
||||
tint = if (sendingStatus == SendingStatus.FAILED) Color.Red
|
||||
else LocalContentColor.current,
|
||||
contentDescription = null
|
||||
text.spanStyles.map { spanStyleRange ->
|
||||
val updatedSpanStyle =
|
||||
if (spanStyleRange.item.color == Color.Red) {
|
||||
spanStyleRange.item.copy(color =
|
||||
if (isOut) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
} else {
|
||||
spanStyleRange.item
|
||||
}
|
||||
|
||||
builder.addStyle(
|
||||
style = updatedSpanStyle,
|
||||
start = spanStyleRange.start,
|
||||
end = spanStyleRange.end
|
||||
)
|
||||
}
|
||||
|
||||
text.paragraphStyles.forEach { style ->
|
||||
builder.addStyle(
|
||||
style = style.item,
|
||||
start = style.start,
|
||||
end = style.end
|
||||
)
|
||||
}
|
||||
|
||||
builder.toAnnotatedString()
|
||||
},
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
SelectionContainer {
|
||||
textLambda.invoke()
|
||||
}
|
||||
} else {
|
||||
textLambda.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.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),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.rotate(45f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (edited) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Create,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = date.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
if (isOut) {
|
||||
Icon(
|
||||
modifier = Modifier.size(14.dp),
|
||||
painter = painterResource(
|
||||
when (sendingStatus) {
|
||||
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
|
||||
SendingStatus.SENT -> {
|
||||
if (isRead) UiR.drawable.round_done_all_24
|
||||
else UiR.drawable.ic_round_done_24
|
||||
}
|
||||
|
||||
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
|
||||
}
|
||||
),
|
||||
tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error
|
||||
else LocalContentColor.current,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import dev.meloda.fast.data.UserConfig
|
||||
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.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.MaterialDialog
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Composable
|
||||
fun HandleDialogs(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
dialog: MessageDialog?,
|
||||
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
|
||||
onDismissed: (MessageDialog) -> Unit = {},
|
||||
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
|
||||
) {
|
||||
when (dialog) {
|
||||
null -> Unit
|
||||
|
||||
is MessageDialog.MessageOptions -> {
|
||||
MessageOptionsDialog(
|
||||
screenState = screenState,
|
||||
message = dialog.message,
|
||||
onDismissed = { onDismissed(dialog) },
|
||||
onItemPicked = { bundle -> onItemPicked(dialog, bundle) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = listOf(dialog.message),
|
||||
onConfirmed = { onConfirmed(dialog, it) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagesDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = dialog.messages,
|
||||
onConfirmed = { onConfirmed(dialog, it) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagePin,
|
||||
is MessageDialog.MessageUnpin -> {
|
||||
MessagePinStateDialog(
|
||||
pin = dialog is MessageDialog.MessagePin,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageMarkImportance -> {
|
||||
MessageImportanceDialog(
|
||||
important = dialog.isImportant,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageSpam -> {
|
||||
MessageSpamDialog(
|
||||
spam = dialog.isSpam,
|
||||
onConfirmed = { onConfirmed(dialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(dialog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun MessageOptionsDialog(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
message: VkMessage,
|
||||
onDismissed: () -> Unit = {},
|
||||
onItemPicked: (Bundle) -> Unit
|
||||
) {
|
||||
val options = mutableListOf<MessageOption>()
|
||||
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<VkMessage>,
|
||||
onConfirmed: (Bundle) -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
var forEveryone by remember {
|
||||
mutableStateOf(
|
||||
!messages.any { it.peerId == UserConfig.userId }
|
||||
&& messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
val shouldBeDisabled by remember(messages) {
|
||||
mutableStateOf(
|
||||
messages.any { it.peerId == UserConfig.userId }
|
||||
|| messages.all(VkMessage::isFailed)
|
||||
|| !messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(R.string.delete_message_title),
|
||||
confirmText = stringResource(R.string.action_delete),
|
||||
confirmAction = {
|
||||
onConfirmed(
|
||||
bundleOf("everyone" to if (messages.all(VkMessage::isOut)) forEveryone else false)
|
||||
)
|
||||
},
|
||||
cancelText = stringResource(R.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(R.string.delete_message_for_everyone))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagePinStateDialog(
|
||||
pin: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (pin) R.string.pin_message_title
|
||||
else R.string.unpin_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (pin) R.string.pin_message_text
|
||||
else R.string.unpin_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (pin) R.string.action_pin
|
||||
else R.string.action_unpin
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageImportanceDialog(
|
||||
important: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (important) R.string.important_message_title
|
||||
else R.string.unimportant_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (important) R.string.important_message_text
|
||||
else R.string.unimportant_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (important) R.string.action_mark
|
||||
else R.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageSpamDialog(
|
||||
spam: Boolean,
|
||||
onConfirmed: () -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
MaterialDialog(
|
||||
onDismissRequest = onDismissed,
|
||||
title = stringResource(
|
||||
if (spam) R.string.spam_message_title
|
||||
else R.string.unspam_message_title
|
||||
),
|
||||
text = stringResource(
|
||||
if (spam) R.string.spam_message_text
|
||||
else R.string.unspam_message_text
|
||||
),
|
||||
confirmText = stringResource(
|
||||
if (spam) R.string.action_mark
|
||||
else R.string.action_unmark
|
||||
),
|
||||
confirmAction = onConfirmed,
|
||||
cancelText = stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.meloda.fast.messageshistory.presentation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
|
||||
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
|
||||
import dev.meloda.fast.messageshistory.model.MessageNavigation
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun MessagesHistoryRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit,
|
||||
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
|
||||
) {
|
||||
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
|
||||
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val uiMessages by viewModel.uiMessages.collectAsStateWithLifecycle()
|
||||
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
|
||||
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
|
||||
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
|
||||
|
||||
val userSettings: UserSettings = koinInject()
|
||||
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(navigationEvent) {
|
||||
val needToConsume = when (val navigation = navigationEvent) {
|
||||
null -> false
|
||||
|
||||
is MessageNavigation.ChatMaterials -> {
|
||||
val (peerId, cmId) = navigation
|
||||
onNavigateToChatMaterials(peerId, cmId)
|
||||
true
|
||||
}
|
||||
}
|
||||
if (needToConsume) viewModel.onNavigationConsumed()
|
||||
}
|
||||
|
||||
MessagesHistoryScreen(
|
||||
screenState = screenState,
|
||||
messages = messages.toImmutableList(),
|
||||
uiMessages = uiMessages.toImmutableList(),
|
||||
scrollIndex = scrollIndex,
|
||||
selectedMessages = selectedMessages.toImmutableList(),
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
showEmojiButton = showEmojiButton,
|
||||
onBack = onBack,
|
||||
onClose = viewModel::onCloseButtonClicked,
|
||||
onScrolledToIndex = viewModel::onScrolledToIndex,
|
||||
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
|
||||
onTopBarClicked = viewModel::onTopBarClicked,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onMessageInputChanged = viewModel::onMessageInputChanged,
|
||||
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
|
||||
onActionButtonClicked = viewModel::onActionButtonClicked,
|
||||
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
|
||||
onMessageClicked = viewModel::onMessageClicked,
|
||||
onMessageLongClicked = viewModel::onMessageLongClicked,
|
||||
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
|
||||
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
|
||||
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
dialog = dialog,
|
||||
onConfirmed = viewModel::onDialogConfirmed,
|
||||
onDismissed = viewModel::onDialogDismissed,
|
||||
onItemPicked = viewModel::onDialogItemPicked
|
||||
)
|
||||
}
|
||||
+61
-438
@@ -1,6 +1,5 @@
|
||||
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
|
||||
@@ -41,7 +40,6 @@ 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
|
||||
@@ -56,7 +54,6 @@ 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
|
||||
@@ -82,9 +79,7 @@ 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
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
@@ -93,377 +88,21 @@ import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.meloda.fast.common.extensions.orDots
|
||||
import dev.meloda.fast.data.UserConfig
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
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.VkErrorView
|
||||
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
|
||||
fun MessagesHistoryRoute(
|
||||
onError: (BaseError) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit,
|
||||
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
|
||||
) {
|
||||
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 scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
|
||||
|
||||
val userSettings: UserSettings = koinInject()
|
||||
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
|
||||
|
||||
MessagesHistoryScreen(
|
||||
screenState = screenState,
|
||||
messages = messages.toImmutableList(),
|
||||
uiMessages = uiMessages.toImmutableList(),
|
||||
scrollIndex = scrollIndex,
|
||||
selectedMessages = selectedMessages.toImmutableList(),
|
||||
baseError = baseError,
|
||||
canPaginate = canPaginate,
|
||||
showEmojiButton = showEmojiButton,
|
||||
onBack = onBack,
|
||||
onClose = viewModel::onCloseButtonClicked,
|
||||
onScrolledToIndex = viewModel::onScrolledToIndex,
|
||||
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
|
||||
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
|
||||
onRefresh = viewModel::onRefresh,
|
||||
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
|
||||
onMessageInputChanged = viewModel::onMessageInputChanged,
|
||||
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
|
||||
onActionButtonClicked = viewModel::onActionButtonClicked,
|
||||
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
|
||||
onMessageClicked = viewModel::onMessageClicked,
|
||||
onMessageLongClicked = viewModel::onMessageLongClicked,
|
||||
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
|
||||
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
|
||||
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
|
||||
)
|
||||
|
||||
HandleDialogs(
|
||||
screenState = screenState,
|
||||
messageDialog = messageDialog,
|
||||
onConfirmed = viewModel::onDialogConfirmed,
|
||||
onDismissed = viewModel::onDialogDismissed,
|
||||
onItemPicked = viewModel::onDialogItemPicked
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HandleDialogs(
|
||||
screenState: MessagesHistoryScreenState,
|
||||
messageDialog: MessageDialog?,
|
||||
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
|
||||
onDismissed: (MessageDialog) -> Unit = {},
|
||||
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
|
||||
) {
|
||||
when (messageDialog) {
|
||||
null -> Unit
|
||||
|
||||
is MessageDialog.MessageOptions -> {
|
||||
MessageOptionsDialog(
|
||||
screenState = screenState,
|
||||
message = messageDialog.message,
|
||||
onDismissed = { onDismissed(messageDialog) },
|
||||
onItemPicked = { bundle -> onItemPicked(messageDialog, bundle) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessageDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = listOf(messageDialog.message),
|
||||
onConfirmed = { onConfirmed(messageDialog, it) },
|
||||
onDismissed = { onDismissed(messageDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagesDelete -> {
|
||||
MessageDeleteDialog(
|
||||
messages = messageDialog.messages,
|
||||
onConfirmed = { onConfirmed(messageDialog, it) },
|
||||
onDismissed = { onDismissed(messageDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDialog.MessagePin,
|
||||
is MessageDialog.MessageUnpin -> {
|
||||
MessagePinStateDialog(
|
||||
pin = messageDialog is MessageDialog.MessagePin,
|
||||
onConfirmed = { onConfirmed(messageDialog, bundleOf()) },
|
||||
onDismissed = { onDismissed(messageDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
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<MessageOption>()
|
||||
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<VkMessage>,
|
||||
onConfirmed: (Bundle) -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
) {
|
||||
var forEveryone by remember {
|
||||
mutableStateOf(
|
||||
!messages.any { it.peerId == UserConfig.userId }
|
||||
&& messages.all(VkMessage::isOut)
|
||||
)
|
||||
}
|
||||
|
||||
val shouldBeDisabled by remember(messages) {
|
||||
mutableStateOf(
|
||||
messages.any { it.peerId == UserConfig.userId }
|
||||
|| messages.all(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(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalHazeMaterialsApi::class,
|
||||
@@ -483,16 +122,16 @@ fun MessagesHistoryScreen(
|
||||
onClose: () -> Unit = {},
|
||||
onScrolledToIndex: () -> Unit = {},
|
||||
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
|
||||
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
|
||||
onTopBarClicked: () -> Unit = {},
|
||||
onRefresh: () -> Unit = {},
|
||||
onPaginationConditionsMet: () -> Unit = {},
|
||||
onMessageInputChanged: (TextFieldValue) -> Unit = {},
|
||||
onAttachmentButtonClicked: () -> Unit = {},
|
||||
onActionButtonClicked: () -> Unit = {},
|
||||
onEmojiButtonLongClicked: () -> Unit = {},
|
||||
onMessageClicked: (Int) -> Unit = {},
|
||||
onMessageLongClicked: (Int) -> Unit = {},
|
||||
onPinnedMessageClicked: (Int) -> Unit = {},
|
||||
onMessageClicked: (Long) -> Unit = {},
|
||||
onMessageLongClicked: (Long) -> Unit = {},
|
||||
onPinnedMessageClicked: (Long) -> Unit = {},
|
||||
onUnpinMessageButtonClicked: () -> Unit = {},
|
||||
onDeleteSelectedButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
@@ -516,12 +155,7 @@ fun MessagesHistoryScreen(
|
||||
onBack = onClose
|
||||
)
|
||||
|
||||
val pinnedMessage by remember(screenState) {
|
||||
derivedStateOf {
|
||||
screenState.conversation.pinnedMessage
|
||||
}
|
||||
}
|
||||
|
||||
val pinnedMessage = screenState.pinnedMessage
|
||||
|
||||
val paginationConditionMet by remember(canPaginate, listState) {
|
||||
derivedStateOf {
|
||||
@@ -598,7 +232,13 @@ fun MessagesHistoryScreen(
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (screenState.isLoading && messages.isEmpty()) Modifier
|
||||
else Modifier.clickable {
|
||||
onTopBarClicked()
|
||||
}
|
||||
),
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -606,23 +246,41 @@ fun MessagesHistoryScreen(
|
||||
) {
|
||||
if (selectedMessages.isEmpty()) {
|
||||
val avatar = screenState.avatar.getImage()
|
||||
if (avatar is Painter) {
|
||||
Image(
|
||||
painter = avatar,
|
||||
contentDescription = null,
|
||||
if (screenState.conversationId == UserConfig.userId) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(24.dp),
|
||||
painter = painterResource(id = UiR.drawable.ic_round_bookmark_border_24),
|
||||
contentDescription = "Favorites icon",
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = screenState.avatar.getImage(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape),
|
||||
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut),
|
||||
)
|
||||
if (avatar is Painter) {
|
||||
Image(
|
||||
painter = avatar,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = screenState.avatar.getImage(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape),
|
||||
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
@@ -705,9 +363,6 @@ fun MessagesHistoryScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (screenState.isLoading) {
|
||||
return@TopAppBar
|
||||
}
|
||||
IconButton(
|
||||
onClick = { dropDownMenuExpanded = true }
|
||||
) {
|
||||
@@ -725,28 +380,6 @@ fun MessagesHistoryScreen(
|
||||
},
|
||||
offset = DpOffset(x = (-4).dp, y = (-60).dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dropDownMenuExpanded = false
|
||||
|
||||
// TODO: 11/07/2024, Danil Nikolaev: to VM
|
||||
|
||||
// TODO: 23-Mar-25, Danil Nikolaev: crash if no messages (ex. new chat)
|
||||
onChatMaterialsDropdownItemClicked(
|
||||
screenState.conversationId,
|
||||
uiMessages.values.firstMessage().conversationMessageId
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(UiR.string.chat_materials_action_title))
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(UiR.drawable.ic_multimedia),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onRefresh()
|
||||
@@ -808,10 +441,13 @@ fun MessagesHistoryScreen(
|
||||
isPaginating = screenState.isPaginating,
|
||||
messageBarHeight = messageBarHeight,
|
||||
onRequestScrollToCmId = { cmId ->
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(
|
||||
index = uiMessages.values.indexOfMessageByCmId(cmId)
|
||||
)
|
||||
val index = uiMessages.values.indexOfMessageByCmId(cmId)
|
||||
if (index == null) { // сообщения нет в списке
|
||||
// pizdets
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(index = index)
|
||||
}
|
||||
}
|
||||
},
|
||||
onMessageClicked = { id ->
|
||||
@@ -847,12 +483,15 @@ fun MessagesHistoryScreen(
|
||||
.clip(RoundedCornerShape(36.dp))
|
||||
.then(
|
||||
if (theme.enableBlur) {
|
||||
Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.ultraThin()
|
||||
).border(1.dp, MaterialTheme.colorScheme.outlineVariant,
|
||||
RoundedCornerShape(36.dp)
|
||||
)
|
||||
Modifier
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.ultraThin()
|
||||
)
|
||||
.border(
|
||||
1.dp, MaterialTheme.colorScheme.outlineVariant,
|
||||
RoundedCornerShape(36.dp)
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.animateContentSize()
|
||||
@@ -1042,23 +681,7 @@ fun MessagesHistoryScreen(
|
||||
}
|
||||
|
||||
baseError != null -> {
|
||||
when (baseError) {
|
||||
is BaseError.SessionExpired -> {
|
||||
ErrorView(
|
||||
text = stringResource(UiR.string.session_expired),
|
||||
buttonText = stringResource(UiR.string.action_log_out),
|
||||
onButtonClick = onSessionExpiredLogOutButtonClicked
|
||||
)
|
||||
}
|
||||
|
||||
is BaseError.SimpleError -> {
|
||||
ErrorView(
|
||||
text = baseError.message,
|
||||
buttonText = stringResource(UiR.string.try_again),
|
||||
onButtonClick = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
VkErrorView(baseError = baseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -45,13 +45,10 @@ fun MessagesList(
|
||||
uiMessages: ImmutableList<UiItem>,
|
||||
isPaginating: Boolean,
|
||||
messageBarHeight: Dp,
|
||||
onRequestScrollToCmId: (cmId: Int) -> Unit = {},
|
||||
onMessageClicked: (Int) -> Unit = {},
|
||||
onMessageLongClicked: (Int) -> Unit = {}
|
||||
onRequestScrollToCmId: (cmId: Long) -> Unit = {},
|
||||
onMessageClicked: (Long) -> Unit = {},
|
||||
onMessageLongClicked: (Long) -> Unit = {}
|
||||
) {
|
||||
val messages = remember(uiMessages) {
|
||||
uiMessages.toList()
|
||||
}
|
||||
val theme = LocalThemeConfig.current
|
||||
val view = LocalView.current
|
||||
|
||||
@@ -77,7 +74,7 @@ fun MessagesList(
|
||||
}
|
||||
|
||||
items(
|
||||
items = messages,
|
||||
items = uiMessages.values,
|
||||
key = UiItem::id,
|
||||
contentType = { item ->
|
||||
when (item) {
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ fun OutgoingMessageBubble(
|
||||
) {
|
||||
MessageBubble(
|
||||
modifier = Modifier,
|
||||
text = message.text.orDots(),
|
||||
text = message.text,
|
||||
isOut = true,
|
||||
date = message.date,
|
||||
edited = message.isEdited,
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ fun PinnedMessageContainer(
|
||||
title: String,
|
||||
summary: AnnotatedString?,
|
||||
canChangePin: Boolean,
|
||||
onPinnedMessageClicked: (Int) -> Unit = {},
|
||||
onPinnedMessageClicked: (Long) -> Unit = {},
|
||||
onUnpinMessageButtonClicked: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
package dev.meloda.fast.messageshistory.util
|
||||
|
||||
import com.conena.nanokt.collections.indexOfFirstOrNull
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
|
||||
fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first()
|
||||
|
||||
fun List<UiItem>.firstMessageOrNull(): UiItem.Message? = filterIsInstance<UiItem.Message>().firstOrNull()
|
||||
|
||||
fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
|
||||
fun List<UiItem>.indexOfMessageById(messageId: Long): Int =
|
||||
indexOfFirst { it.id == messageId }
|
||||
|
||||
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
|
||||
fun List<UiItem>.findMessageById(messageId: Long): UiItem.Message? =
|
||||
firstOrNull { it.id == messageId } as UiItem.Message?
|
||||
|
||||
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
|
||||
indexOfFirst { it.cmId == cmId }
|
||||
fun List<UiItem>.indexOfMessageByCmId(cmId: Long): Int? =
|
||||
indexOfFirstOrNull { it.cmId == cmId }
|
||||
|
||||
fun List<UiItem>.findMessageByCmId(cmId: Int): UiItem.Message =
|
||||
fun List<UiItem>.findMessageByCmId(cmId: Long): UiItem.Message =
|
||||
first { it.cmId == cmId } as UiItem.Message
|
||||
|
||||
+155
-4
@@ -1,10 +1,15 @@
|
||||
package dev.meloda.fast.messageshistory.util
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.AnnotatedString.Annotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.StringAnnotation
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import dev.meloda.fast.common.extensions.orDots
|
||||
import dev.meloda.fast.common.model.UiImage
|
||||
import dev.meloda.fast.common.model.UiText
|
||||
@@ -15,6 +20,7 @@ import dev.meloda.fast.data.VkMemoryCache
|
||||
import dev.meloda.fast.messageshistory.model.SendingStatus
|
||||
import dev.meloda.fast.messageshistory.model.UiItem
|
||||
import dev.meloda.fast.model.api.PeerType
|
||||
import dev.meloda.fast.model.api.domain.FormatDataType
|
||||
import dev.meloda.fast.model.api.domain.VkConversation
|
||||
import dev.meloda.fast.model.api.domain.VkMessage
|
||||
import dev.meloda.fast.ui.R
|
||||
@@ -22,7 +28,7 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
private fun isAccount(fromId: Int) = fromId == UserConfig.userId
|
||||
private fun isAccount(fromId: Long) = fromId == UserConfig.userId
|
||||
|
||||
fun VkMessage.extractAvatar() = when {
|
||||
isUser() -> {
|
||||
@@ -101,7 +107,7 @@ fun VkMessage.asPresentation(
|
||||
): UiItem = when {
|
||||
action != null -> UiItem.ActionMessage(
|
||||
id = id,
|
||||
conversationMessageId = conversationMessageId,
|
||||
conversationMessageId = cmId,
|
||||
text = extractActionText(
|
||||
resources = resourceProvider.resources,
|
||||
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
|
||||
@@ -112,8 +118,12 @@ fun VkMessage.asPresentation(
|
||||
|
||||
else -> UiItem.Message(
|
||||
id = id,
|
||||
conversationMessageId = conversationMessageId,
|
||||
text = text,
|
||||
conversationMessageId = cmId,
|
||||
text = extractTextWithVisualizedMentions(
|
||||
isOut = isOut,
|
||||
originalText = text,
|
||||
formatData = formatData
|
||||
),
|
||||
isOut = isOut,
|
||||
fromId = fromId,
|
||||
date = extractDate(),
|
||||
@@ -542,3 +552,144 @@ fun VkMessage.extractActionText(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 04-Apr-25, Danil Nikolaev: get rid of method duplication
|
||||
fun extractTextWithVisualizedMentions(
|
||||
isOut: Boolean,
|
||||
originalText: String?,
|
||||
formatData: VkMessage.FormatData?
|
||||
): AnnotatedString? {
|
||||
if (originalText == null) return null
|
||||
|
||||
val annotations =
|
||||
mutableListOf<AnnotatedString.Range<out Annotation>>()
|
||||
|
||||
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
|
||||
|
||||
val mentions = mutableListOf<MentionIndex>()
|
||||
|
||||
var currentIndex = 0
|
||||
val replacements = mutableListOf<Pair<IntRange, String>>()
|
||||
|
||||
val newText = regex.replace(originalText) { matchResult ->
|
||||
val idPrefix = matchResult.groups[1]?.value.orEmpty()
|
||||
val startIndex = matchResult.range.first
|
||||
val endIndex = matchResult.range.last
|
||||
|
||||
val id = matchResult.groups[2]?.value ?: ""
|
||||
|
||||
val replaced = matchResult.groups[3]?.value.orEmpty()
|
||||
|
||||
val indexRange =
|
||||
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
|
||||
|
||||
replacements.add(indexRange to replaced)
|
||||
|
||||
mentions += MentionIndex(
|
||||
id = id.toLongOrNull() ?: -1,
|
||||
idPrefix = idPrefix,
|
||||
indexRange = indexRange
|
||||
)
|
||||
|
||||
currentIndex += replaced.length - (endIndex - startIndex + 1)
|
||||
|
||||
replaced
|
||||
}
|
||||
|
||||
mentions.forEach { mention ->
|
||||
val startIndex = mention.indexRange.first
|
||||
val endIndex = mention.indexRange.last
|
||||
|
||||
annotations += AnnotatedString.Range(
|
||||
item = SpanStyle(color = Color.Red),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
annotations += AnnotatedString.Range(
|
||||
item = StringAnnotation(mention.id.toString()),
|
||||
tag = mention.idPrefix,
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
}
|
||||
|
||||
if (formatData == null) return AnnotatedString(text = newText, annotations = annotations)
|
||||
|
||||
var current = 0
|
||||
|
||||
val newOffsets = formatData.items.map { (offset, length) ->
|
||||
val r = replacements.filter { (range, _) ->
|
||||
(range - current) collidesWith (offset..<offset + length) || offset > range.first
|
||||
}
|
||||
|
||||
current = r.sumOf { (range, _) -> range.last - range.first - 1 }
|
||||
|
||||
offset + current
|
||||
}
|
||||
|
||||
formatData.items.forEachIndexed { index, item ->
|
||||
val offset = newOffsets[index]
|
||||
|
||||
val spanStyle = when (item.type) {
|
||||
FormatDataType.BOLD -> {
|
||||
SpanStyle(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
FormatDataType.ITALIC -> {
|
||||
SpanStyle(fontStyle = FontStyle.Italic)
|
||||
}
|
||||
|
||||
FormatDataType.UNDERLINE -> {
|
||||
SpanStyle(textDecoration = TextDecoration.Underline)
|
||||
}
|
||||
|
||||
FormatDataType.URL -> {
|
||||
annotations += AnnotatedString.Range(
|
||||
item = StringAnnotation(item.url.orEmpty()),
|
||||
start = offset,
|
||||
end = offset + item.length,
|
||||
tag = newText.substring(offset, offset + item.length)
|
||||
)
|
||||
|
||||
if (isOut) {
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
} else {
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
annotations += AnnotatedString.Range(
|
||||
item = spanStyle,
|
||||
start = offset,
|
||||
end = offset + item.length
|
||||
)
|
||||
}
|
||||
|
||||
return AnnotatedString(text = newText, annotations = annotations)
|
||||
}
|
||||
|
||||
data class MentionIndex(
|
||||
val id: Long,
|
||||
val idPrefix: String,
|
||||
val indexRange: IntRange
|
||||
)
|
||||
|
||||
infix fun ClosedRange<Int>.collidesWith(other: ClosedRange<Int>): Boolean {
|
||||
return this.start < other.endInclusive && other.start < this.endInclusive
|
||||
}
|
||||
|
||||
operator fun ClosedRange<Int>.minus(other: ClosedRange<Int>): ClosedRange<Int> {
|
||||
return (this.start - other.start)..(this.endInclusive - other.endInclusive)
|
||||
}
|
||||
|
||||
operator fun ClosedRange<Int>.minus(other: Int): ClosedRange<Int> {
|
||||
return (this.start - other)..(this.endInclusive - other)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user