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:
2025-04-04 20:43:59 +03:00
committed by GitHub
parent add67b6f8d
commit 89748b72ed
237 changed files with 4896 additions and 3289 deletions
@@ -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(
@@ -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()
@@ -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()
}
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class MessagesHistoryArguments(val conversationId: Int) : Parcelable
data class MessagesHistoryArguments(val conversationId: Long) : Parcelable
@@ -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,
@@ -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)
}
@@ -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)))
}
@@ -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
)
}
}
}
}
@@ -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)
)
}
@@ -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
)
}
@@ -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)
}
}
}
@@ -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) {
@@ -38,7 +38,7 @@ fun OutgoingMessageBubble(
) {
MessageBubble(
modifier = Modifier,
text = message.text.orDots(),
text = message.text,
isOut = true,
date = message.date,
edited = message.isEdited,
@@ -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
@@ -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)
}