* 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
View File
@@ -8,6 +8,7 @@ android {
}
dependencies {
api(projects.core.common)
api(projects.core.data)
api(projects.core.model)
@@ -15,4 +16,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core)
implementation(libs.eithernet)
implementation(libs.bundles.nanokt)
implementation(libs.compose.ui)
}
@@ -1,25 +1,25 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.flow.Flow
interface ConversationsUseCase : BaseUseCase {
interface ConvoUseCase : BaseUseCase {
suspend fun storeConversations(conversations: List<VkConversation>)
suspend fun storeConvos(convos: List<VkConvo>)
fun getConversations(
fun getConvos(
count: Int? = null,
offset: Int? = null,
filter: ConversationsFilter
): Flow<State<List<VkConversation>>>
filter: ConvosFilter
): Flow<State<List<VkConvo>>>
fun getById(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): Flow<State<List<VkConversation>>>
): Flow<State<List<VkConvo>>>
fun delete(peerId: Long): Flow<State<Long>>
@@ -1,30 +1,30 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
class ConversationsUseCaseImpl(
private val repository: ConversationsRepository,
) : ConversationsUseCase {
class ConvoUseCaseImpl(
private val repository: ConvosRepository,
) : ConvoUseCase {
override suspend fun storeConversations(
conversations: List<VkConversation>
override suspend fun storeConvos(
convos: List<VkConvo>
) = withContext(Dispatchers.IO) {
repository.storeConversations(conversations)
repository.storeConvos(convos)
}
override fun getConversations(
override fun getConvos(
count: Int?,
offset: Int?,
filter: ConversationsFilter
): Flow<State<List<VkConversation>>> = flowNewState {
repository.getConversations(
filter: ConvosFilter
): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConvos(
count = count,
offset = offset,
filter = filter
@@ -35,8 +35,8 @@ class ConversationsUseCaseImpl(
peerIds: List<Long>,
extended: Boolean?,
fields: String?
): Flow<State<List<VkConversation>>> = flowNewState {
repository.getConversationsById(
): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConvosById(
peerIds = peerIds,
extended = extended,
fields = fields
@@ -1,22 +1,22 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.flow.Flow
class LoadConversationsByIdUseCase(
private val conversationsRepository: ConversationsRepository
class LoadConvosByIdUseCase(
private val convosRepository: ConvosRepository
) : BaseUseCase {
operator fun invoke(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): Flow<State<List<VkConversation>>> = flowNewState {
conversationsRepository
.getConversationsById(
): Flow<State<List<VkConvo>>> = flowNewState {
convosRepository
.getConvosById(
peerIds = peerIds,
extended = extended,
fields = fields,
@@ -9,12 +9,12 @@ import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConversationFlags
import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -28,7 +28,7 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser(
private val conversationsUseCase: ConversationsUseCase,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase
) {
private val job = SupervisorJob()
@@ -271,9 +271,9 @@ class LongPollUpdatesParser(
val message =
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val conversation =
val convo =
async {
loadConversation(
loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
@@ -287,7 +287,7 @@ class LongPollUpdatesParser(
.onEvent(
LongPollParsedEvent.NewMessage(
message = message,
inArchive = conversation?.isArchived == true
inArchive = convo?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with
// enabled notifications from archive
@@ -368,13 +368,13 @@ class LongPollUpdatesParser(
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConversationFlags.parse(flags)
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
@@ -382,11 +382,11 @@ class LongPollUpdatesParser(
val message = loadMessage(
peerId = peerId,
cmId = conversation.lastCmId
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
conversation = conversation.copy(lastMessage = message),
convo = convo.copy(lastMessage = message),
archived = false
)
eventsToSend += eventToSend
@@ -423,13 +423,13 @@ class LongPollUpdatesParser(
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConversationFlags.parse(flags)
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
@@ -437,11 +437,11 @@ class LongPollUpdatesParser(
val message = loadMessage(
peerId = peerId,
cmId = conversation.lastCmId
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
conversation = conversation.copy(lastMessage = message),
convo = convo.copy(lastMessage = message),
archived = true
)
eventsToSend += eventToSend
@@ -673,29 +673,29 @@ class LongPollUpdatesParser(
}
}
private suspend fun loadConversation(
private suspend fun loadConvo(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConversation? = suspendCoroutine { continuation ->
): VkConvo? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
conversationsUseCase.getById(
convoUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
Log.e("LongPollUpdatesParser", "loadConversation: error: $error")
Log.e("LongPollUpdatesParser", "loadConvo: error: $error")
continuation.resume(null)
},
success = { response ->
val conversation = response.singleOrNull() ?: run {
val convo = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(conversation)
continuation.resume(convo)
}
)
}
@@ -14,7 +14,7 @@ interface MessagesUseCase : BaseUseCase {
suspend fun storeMessages(messages: List<VkMessage>)
fun getMessagesHistory(
conversationId: Long,
convoId: Long,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryInfo>>
@@ -23,12 +23,12 @@ class MessagesUseCaseImpl(
}
override fun getMessagesHistory(
conversationId: Long,
convoId: Long,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryInfo>> = flowNewState {
repository.getHistory(
conversationId = conversationId,
convoId = convoId,
offset = offset,
count = count
).mapToState()
@@ -7,7 +7,7 @@ import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.StoreUsersUseCase
@@ -27,7 +27,7 @@ val domainModule = module {
singleOf(::AccountUseCaseImpl) bind AccountUseCase::class
singleOf(::GetCurrentAccountUseCase)
singleOf(::LoadConversationsByIdUseCase)
singleOf(::LoadConvosByIdUseCase)
singleOf(::GetMessageReadPeersUseCase)
}
@@ -0,0 +1,772 @@
package dev.meloda.fast.domain.util
import android.content.res.Resources
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.month
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.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkVideoDomain
import dev.meloda.fast.ui.R
import java.util.Calendar
import java.util.Locale
import kotlin.math.ln
import kotlin.math.pow
fun VkConvo.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 VkConvo.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 extractUnreadCount(
lastMessage: VkMessage?,
convo: VkConvo
): String? = when {
lastMessage?.isOut == false && convo.isInRead() -> null
convo.unreadCount == 0 -> null
convo.unreadCount < 1000 -> convo.unreadCount.toString()
else -> {
val exp = (ln(convo.unreadCount.toDouble()) / ln(1000.0)).toInt()
val suffix = "KMBT"[exp - 1]
val result = convo.unreadCount / 1000.0.pow(exp.toDouble())
if (result.toLong().toDouble() == result) {
String.format(Locale.getDefault(), "%.0f%s", result, suffix)
} else {
String.format(Locale.getDefault(), "%.1f%s", result, suffix)
}
}
}
fun extractMessage(
resources: Resources,
lastMessage: VkMessage?,
peerId: Long,
peerType: PeerType,
showPeer: Boolean = true
): AnnotatedString {
val youPrefix = UiText.Resource(R.string.you_message_prefix)
.parseString(resources)
.orDots()
val actionMessage = extractActionText(
lastMessage = lastMessage,
resources = resources,
youPrefix = youPrefix
)
val attachmentIcon: UiImage? = extractAttachmentIcon(lastMessage)
val attachmentText: AnnotatedString? =
if (attachmentIcon != null) null
else extractAttachmentText(resources, lastMessage)
val forwardsMessage =
if (lastMessage?.text != null) null
else extractForwardsText(resources, lastMessage)
val messageText = lastMessage?.text.orEmpty()
val prefixText: AnnotatedString? = when {
!showPeer -> null
actionMessage != null -> null
lastMessage == null -> null
peerId == UserConfig.userId -> null
!peerType.isChat() && !lastMessage.isOut -> null
lastMessage.isOut -> buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(youPrefix)
}
}
else ->
when {
lastMessage.user?.firstName.orEmpty().isNotEmpty() -> buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(lastMessage.user?.firstName)
}
}
lastMessage.group?.name.orEmpty().isNotEmpty() -> buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(lastMessage.group?.name)
}
}
else -> null
}
}
val prefix = buildAnnotatedString {
if (prefixText != null) {
append(prefixText)
append(": ")
}
}
val finalText = when {
actionMessage != null -> {
prefix + actionMessage
}
forwardsMessage != null -> {
prefix + forwardsMessage
}
attachmentText != null -> {
prefix + attachmentText
}
else ->
messageText
.replace("\n", " ")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("<br>", " ")
.replace("&gt;", ">")
.replace("&lt;", "<")
.replace("<br/>", " ")
.replace("&ndash;", "-")
.trim()
.let { text ->
extractTextWithVisualizedMentions(
isOut = lastMessage?.isOut == true,
originalText = text,
formatData = null
)
}
.let { text -> prefix + text.orEmpty() }
}
return finalText
}
fun extractActionText(
lastMessage: VkMessage?,
resources: Resources,
youPrefix: String
): AnnotatedString? {
if (lastMessage == null) return null
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 (lastMessage.action) {
null -> return null
VkMessage.Action.CHAT_CREATE -> {
val string = UiText.ResourceParams(
R.string.message_action_chat_created,
listOf(prefix, text)
).parseString(resources).orEmpty()
append(string)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
val textStartIndex = string.indexOf(text)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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()
append(string)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
val textStartIndex = string.indexOf(text)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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()
append(string)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
val postfixStartIndex = string.indexOf(postfix)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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()
append(string)
val postfixStartIndex = string.indexOf(postfix)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_PIN_MESSAGE -> {
UiText.ResourceParams(
R.string.message_action_chat_pin_message,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_UNPIN_MESSAGE -> {
UiText.ResourceParams(
R.string.message_action_chat_unpin_message,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
VkMessage.Action.CHAT_SCREENSHOT -> {
UiText.ResourceParams(
R.string.message_action_chat_screenshot,
listOf(prefix)
).parseString(resources).orEmpty().let(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
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(::append)
addStyle(
style = SpanStyle(fontWeight = FontWeight.SemiBold),
start = 0,
end = prefix.length
)
}
}
}
}
private fun extractAttachmentIcon(
lastMessage: VkMessage?
): UiImage? = when {
lastMessage == null -> null
lastMessage.text == null -> null
!lastMessage.forwards.isNullOrEmpty() -> {
if (lastMessage.forwards.orEmpty().size == 1) {
UiImage.Resource(R.drawable.ic_attachment_forwarded_message)
} else {
UiImage.Resource(R.drawable.ic_attachment_forwarded_messages)
}
}
else -> {
lastMessage.attachments?.let { attachments ->
if (attachments.isEmpty()) return null
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
lastMessage.geoType?.let {
return UiImage.Resource(R.drawable.ic_map_marker)
}
getAttachmentIconByType(attachments.first().type)
} else {
UiImage.Resource(R.drawable.ic_baseline_attach_file_24)
}
}
}
}
fun extractAttachmentText(
resources: Resources,
lastMessage: VkMessage?
): AnnotatedString? = when {
lastMessage == null -> null
lastMessage.geoType != null -> {
buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
when (lastMessage.geoType) {
"point" -> {
UiText.Resource(R.string.message_geo_point)
.parseString(resources)
.let(::append)
}
else -> {
UiText.Resource(R.string.message_geo)
.parseString(resources)
.let(::append)
}
}
}
}
}
lastMessage.hasAttachments() -> {
buildAnnotatedString {
val attachments = lastMessage.attachments.orEmpty()
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
if (attachments.size == 1) {
getAttachmentUiText(attachments.first())
.parseString(resources)
.let(::append)
} else {
when {
isAttachmentsHaveOneType(attachments) -> {
getAttachmentUiText(attachments.first(), attachments.size)
.parseString(resources)
.let(::append)
}
attachments.any { it.type == AttachmentType.ARTIST } -> {
getAttachmentUiText(
attachments.first { it.type == AttachmentType.ARTIST }
)
.parseString(resources)
.let(::append)
}
else -> {
UiText.Resource(R.string.message_attachments_many)
.parseString(resources)
.let(::append)
}
}
}
}
}
}
else -> null
}
private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
return when (attachmentType) {
AttachmentType.PHOTO -> R.drawable.ic_attachment_photo
AttachmentType.VIDEO -> R.drawable.ic_attachment_video
AttachmentType.AUDIO -> R.drawable.ic_attachment_audio
AttachmentType.FILE -> R.drawable.ic_attachment_file
AttachmentType.LINK -> R.drawable.ic_attachment_link
AttachmentType.AUDIO_MESSAGE -> R.drawable.ic_attachment_voice
AttachmentType.MINI_APP -> R.drawable.ic_attachment_mini_app
AttachmentType.STICKER -> R.drawable.ic_attachment_sticker
AttachmentType.GIFT -> R.drawable.ic_attachment_gift
AttachmentType.WALL -> R.drawable.ic_attachment_wall
AttachmentType.GRAFFITI -> R.drawable.ic_attachment_graffiti
AttachmentType.POLL -> R.drawable.ic_attachment_poll
AttachmentType.WALL_REPLY -> R.drawable.ic_attachment_wall_reply
AttachmentType.CALL -> R.drawable.ic_attachment_call
AttachmentType.GROUP_CALL_IN_PROGRESS -> R.drawable.ic_attachment_group_call
AttachmentType.STORY -> R.drawable.ic_attachment_story
AttachmentType.UNKNOWN -> null
AttachmentType.CURATOR -> null
AttachmentType.EVENT -> null
AttachmentType.WIDGET -> null
AttachmentType.ARTIST -> null
AttachmentType.AUDIO_PLAYLIST -> null
AttachmentType.PODCAST -> null
AttachmentType.NARRATIVE -> null
AttachmentType.ARTICLE -> null
AttachmentType.VIDEO_MESSAGE -> null
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_attachment_sticker
AttachmentType.STICKER_PACK_PREVIEW -> null
}?.let(UiImage::Resource)
}
private fun isAttachmentsHaveOneType(attachments: List<VkAttachment>): Boolean {
if (attachments.isEmpty()) return true
if (attachments.size == 1) return true
val firstType = attachments.first().type
for (attachment in attachments) {
if (firstType != attachment.type) return false
}
return true
}
fun extractForwardsText(
resources: Resources,
lastMessage: VkMessage?
): AnnotatedString? = when {
lastMessage == null -> null
lastMessage.hasForwards() -> buildAnnotatedString {
val forwards = lastMessage.forwards.orEmpty()
withStyle(style = SpanStyle(fontWeight = FontWeight.SemiBold)) {
append(
UiText.Resource(
if (forwards.size == 1) R.string.forwarded_message
else R.string.forwarded_messages
).parseString(resources)
)
}
}
else -> null
}
fun getAttachmentUiText(
attachment: VkAttachment,
size: Int = 1,
): UiText {
if (attachment.type == AttachmentType.VIDEO &&
(attachment as? VkVideoDomain)?.isShortVideo == true
) {
return UiText.Resource(R.string.message_attachments_clip)
}
if (attachment.type.isMultiple()) {
return when (attachment.type) {
AttachmentType.PHOTO -> R.plurals.attachment_photos
AttachmentType.VIDEO -> R.plurals.attachment_videos
AttachmentType.AUDIO -> R.plurals.attachment_audios
AttachmentType.FILE -> R.plurals.attachment_files
else -> throw IllegalArgumentException("Unknown multiple type: ${attachment.type}")
}.let { resId -> UiText.QuantityResource(resId, size) }
}
return when (attachment.type) {
AttachmentType.UNKNOWN,
AttachmentType.PHOTO,
AttachmentType.VIDEO,
AttachmentType.AUDIO,
AttachmentType.FILE -> {
throw IllegalArgumentException("Unknown multiple type: ${attachment.type}")
}
AttachmentType.LINK -> R.string.message_attachments_link
AttachmentType.AUDIO_MESSAGE -> R.string.message_attachments_audio_message
AttachmentType.MINI_APP -> R.string.message_attachments_mini_app
AttachmentType.STICKER -> R.string.message_attachments_sticker
AttachmentType.GIFT -> R.string.message_attachments_gift
AttachmentType.WALL -> R.string.message_attachments_wall
AttachmentType.GRAFFITI -> R.string.message_attachments_graffiti
AttachmentType.POLL -> R.string.message_attachments_poll
AttachmentType.WALL_REPLY -> R.string.message_attachments_wall_reply
AttachmentType.CALL -> R.string.message_attachments_call
AttachmentType.GROUP_CALL_IN_PROGRESS -> R.string.message_attachments_call_in_progress
AttachmentType.CURATOR -> R.string.message_attachments_curator
AttachmentType.EVENT -> R.string.message_attachments_event
AttachmentType.STORY -> R.string.message_attachments_story
AttachmentType.WIDGET -> R.string.message_attachments_widget
AttachmentType.ARTIST -> R.string.message_attachments_artist
AttachmentType.AUDIO_PLAYLIST -> R.string.message_attachments_audio_playlist
AttachmentType.PODCAST -> R.string.message_attachments_podcast
AttachmentType.NARRATIVE -> R.string.message_attachments_narrative
AttachmentType.ARTICLE -> R.string.message_attachments_article
AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message
AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker
AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview
}.let(UiText::Resource)
}
fun getAttachmentConvoIcon(message: VkMessage?): UiImage? {
return message?.attachments?.let { attachments ->
if (attachments.isEmpty()) return null
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
message.geoType?.let {
return UiImage.Resource(R.drawable.ic_map_marker)
}
getAttachmentIconByType(attachments.first().type)
} else {
UiImage.Resource(R.drawable.ic_baseline_attach_file_24)
}
}
}
fun extractBirthday(convo: VkConvo): Boolean {
val birthday = convo.user?.birthday ?: return false
val splitBirthday = birthday.split(".").mapNotNull(String::toIntOrNull)
if (splitBirthday.isEmpty()) return false
return if (splitBirthday.size > 1) {
val (day, month) = splitBirthday
val birthdayCalendar = Calendar.getInstance().also { calendar ->
calendar.dayOfMonth = day
calendar.month = month - 1
}
val nowCalendar = Calendar.getInstance()
nowCalendar.dayOfMonth == birthdayCalendar.dayOfMonth &&
nowCalendar.month == birthdayCalendar.month
} else false
}
fun extractReadCondition(
convo: VkConvo,
lastMessage: VkMessage?
): Boolean = !convo.isRead(lastMessage)
fun extractInteractionText(
resources: Resources,
convo: VkConvo
): String? {
val interactionType = InteractionType.parse(convo.interactionType)
val interactiveUsers = extractInteractionUsers(convo)
val typingText =
if (interactionType == null) {
null
} else {
if (!convo.peerType.isChat() && interactiveUsers.size == 1) {
when (interactionType) {
InteractionType.File -> R.string.chat_interaction_uploading_file
InteractionType.Photo -> R.string.chat_interaction_uploading_photo
InteractionType.Typing -> R.string.chat_interaction_typing
InteractionType.Video -> R.string.chat_interaction_uploading_video
InteractionType.VoiceMessage -> R.string.chat_interaction_recording_audio_message
}.let(UiText::Resource)
} else {
if (interactiveUsers.size == 1) {
R.string.chat_interaction_chat_single_typing
} else {
R.string.chat_interaction_chat_typing
}.let { resId ->
UiText.ResourceParams(
resId,
listOf(interactiveUsers.joinToString(separator = ", "))
)
}
}.parseString(resources)
}
return typingText
}
fun extractInteractionUsers(convo: VkConvo): List<String> {
return convo.interactionIds.mapNotNull { id ->
when {
id > 0 -> VkMemoryCache.getUser(id)?.fullName
id < 0 -> VkMemoryCache.getGroup(id)?.name
else -> null
}
}
}
@@ -0,0 +1,47 @@
package dev.meloda.fast.domain.util
import android.content.res.Resources
import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.ActionState
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
fun VkConvo.asPresentation(
resources: Resources,
useContactName: Boolean,
isExpanded: Boolean = false,
options: ImmutableList<ConvoOption> = emptyImmutableList()
): UiConvo = UiConvo(
id = id,
lastMessageId = lastMessageId,
avatar = extractAvatar(),
title = extractTitle(useContactName, resources),
unreadCount = extractUnreadCount(lastMessage, this),
date = TimeUtils.getLocalizedTime(
date = (lastMessage?.date ?: -1) * 1000L,
yearShort = { resources.getString(R.string.year_short) },
monthShort = { resources.getString(R.string.month_short) },
weekShort = { resources.getString(R.string.week_short) },
dayShort = { resources.getString(R.string.day_short) },
now = { resources.getString(R.string.time_now) },
),
message = extractMessage(resources, lastMessage, id, peerType),
attachmentImage = if (lastMessage?.text == null) null
else getAttachmentConvoIcon(lastMessage),
isPinned = majorId > 0,
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
isBirthday = extractBirthday(this),
isUnread = !isRead(),
isAccount = isAccount(id),
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
lastMessage = lastMessage,
peerType = peerType,
interactionText = extractInteractionText(resources, this),
isExpanded = isExpanded,
isArchived = isArchived,
options = options
)
@@ -3,7 +3,7 @@ package dev.meloda.fast.domain.util
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.model.vk.UiFriend
fun VkUser.asPresentation(
useContactNames: Boolean = false
@@ -0,0 +1,461 @@
package dev.meloda.fast.domain.util
import android.content.res.Resources
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
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.data.UserConfig
import dev.meloda.fast.model.api.PeerType.Companion.getPeerType
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import java.text.SimpleDateFormat
import java.util.Locale
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()
fun VkMessage.extractReplySummary(resources: Resources): AnnotatedString? =
extractMessage(
resources = resources,
lastMessage = this,
peerId = peerId,
peerType = getPeerType(),
showPeer = false
)
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
)
}
}
}
}
@@ -0,0 +1,65 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.text.buildAnnotatedString
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.MessageUiItem
import dev.meloda.fast.ui.model.vk.SendingStatus
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
fun VkMessage.asPresentation(
convo: VkConvo,
resourceProvider: ResourceProvider,
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean,
isSelected: Boolean
): MessageUiItem = when {
action != null -> MessageUiItem.ActionMessage(
id = id,
cmId = cmId,
text = extractActionText(
resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
showTime = showTimeInActionMessages
) ?: buildAnnotatedString { },
actionCmId = actionCmId
)
else -> MessageUiItem.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(convo),
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 = replyMessage?.extractReplySummary(resourceProvider.resources)
)
}
@@ -0,0 +1,177 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.StringAnnotation
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.collidesWith
import dev.meloda.fast.common.extensions.minus
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.vk.MentionIndex
import dev.meloda.fast.ui.model.vk.MessageUiItem
fun emptyAnnotatedString(): AnnotatedString = AnnotatedString(text = "")
fun AnnotatedString?.orEmpty(): AnnotatedString = this ?: emptyAnnotatedString()
fun String.annotated(): AnnotatedString = AnnotatedString(text = this)
fun isAccount(id: Long) = id == UserConfig.userId
fun extractTextWithVisualizedMentions(
isOut: Boolean,
originalText: String?,
formatData: VkMessage.FormatData?
): AnnotatedString? {
if (originalText == null) return null
val annotations =
mutableListOf<AnnotatedString.Range<out androidx.compose.ui.text.AnnotatedString.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)
}
fun List<MessageUiItem>.firstMessage(): MessageUiItem.Message =
filterIsInstance<MessageUiItem.Message>().first()
fun List<MessageUiItem>.firstMessageOrNull(): MessageUiItem.Message? =
filterIsInstance<MessageUiItem.Message>().firstOrNull()
fun List<MessageUiItem>.indexOfMessageById(messageId: Long): Int =
indexOfFirst { it.id == messageId }
fun List<MessageUiItem>.findMessageById(messageId: Long): MessageUiItem.Message? =
firstOrNull { it.id == messageId } as MessageUiItem.Message?
fun List<MessageUiItem>.indexOfMessageByCmId(cmId: Long): Int? =
indexOfFirstOrNull { it.cmId == cmId }
fun List<MessageUiItem>.findMessageByCmId(cmId: Long): MessageUiItem.Message =
first { it.cmId == cmId } as MessageUiItem.Message