* refactor Conversation -> Convo

* extract Message and Convo mappers to core/domain module
* improve reply container text
This commit is contained in:
2025-12-17 17:16:02 +03:00
parent 7b6571f208
commit 45ee0acea5
125 changed files with 2361 additions and 2005 deletions
@@ -5,9 +5,9 @@ import androidx.compose.ui.text.input.TextFieldValue
import dev.meloda.fast.messageshistory.model.MessageDialog
import dev.meloda.fast.messageshistory.model.MessageNavigation
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.vk.MessageUiItem
import kotlinx.coroutines.flow.StateFlow
interface MessagesHistoryViewModel {
@@ -15,7 +15,7 @@ interface MessagesHistoryViewModel {
val screenState: StateFlow<MessagesHistoryScreenState>
val navigation: StateFlow<MessageNavigation?>
val messages: StateFlow<List<VkMessage>>
val uiMessages: StateFlow<List<UiItem>>
val uiMessages: StateFlow<List<MessageUiItem>>
val dialog: StateFlow<MessageDialog?>
val selectedMessages: StateFlow<List<VkMessage>>
@@ -37,21 +37,21 @@ import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.domain.util.extractReplySummary
import dev.meloda.fast.domain.util.extractTitle
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
import dev.meloda.fast.messageshistory.navigation.MessagesHistory
import dev.meloda.fast.messageshistory.util.asPresentation
import dev.meloda.fast.messageshistory.util.extractAvatar
import dev.meloda.fast.messageshistory.util.extractTitle
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.FormatDataType
@@ -60,6 +60,7 @@ import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.MessageUiItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@@ -79,10 +80,10 @@ import kotlin.random.Random
class MessagesHistoryViewModelImpl(
private val applicationContext: Context,
private val messagesUseCase: MessagesUseCase,
private val conversationsUseCase: ConversationsUseCase,
private val convoUseCase: ConvoUseCase,
private val resourceProvider: ResourceProvider,
private val userSettings: UserSettings,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase,
private val loadConvosByIdUseCase: LoadConvosByIdUseCase,
private val getMessageReadPeersUseCase: GetMessageReadPeersUseCase,
updatesParser: LongPollUpdatesParser,
savedStateHandle: SavedStateHandle
@@ -105,7 +106,7 @@ class MessagesHistoryViewModelImpl(
override val canPaginate = MutableStateFlow(false)
override val messages = MutableStateFlow<List<VkMessage>>(emptyList())
override val uiMessages = MutableStateFlow<List<UiItem>>(emptyList())
override val uiMessages = MutableStateFlow<List<MessageUiItem>>(emptyList())
private var lastMessageText: String? = null
@@ -117,9 +118,9 @@ class MessagesHistoryViewModelImpl(
init {
val arguments = MessagesHistory.from(savedStateHandle).arguments
screenState.setValue { old -> old.copy(conversationId = arguments.conversationId) }
screenState.setValue { old -> old.copy(convoId = arguments.convoId) }
loadConversation()
loadConvo()
loadMessagesHistory()
updatesParser.onNewMessage(::handleNewMessage)
@@ -142,7 +143,7 @@ class MessagesHistoryViewModelImpl(
navigation.setValue {
MessageNavigation.ChatMaterials(
peerId = screenState.value.conversationId,
peerId = screenState.value.convoId,
cmId = cmId
)
}
@@ -411,7 +412,7 @@ class MessagesHistoryViewModelImpl(
override fun onPinnedMessageClicked(messageId: Long) {
val uiMessages = uiMessages.value
val messageIndex = uiMessages.indexOfFirstOrNull {
it is UiItem.Message && it.id == messageId
it is MessageUiItem.Message && it.id == messageId
}
if (messageIndex == null) { // сообщения нет в списке
@@ -442,7 +443,7 @@ class MessagesHistoryViewModelImpl(
screenState.setValue { old ->
old.copy(
replyTitle = messageToReply.extractTitle(),
replyText = messageToReply.text
replyText = messageToReply.extractReplySummary(resourceProvider.resources)
)
}
}
@@ -601,7 +602,7 @@ class MessagesHistoryViewModelImpl(
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.conversationId) return
if (message.peerId != screenState.value.convoId) return
if (messages.value.indexOfFirstOrNull { it.id == message.id } != null) return
val randomIds = messages.value.map(VkMessage::randomId)
@@ -617,7 +618,7 @@ class MessagesHistoryViewModelImpl(
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message
if (message.peerId != screenState.value.conversationId) return
if (message.peerId != screenState.value.convoId) return
val newMessages = messages.value.toMutableList()
val index = newMessages.indexOfFirstOrNull { it.id == message.id }
@@ -631,7 +632,7 @@ class MessagesHistoryViewModelImpl(
}
private fun handleReadIncomingEvent(event: LongPollParsedEvent.IncomingMessageRead) {
if (event.peerId != screenState.value.conversationId) return
if (event.peerId != screenState.value.convoId) return
val messages = messages.value
val index = messages.indexOfFirstOrNull { it.cmId == event.cmId }
@@ -639,12 +640,12 @@ class MessagesHistoryViewModelImpl(
if (index == null) { // диалога нет в списке
// pizdets
} else {
val newConversation = screenState.value.conversation.copy(
val newConvo = screenState.value.convo.copy(
inReadCmId = event.cmId
)
screenState.setValue { old ->
old.copy(conversation = newConversation)
old.copy(convo = newConvo)
}
syncUiMessages()
@@ -652,7 +653,7 @@ class MessagesHistoryViewModelImpl(
}
private fun handleReadOutgoingEvent(event: LongPollParsedEvent.OutgoingMessageRead) {
if (event.peerId != screenState.value.conversationId) return
if (event.peerId != screenState.value.convoId) return
val messages = messages.value
val index = messages.indexOfFirstOrNull { it.cmId == event.cmId }
@@ -660,12 +661,12 @@ class MessagesHistoryViewModelImpl(
if (index == null) { // сообщения нет в списке
// pizdets
} else {
val newConversation = screenState.value.conversation.copy(
val newConvo = screenState.value.convo.copy(
outReadCmId = event.cmId
)
screenState.setValue { old ->
old.copy(conversation = newConversation)
old.copy(convo = newConvo)
}
syncUiMessages()
@@ -673,7 +674,7 @@ class MessagesHistoryViewModelImpl(
}
private fun handleMessageDeleted(event: LongPollParsedEvent.MessageDeleted) {
if (event.peerId != screenState.value.conversationId) return
if (event.peerId != screenState.value.convoId) return
val newMessages = messages.value.toMutableList()
val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId }
@@ -688,7 +689,7 @@ class MessagesHistoryViewModelImpl(
}
private fun handleMessageRestored(event: LongPollParsedEvent.MessageRestored) {
if (event.message.peerId != screenState.value.conversationId) return
if (event.message.peerId != screenState.value.convoId) return
val newMessages = messages.value.toMutableList()
val minDate = newMessages.minOf(VkMessage::date)
@@ -704,7 +705,7 @@ class MessagesHistoryViewModelImpl(
}
private fun handleMessageMarkedAsImportant(event: LongPollParsedEvent.MessageMarkedAsImportant) {
if (event.peerId != screenState.value.conversationId) return
if (event.peerId != screenState.value.convoId) return
val newMessages = messages.value.toMutableList()
val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId }
@@ -720,7 +721,7 @@ class MessagesHistoryViewModelImpl(
}
private fun handleMessageMarkedAsSpam(event: LongPollParsedEvent.MessageMarkedAsSpam) {
if (event.peerId != screenState.value.conversationId) return
if (event.peerId != screenState.value.convoId) return
val newMessages = messages.value.toMutableList()
val index = newMessages.indexOfFirstOrNull { it.cmId == event.cmId }
@@ -735,7 +736,7 @@ class MessagesHistoryViewModelImpl(
}
private fun handleMessageMarkedAsNotSpam(event: LongPollParsedEvent.MessageMarkedAsNotSpam) {
if (event.message.peerId != screenState.value.conversationId) return
if (event.message.peerId != screenState.value.convoId) return
val newMessages = messages.value.toMutableList()
val maxDate = newMessages.maxOf(VkMessage::date)
@@ -748,33 +749,33 @@ class MessagesHistoryViewModelImpl(
syncUiMessages()
}
private fun loadConversation() {
Log.d("MessagesHistoryViewModelImpl", "loadConversation()")
private fun loadConvo() {
Log.d("MessagesHistoryViewModelImpl", "loadConvo()")
loadConversationsByIdUseCase(
peerIds = listOf(screenState.value.conversationId),
loadConvosByIdUseCase(
peerIds = listOf(screenState.value.convoId),
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(
val convo = response.firstOrNull() ?: return@listenValue
val title = convo.extractTitle(
useContactName = AppSettings.General.useContactNames,
resources = resourceProvider.resources
)
val avatar = conversation.extractAvatar()
val avatar = convo.extractAvatar()
screenState.setValue { old ->
old.copy(
conversation = conversation,
convo = convo,
title = title,
avatar = avatar
)
}
conversation.pinnedMessage?.let(::handlePinnedMessage)
convo.pinnedMessage?.let(::handlePinnedMessage)
}
)
}
@@ -785,7 +786,7 @@ class MessagesHistoryViewModelImpl(
screenState.setValue { old ->
old.copy(
pinnedMessage = null,
conversation = old.conversation.copy(
convo = old.convo.copy(
pinnedMessage = null,
pinnedMessageId = null
),
@@ -807,7 +808,7 @@ class MessagesHistoryViewModelImpl(
screenState.setValue { old ->
old.copy(
pinnedMessage = pinnedMessage,
conversation = old.conversation.copy(
convo = old.convo.copy(
pinnedMessage = pinnedMessage,
pinnedMessageId = pinnedMessage.id
),
@@ -821,7 +822,7 @@ class MessagesHistoryViewModelImpl(
Log.d("MessagesHistoryViewModel", "loadMessagesHistory: $offset")
messagesUseCase.getMessagesHistory(
conversationId = screenState.value.conversationId,
convoId = screenState.value.convoId,
count = MESSAGES_LOAD_COUNT,
offset = offset,
).listenValue(viewModelScope) { state ->
@@ -835,14 +836,14 @@ class MessagesHistoryViewModelImpl(
this.messages.value.plus(messages)
}.sorted()
val conversations = response.conversations
val convos = response.convos
imagesToPreload.setValue {
messages.mapNotNull { it.extractAvatar().extractUrl() }
}
messagesUseCase.storeMessages(messages)
conversationsUseCase.storeConversations(conversations)
convoUseCase.storeConvos(convos)
val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT
@@ -925,14 +926,14 @@ class MessagesHistoryViewModelImpl(
cmId = -1L - sendingMessages.size,
text = lastMessageText,
isOut = true,
peerId = screenState.value.conversationId,
peerId = screenState.value.convoId,
fromId = UserConfig.userId,
date = (System.currentTimeMillis() / 1000).toInt(),
randomId = Random.nextInt().toLong(),
action = null,
actionMemberId = null,
actionText = null,
actionConversationMessageId = null,
actionCmId = null,
actionMessage = null,
updateTime = null,
isImportant = false,
@@ -972,7 +973,7 @@ class MessagesHistoryViewModelImpl(
val forward = when {
replyCmId != null -> {
buildJsonObject {
put("peer_id", screenState.value.conversationId)
put("peer_id", screenState.value.convoId)
put("conversation_message_ids", buildJsonArray { add(replyCmId) })
put("is_reply", true)
}.toString()
@@ -982,7 +983,7 @@ class MessagesHistoryViewModelImpl(
}
messagesUseCase.sendMessage(
peerId = screenState.value.conversationId,
peerId = screenState.value.convoId,
randomId = newMessage.randomId,
message = newMessage.text,
forward = forward,
@@ -1019,7 +1020,7 @@ class MessagesHistoryViewModelImpl(
important: Boolean,
) {
messagesUseCase.markAsImportant(
peerId = screenState.value.conversationId,
peerId = screenState.value.convoId,
messageIds = messageIds,
important = important
).listenValue(viewModelScope) { state ->
@@ -1049,7 +1050,7 @@ class MessagesHistoryViewModelImpl(
onSuccess: () -> Unit = {}
) {
messagesUseCase.delete(
peerId = screenState.value.conversationId,
peerId = screenState.value.convoId,
messageIds = messageIds,
spam = spam,
deleteForAll = deleteForAll
@@ -1070,7 +1071,7 @@ class MessagesHistoryViewModelImpl(
private fun pinMessage(messageId: Long) {
messagesUseCase.pin(
peerId = screenState.value.conversationId,
peerId = screenState.value.convoId,
messageId = messageId,
cmId = null
).listenValue(viewModelScope) { state ->
@@ -1095,7 +1096,7 @@ class MessagesHistoryViewModelImpl(
}
private fun unpinMessage(messageId: Long) {
messagesUseCase.unpin(screenState.value.conversationId)
messagesUseCase.unpin(screenState.value.convoId)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
@@ -1142,24 +1143,24 @@ class MessagesHistoryViewModelImpl(
private fun readMessage(message: VkMessage) {
messagesUseCase.markAsRead(
peerId = screenState.value.conversationId,
peerId = screenState.value.convoId,
startMessageId = message.id
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = {
val oldConversation = screenState.value.conversation
val newConversation = oldConversation.copy(
val oldConvo = screenState.value.convo
val newConvo = oldConvo.copy(
inRead =
if (!message.isOut) message.id
else oldConversation.inRead,
else oldConvo.inRead,
outRead =
if (message.isOut) message.id
else oldConversation.outRead
else oldConvo.outRead
)
screenState.setValue { old ->
old.copy(conversation = newConversation)
old.copy(convo = newConvo)
}
syncUiMessages()
@@ -1224,7 +1225,7 @@ class MessagesHistoryViewModelImpl(
}
}
private fun syncUiMessages(): List<UiItem> {
private fun syncUiMessages(): List<MessageUiItem> {
val messages = messages.value
val selectedMessages = selectedMessages.value
@@ -1235,7 +1236,7 @@ class MessagesHistoryViewModelImpl(
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages,
conversation = screenState.value.conversation,
convo = screenState.value.convo,
isSelected = selectedMessages.indexOfFirstOrNull { it.id == message.id } != null
)
}
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class MessagesHistoryArguments(val conversationId: Long) : Parcelable
data class MessagesHistoryArguments(val convoId: Long) : Parcelable
@@ -5,12 +5,12 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
@Immutable
data class MessagesHistoryScreenState(
val conversationId: Long,
val convoId: Long,
val title: String,
val status: String?,
val avatar: UiImage,
@@ -21,17 +21,17 @@ data class MessagesHistoryScreenState(
val isPaginationExhausted: Boolean,
val actionMode: ActionMode,
val chatImageUrl: String?,
val conversation: VkConversation,
val convo: VkConvo,
val pinnedMessage: VkMessage?,
val pinnedTitle: String?,
val pinnedSummary: AnnotatedString?,
val replyTitle: String?,
val replyText: String?
val replyText: AnnotatedString?
) {
companion object {
val EMPTY: MessagesHistoryScreenState = MessagesHistoryScreenState(
conversationId = -1,
convoId = -1,
title = "",
status = null,
avatar = UiImage.Color(0),
@@ -42,7 +42,7 @@ data class MessagesHistoryScreenState(
isPaginationExhausted = false,
actionMode = ActionMode.RECORD_AUDIO,
chatImageUrl = null,
conversation = VkConversation.EMPTY,
convo = VkConvo.EMPTY,
pinnedMessage = null,
pinnedTitle = null,
pinnedSummary = null,
@@ -1,5 +1,3 @@
package dev.meloda.fast.messageshistory.model
enum class SendingStatus {
SENDING, SENT, FAILED
}
@@ -1,48 +0,0 @@
package dev.meloda.fast.messageshistory.model
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.util.ImmutableList
sealed class UiItem(
open val id: Long,
open val cmId: Long
) {
@Stable
data class Message(
override val id: Long,
override val cmId: Long,
val text: AnnotatedString?,
val isOut: Boolean,
val fromId: Long,
val date: String,
val randomId: Long,
val isInChat: Boolean,
val name: String,
val showDate: Boolean,
val showAvatar: Boolean,
val showName: Boolean,
val avatar: UiImage,
val isEdited: Boolean,
val isRead: Boolean,
val sendingStatus: SendingStatus,
val isSelected: Boolean,
val isPinned: Boolean,
val isImportant: Boolean,
val attachments: ImmutableList<VkAttachment>?,
val replyCmId: Long?,
val replyTitle: String?,
val replySummary: String?
) : UiItem(id, cmId)
@Stable
data class ActionMessage(
override val id: Long,
override val cmId: Long,
val text: AnnotatedString,
val actionCmId: Long?
) : UiItem(id, cmId)
}
@@ -5,7 +5,6 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.messageshistory.model.MessagesHistoryArguments
import dev.meloda.fast.messageshistory.presentation.MessagesHistoryRoute
import dev.meloda.fast.model.BaseError
@@ -41,6 +40,6 @@ fun NavGraphBuilder.messagesHistoryScreen(
}
}
fun NavController.navigateToMessagesHistory(conversationId: Long) {
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
fun NavController.navigateToMessagesHistory(convoId: Long) {
this.navigate(MessagesHistory(MessagesHistoryArguments(convoId)))
}
@@ -16,11 +16,11 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.ui.model.vk.MessageUiItem
@Composable
fun ActionMessageItem(
item: UiItem.ActionMessage,
item: MessageUiItem.ActionMessage,
modifier: Modifier = Modifier,
onClick: () -> Unit = {}
) {
@@ -56,7 +56,7 @@ fun ActionMessageItemPreview() {
.padding(10.dp)
) {
ActionMessageItem(
item = UiItem.ActionMessage(
item = MessageUiItem.ActionMessage(
id = 0,
text = buildAnnotatedString {
append("You pinned message \"wow hello there\"")
@@ -17,8 +17,8 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.SendingStatus
import dev.meloda.fast.ui.theme.LocalThemeConfig
@Composable
@@ -51,6 +51,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -62,6 +63,7 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FastTextField
@@ -78,7 +80,7 @@ fun InputBar(
showAttachmentButton: Boolean,
actionMode: ActionMode,
replyTitle: String?,
replyText: String?,
replyText: AnnotatedString?,
inputFieldFocusRequester: Boolean,
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onBoldRequested: () -> Unit = {},
@@ -136,7 +138,7 @@ fun InputBar(
ReplyContainer(
modifier = Modifier.padding(horizontal = 8.dp),
title = replyTitle.orEmpty(),
text = replyText.orEmpty(),
text = replyText,
onCloseClicked = onReplyCloseClicked,
)
}
@@ -357,7 +359,7 @@ private fun InputBarPreview() {
showAttachmentButton = true,
actionMode = ActionMode.SEND,
replyTitle = "Иннокентий Панфилович",
replyText = "Ого, ром!",
replyText = "Ого, ром!".annotated(),
inputFieldFocusRequester = false
)
}
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
@@ -33,15 +32,20 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.messageshistory.presentation.attachments.Attachments
import dev.meloda.fast.messageshistory.presentation.attachments.Reply
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkStickerDomain
import dev.meloda.fast.model.api.domain.VkVideoMessageDomain
import dev.meloda.fast.ui.model.vk.SendingStatus
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.darken
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isDark
import dev.meloda.fast.ui.util.lighten
@Composable
fun MessageBubble(
@@ -57,7 +61,7 @@ fun MessageBubble(
isSelected: Boolean,
attachments: ImmutableList<VkAttachment>?,
replyTitle: String?,
replySummary: String? = null,
replySummary: AnnotatedString? = null,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {},
@@ -260,10 +264,18 @@ private data class MessageBubbleColors(
@Composable
private fun messageBubbleColors(isOut: Boolean): MessageBubbleColors {
return if (isOut) {
val containerColor = MaterialTheme.colorScheme.primaryContainer
val replyContainerColor = if (containerColor.isDark()) {
containerColor.lighten(0.15f)
} else {
containerColor.darken(0.075f)
}
MessageBubbleColors(
container = MaterialTheme.colorScheme.primaryContainer,
container = containerColor,
content = MaterialTheme.colorScheme.onPrimaryContainer,
replyContainer = MaterialTheme.colorScheme.inversePrimary
replyContainer = replyContainerColor
)
} else {
MessageBubbleColors(
@@ -277,41 +289,46 @@ private fun messageBubbleColors(isOut: Boolean): MessageBubbleColors {
@Preview
@Composable
private fun Bubble() {
Column {
MessageBubble(
modifier = Modifier,
text = AnnotatedString("Some cool text"),
isOut = true,
date = "19:01",
isEdited = true,
isRead = true,
sendingStatus = SendingStatus.SENT,
isPinned = true,
isImportant = true,
isSelected = false,
attachments = emptyImmutableList(),
replyTitle = "Danil Nikolaev",
replySummary = "2 photos",
onClick = {},
onLongClick = {},
)
AppTheme(
useDarkTheme = true,
useDynamicColors = true
) {
Column {
MessageBubble(
modifier = Modifier,
text = AnnotatedString("Some cool text"),
isOut = true,
date = "19:01",
isEdited = true,
isRead = true,
sendingStatus = SendingStatus.SENT,
isPinned = true,
isImportant = true,
isSelected = false,
attachments = emptyImmutableList(),
replyTitle = "Danil Nikolaev",
replySummary = "2 photos".annotated(),
onClick = {},
onLongClick = {},
)
MessageBubble(
modifier = Modifier,
text = AnnotatedString("Some cool text"),
isOut = false,
date = "19:01",
isEdited = true,
isRead = true,
sendingStatus = SendingStatus.SENT,
isPinned = true,
isImportant = true,
isSelected = false,
attachments = emptyImmutableList(),
replyTitle = "Danil Nikolaev",
replySummary = "2 photos",
onClick = {},
onLongClick = {},
)
MessageBubble(
modifier = Modifier,
text = AnnotatedString("Some cool text"),
isOut = false,
date = "19:01",
isEdited = true,
isRead = true,
sendingStatus = SendingStatus.SENT,
isPinned = true,
isImportant = true,
isSelected = false,
attachments = emptyImmutableList(),
replyTitle = "Danil Nikolaev",
replySummary = "2 photos".annotated(),
onClick = {},
onLongClick = {},
)
}
}
}
@@ -37,16 +37,16 @@ import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import com.conena.nanokt.android.content.dpInPx
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.MessageUiItem
import kotlin.math.roundToInt
@Composable
fun IncomingMessageBubble(
enableAnimations: Boolean,
modifier: Modifier = Modifier,
message: UiItem.Message,
message: MessageUiItem.Message,
offsetX: Float = 0f,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {},
@@ -18,17 +18,16 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.conena.nanokt.android.content.dpInPx
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.model.vk.MessageUiItem
import kotlin.math.roundToInt
@Composable
fun OutgoingMessageBubble(
modifier: Modifier = Modifier,
enableAnimations: Boolean,
message: UiItem.Message,
message: MessageUiItem.Message,
offsetX: Float = 0f,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {},
@@ -123,11 +123,11 @@ fun MessageOptionsDialog(
options += MessageOption.ForwardHere
options += MessageOption.Forward
if (message.isPeerChat() && screenState.conversation.canChangePin) {
if (message.isPeerChat() && screenState.convo.canChangePin) {
options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin
}
if (!message.isOut && !message.isRead(screenState.conversation)) {
if (!message.isOut && !message.isRead(screenState.convo)) {
options += MessageOption.Read
}
@@ -16,7 +16,7 @@ import org.koin.androidx.compose.koinViewModel
fun MessagesHistoryRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit,
onNavigateToChatMaterials: (peerId: Long, cmId: Long) -> Unit,
onNavigateToPhotoViewer: (images: List<String>, index: Int) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) {
@@ -39,14 +39,14 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.util.indexOfMessageByCmId
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.UiItem
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.R
import dev.meloda.fast.ui.components.Loader
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.vk.MessageUiItem
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@@ -61,7 +61,7 @@ import kotlinx.coroutines.launch
fun MessagesHistoryScreen(
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
messages: ImmutableList<VkMessage> = emptyImmutableList(),
uiMessages: ImmutableList<UiItem> = emptyImmutableList(),
uiMessages: ImmutableList<MessageUiItem> = emptyImmutableList(),
isSelectedAtLeastOne: Boolean = false,
scrollIndex: Int? = null,
selectedMessages: ImmutableList<VkMessage> = emptyImmutableList(),
@@ -183,7 +183,7 @@ fun MessagesHistoryScreen(
topBarContainerColorAlpha = topBarContainerColorAlpha,
isClickable = !(screenState.isLoading && messages.isEmpty()),
isMessagesSelecting = selectedMessages.isNotEmpty(),
isPeerAccount = screenState.conversationId == UserConfig.userId,
isPeerAccount = screenState.convoId == UserConfig.userId,
avatar = screenState.avatar,
title = topBarTitle,
showHorizontalProgressBar = screenState.isLoading && messages.isNotEmpty(),
@@ -191,7 +191,7 @@ fun MessagesHistoryScreen(
pinnedMessage = pinnedMessage,
pinnedTitle = screenState.pinnedTitle,
pinnedSummary = screenState.pinnedSummary,
showUnpinButton = screenState.conversation.canChangePin,
showUnpinButton = screenState.convo.canChangePin,
onTopBarClicked = onTopBarClicked,
onBack = onBack,
onClose = onClose,
@@ -44,11 +44,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkFileDomain
import dev.meloda.fast.model.api.domain.VkLinkDomain
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.ui.model.vk.MessageUiItem
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -61,7 +61,7 @@ fun MessagesList(
hasPinnedMessage: Boolean,
hazeState: HazeState,
listState: LazyListState,
uiMessages: ImmutableList<UiItem>,
uiMessages: ImmutableList<MessageUiItem>,
isSelectedAtLeastOne: Boolean,
isPaginating: Boolean,
isReplying: Boolean,
@@ -78,7 +78,7 @@ fun MessagesList(
val scope = rememberCoroutineScope()
val onAttachmentClick by rememberUpdatedState { message: UiItem.Message, attachment: VkAttachment ->
val onAttachmentClick by rememberUpdatedState { message: MessageUiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageClicked(message.id)
} else {
@@ -117,7 +117,7 @@ fun MessagesList(
}
}
val onAttachmentLongClick by rememberUpdatedState { message: UiItem.Message, attachment: VkAttachment ->
val onAttachmentLongClick by rememberUpdatedState { message: MessageUiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageLongClicked(message.id)
uiMessages
@@ -158,16 +158,16 @@ fun MessagesList(
items(
items = uiMessages.values,
key = UiItem::id,
key = MessageUiItem::id,
contentType = { item ->
when (item) {
is UiItem.ActionMessage -> "action_message"
is UiItem.Message -> "message"
is MessageUiItem.ActionMessage -> "action_message"
is MessageUiItem.Message -> "message"
}
}
) { item ->
when (item) {
is UiItem.ActionMessage -> {
is MessageUiItem.ActionMessage -> {
ActionMessageItem(
modifier = Modifier.then(
if (theme.enableAnimations) Modifier.animateItem(
@@ -178,13 +178,13 @@ fun MessagesList(
item = item,
onClick = {
if (item.actionCmId != null) {
onRequestScrollToCmId(item.actionCmId)
onRequestScrollToCmId(item.actionCmId!!)
}
}
)
}
is UiItem.Message -> {
is MessageUiItem.Message -> {
val backgroundColor by animateColorAsState(
targetValue = if (item.isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
@@ -275,7 +275,7 @@ fun MessagesList(
},
onReplyClick = {
if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId)
onRequestScrollToCmId(item.replyCmId!!)
}
},
offsetX = offsetX.value
@@ -302,7 +302,7 @@ fun MessagesList(
},
onReplyClick = {
if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId)
onRequestScrollToCmId(item.replyCmId!!)
}
},
offsetX = offsetX.value
@@ -22,16 +22,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.domain.util.orEmpty
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.RippledClickContainer
@Composable
fun ReplyContainer(
title: String,
text: String?,
text: AnnotatedString?,
modifier: Modifier = Modifier,
onCloseClicked: () -> Unit = {},
backgroundColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
@@ -103,7 +106,7 @@ private fun ReplyContainerPreview() {
ReplyContainer(
onCloseClicked = {},
title = "В ответ Ишак",
text = "Приветствую тебя, Ишак!",
text = "Приветствую тебя, Ишак!".annotated(),
)
}
}
@@ -22,10 +22,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.domain.util.orEmpty
@Composable
fun Reply(
@@ -35,7 +38,7 @@ fun Reply(
backgroundColor: Color,
innerBackgroundColor: Color,
title: String,
summary: String?,
summary: AnnotatedString?,
modifier: Modifier = Modifier
) {
Box(
@@ -105,7 +108,7 @@ private fun ReplyBasePreview(
),
onClick = {},
title = "Danil Nikolaev",
summary = "2 photos",
summary = "2 photos".annotated(),
backgroundColor = backgroundColor,
innerBackgroundColor = innerBackgroundColor,
bottomPadding = 0.dp
@@ -1,20 +0,0 @@
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: Long): Int =
indexOfFirst { it.id == messageId }
fun List<UiItem>.findMessageById(messageId: Long): UiItem.Message? =
firstOrNull { it.id == messageId } as UiItem.Message?
fun List<UiItem>.indexOfMessageByCmId(cmId: Long): Int? =
indexOfFirstOrNull { it.cmId == cmId }
fun List<UiItem>.findMessageByCmId(cmId: Long): UiItem.Message =
first { it.cmId == cmId } as UiItem.Message
@@ -1,721 +0,0 @@
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
import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.data.UserConfig
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
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import java.text.SimpleDateFormat
import java.util.Locale
private fun isAccount(fromId: Long) = fromId == UserConfig.userId
fun VkMessage.extractAvatar() = when {
isUser() -> {
if (isAccount(id)) null
else user?.photo200
}
isGroup() -> {
group?.photo200
}
else -> null
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_cut)
fun VkMessage.extractDate(): String =
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date * 1000L)
fun VkMessage.extractTitle(): String = when {
isUser() -> "%s %s".format(
user?.firstName.orDots(),
user?.lastName?.firstOrNull()?.toString().orEmpty().plus(".")
)
isGroup() -> group?.name.orDots()
else -> throw IllegalStateException("Message is not from user nor group. fromId: $fromId")
}
fun VkMessage.extractReplyTitle(): String? = replyMessage?.extractTitle()
// TODO: 24-Jun-25, Danil Nikolaev: improve
fun VkMessage.extractReplySummary(): String? = when (val message = replyMessage) {
null -> null
else -> {
when {
message.text != null -> message.text
else -> null
}
}
}
fun VkConversation.extractAvatar(): UiImage = when (peerType) {
PeerType.USER -> {
if (isAccount(id)) null
else user?.photo200
}
PeerType.GROUP -> {
group?.photo200
}
PeerType.CHAT -> {
photo200
}
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_cut)
fun VkConversation.extractTitle(
useContactName: Boolean,
resources: Resources
) = when (peerType) {
PeerType.USER -> {
if (isAccount(id)) {
UiText.Resource(R.string.favorites)
} else {
val userName = user?.let { user ->
if (useContactName) {
VkMemoryCache.getContact(user.id)?.name
} else {
user.fullName
}
}
UiText.Simple(userName.orDots())
}
}
PeerType.GROUP -> UiText.Simple(group?.name.orDots())
PeerType.CHAT -> UiText.Simple(title.orDots())
}.parseString(resources).orDots()
fun VkMessage.asPresentation(
conversation: VkConversation,
resourceProvider: ResourceProvider,
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean,
isSelected: Boolean
): UiItem = when {
action != null -> UiItem.ActionMessage(
id = id,
cmId = cmId,
text = extractActionText(
resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
showTime = showTimeInActionMessages
) ?: buildAnnotatedString { },
actionCmId = actionConversationMessageId
)
else -> UiItem.Message(
id = id,
cmId = cmId,
text = extractTextWithVisualizedMentions(
isOut = isOut,
originalText = text,
formatData = formatData
),
isOut = isOut,
fromId = fromId,
date = extractDate(),
randomId = randomId,
isInChat = isPeerChat(),
name = extractTitle(),
showDate = true,
showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(),
isEdited = updateTime != null,
isRead = isRead(conversation),
sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
},
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant,
attachments = attachments?.ifEmpty { null }?.toImmutableList(),
replyCmId = replyMessage?.cmId,
replyTitle = extractReplyTitle(),
replySummary = extractReplySummary()
)
}
fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean {
if (isOut) return false
return nextMessage == null || nextMessage.fromId != fromId
}
fun VkMessage.extractShowName(prevMessage: VkMessage?): Boolean {
if (isOut || !isPeerChat()) return false
return prevMessage == null || prevMessage.fromId != fromId
}
fun VkMessage.extractActionText(
resources: Resources,
youPrefix: String,
showTime: Boolean
): AnnotatedString? {
val lastMessage = this
val action = lastMessage.action ?: return null
val formattedMessageDate =
SimpleDateFormat("HH:mm", Locale.getDefault()).format(lastMessage.date * 1000L)
val fromId = lastMessage.fromId
val text = lastMessage.actionText.orDots()
val groupName = lastMessage.group?.name.orDots()
val userName = lastMessage.user?.fullName.orDots()
val actionGroupName = lastMessage.actionGroup?.name.orDots()
val actionUserName = lastMessage.actionUser?.fullName.orDots()
val memberId = lastMessage.actionMemberId
val isMemberUser = (memberId ?: 0) > 0
val isMemberGroup = (memberId ?: 0) < 0
val prefix = when {
lastMessage.fromId == UserConfig.userId -> youPrefix
lastMessage.isGroup() -> groupName
lastMessage.isUser() -> userName
else -> null
}.orDots()
val memberPrefix = when {
memberId == UserConfig.userId -> youPrefix
isMemberUser -> actionUserName
isMemberGroup -> actionGroupName
else -> null
}.orDots()
return buildAnnotatedString {
when (action) {
VkMessage.Action.CHAT_CREATE -> {
val string = UiText.ResourceParams(
R.string.message_action_chat_created,
listOf(prefix, text)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
val textStartIndex = string.indexOf(text)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = textStartIndex,
end = textStartIndex + text.length
)
}
VkMessage.Action.CHAT_TITLE_UPDATE -> {
val string = UiText.ResourceParams(
R.string.message_action_chat_renamed,
listOf(prefix, text)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
val textStartIndex = string.indexOf(text)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = textStartIndex,
end = textStartIndex + text.length
)
}
VkMessage.Action.CHAT_PHOTO_UPDATE -> {
UiText.ResourceParams(
R.string.message_action_chat_photo_update,
listOf(prefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_PHOTO_REMOVE -> {
UiText.ResourceParams(
R.string.message_action_chat_photo_remove,
listOf(prefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_KICK_USER -> {
if (memberId == fromId) {
UiText.ResourceParams(
R.string.message_action_chat_user_left,
listOf(memberPrefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = memberPrefix.length
)
} else {
val postfix =
if (memberId == UserConfig.userId) youPrefix.lowercase()
else lastMessage.actionUser.toString()
val string = UiText.ResourceParams(
R.string.message_action_chat_user_kicked,
listOf(prefix, postfix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
val postfixStartIndex = string.indexOf(postfix)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = postfixStartIndex,
end = postfixStartIndex + postfix.length
)
}
}
VkMessage.Action.CHAT_INVITE_USER -> {
if (memberId == lastMessage.fromId) {
UiText.ResourceParams(
R.string.message_action_chat_user_returned,
listOf(memberPrefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = memberPrefix.length
)
} else {
val postfix =
if (memberId == UserConfig.userId) youPrefix.lowercase()
else lastMessage.actionUser.toString()
val string = UiText.ResourceParams(
R.string.message_action_chat_user_invited,
listOf(memberPrefix, postfix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
val postfixStartIndex = string.indexOf(postfix)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = postfixStartIndex,
end = postfixStartIndex + postfix.length
)
}
}
VkMessage.Action.CHAT_INVITE_USER_BY_LINK -> {
UiText.ResourceParams(
R.string.message_action_chat_user_joined_by_link,
listOf(prefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> {
UiText.ResourceParams(
R.string.message_action_chat_user_joined_by_call,
listOf(prefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> {
UiText.ResourceParams(
R.string.message_action_chat_user_joined_by_call_link,
listOf(prefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_PIN_MESSAGE -> {
// TODO: 16/07/2024, Danil Nikolaev: get pinned message by cmid
// val messageText = lastMessage.text.orEmpty().trim()
// val croppedMessage = messageText.take(40)
// val hasMessageText = messageText.isNotEmpty()
UiText.ResourceParams(
R.string.message_action_chat_pin_message,
listOf(prefix)
).parseString(resources)
.orEmpty()
// .let { text ->
// if (hasMessageText) {
// text.plus("«%s»".format(croppedMessage))
// .plus(if (messageText.length > 40) "..." else "")
// } else text
// }
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
// if (hasMessageText) {
// val croppedIndex = fullText.indexOf(croppedMessage)
//
// addStyle(
// style = SpanStyle(fontWeight = FontWeight.Medium),
// start = croppedIndex - 1,
// end = croppedIndex - 1 + croppedMessage.length + 1
// )
// }
}
VkMessage.Action.CHAT_UNPIN_MESSAGE -> {
UiText.ResourceParams(
R.string.message_action_chat_unpin_message,
listOf(prefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_SCREENSHOT -> {
UiText.ResourceParams(
R.string.message_action_chat_screenshot,
listOf(prefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_STYLE_UPDATE -> {
UiText.ResourceParams(
R.string.message_action_chat_style_update,
listOf(prefix)
).parseString(resources)
.orEmpty()
.let { text ->
if (showTime) {
text.plus("\n")
.plus(formattedMessageDate)
} else text
}.also(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.Medium),
start = 0,
end = prefix.length
)
}
}
}
}
// 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 += if (isOut) {
AnnotatedString.Range(
item = SpanStyle(textDecoration = TextDecoration.Underline),
start = startIndex,
end = endIndex
)
} else {
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)
}