* 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
@@ -53,7 +53,7 @@ class ChatMaterialsViewModelImpl(
screenState.setValue { old ->
old.copy(
peerId = arguments.peerId,
cmId = arguments.conversationMessageId
cmId = arguments.cmId
)
}
@@ -101,7 +101,7 @@ class ChatMaterialsViewModelImpl(
isPaginationExhausted = paginationExhausted,
cmId = if (loadedMaterials.size + offset > 200) {
currentOffset.setValue { 0 }
loadedMaterials.lastOrNull()?.conversationMessageId ?: -1
loadedMaterials.lastOrNull()?.cmId ?: -1
} else {
screenState.value.cmId
}
@@ -1,43 +1,43 @@
package dev.meloda.fast.chatmaterials.model
sealed class UiChatMaterial(
open val conversationMessageId: Long
open val cmId: Long
) {
data class Photo(
override val conversationMessageId: Long,
override val cmId: Long,
val previewUrl: String
) : UiChatMaterial(conversationMessageId)
) : UiChatMaterial(cmId)
data class Video(
override val conversationMessageId: Long,
override val cmId: Long,
val previewUrl: String?,
val title: String,
val views: Int,
val duration: String
) : UiChatMaterial(conversationMessageId)
) : UiChatMaterial(cmId)
data class Audio(
override val conversationMessageId: Long,
override val cmId: Long,
val previewUrl: String?,
val title: String,
val artist: String,
val duration: String
) : UiChatMaterial(conversationMessageId)
) : UiChatMaterial(cmId)
data class File(
override val conversationMessageId: Long,
override val cmId: Long,
val previewUrl: String?,
val title: String,
val size: String,
val extension: String
) : UiChatMaterial(conversationMessageId)
) : UiChatMaterial(cmId)
data class Link(
override val conversationMessageId: Long,
override val cmId: Long,
val previewUrl: String?,
val title: String?,
val url: String,
val urlFirstChar: String
) : UiChatMaterial(conversationMessageId)
) : UiChatMaterial(cmId)
}
@@ -11,7 +11,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class ChatMaterials(
val peerId: Long,
val conversationMessageId: Long
val cmId: Long
) {
companion object {
fun from(savedStateHandle: SavedStateHandle) =
@@ -31,11 +31,11 @@ fun NavGraphBuilder.chatMaterialsScreen(
}
}
fun NavController.navigateToChatMaterials(peerId: Long, conversationMessageId: Long) {
fun NavController.navigateToChatMaterials(peerId: Long, cmId: Long) {
this.navigate(
ChatMaterials(
peerId = peerId,
conversationMessageId = conversationMessageId
cmId = cmId
)
)
}
@@ -17,7 +17,7 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
AttachmentType.PHOTO -> {
val attachment = this.attachment as VkPhotoDomain
UiChatMaterial.Photo(
conversationMessageId = this.conversationMessageId,
cmId = this.cmId,
previewUrl = attachment.getSizeOrSmaller(VkPhotoDomain.SIZE_TYPE_1080_1024)?.url.orEmpty()
)
}
@@ -47,7 +47,7 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
builder.toString().format(Locale.getDefault(), *args.toTypedArray())
UiChatMaterial.Video(
conversationMessageId = this.conversationMessageId,
cmId = this.cmId,
previewUrl = attachment.images.maxByOrNull(VkVideoDomain.VideoImage::width)?.url.orEmpty(),
title = attachment.title,
views = attachment.views,
@@ -80,7 +80,7 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
builder.toString().format(Locale.getDefault(), *args.toTypedArray())
UiChatMaterial.Audio(
conversationMessageId = this.conversationMessageId,
cmId = this.cmId,
previewUrl = null,
title = attachment.title,
artist = attachment.artist,
@@ -112,7 +112,7 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
}
UiChatMaterial.File(
conversationMessageId = this.conversationMessageId,
cmId = this.cmId,
title = attachment.title,
previewUrl = previewUrl,
size = AndroidUtils.bytesToHumanReadableSize(attachment.size.toDouble()),
@@ -124,7 +124,7 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
val attachment = this.attachment as VkLinkDomain
UiChatMaterial.Link(
conversationMessageId = this.conversationMessageId,
cmId = this.cmId,
title = attachment.title,
previewUrl = attachment.photo?.getMaxSize()?.url,
url = attachment.url,
@@ -1,746 +0,0 @@
package dev.meloda.fast.conversations
import android.content.Context
import android.content.res.Resources
import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.conversations.model.ConversationDialog
import dev.meloda.fast.conversations.model.ConversationNavigation
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.InteractionJob
import dev.meloda.fast.conversations.model.NewInteractionException
import dev.meloda.fast.conversations.util.asPresentation
import dev.meloda.fast.conversations.util.extractAvatar
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class ConversationsViewModel(
updatesParser: LongPollUpdatesParser,
private val filter: ConversationsFilter,
private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
private val userSettings: UserSettings,
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase
) : ViewModel() {
private val _screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val _navigation = MutableStateFlow<ConversationNavigation?>(null)
val navigation = _navigation.asStateFlow()
private val _dialog = MutableStateFlow<ConversationDialog?>(null)
val dialog = _dialog.asStateFlow()
private val _conversations = MutableStateFlow<List<VkConversation>>(emptyList())
val conversations = _conversations.asStateFlow()
private val _uiConversations = MutableStateFlow<List<UiConversation>>(emptyList())
val uiConversations = _uiConversations.asStateFlow()
private val pinnedConversationsCount = conversations.map { conversations ->
conversations.count(VkConversation::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
private val _currentOffset = MutableStateFlow(0)
val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConversationId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init {
_screenState.updateValue { copy(isArchive = filter == ConversationsFilter.ARCHIVE) }
loadConversations()
updatesParser.onNewMessage(::handleNewMessage)
updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingMessage)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage)
updatesParser.onInteractions(::handleInteraction)
updatesParser.onChatMajorChanged(::handleChatMajorChanged)
updatesParser.onChatMinorChanged(::handleChatMinorChanged)
updatesParser.onChatCleared(::handleChatClearing)
updatesParser.onChatArchived(::handleChatArchived)
userSettings.useContactNames.listenValue(viewModelScope) {
syncUiConversation()
}
}
fun onNavigationConsumed() {
_navigation.setValue { null }
}
fun onDialogConfirmed(dialog: ConversationDialog, bundle: Bundle) {
onDialogDismissed(dialog)
when (dialog) {
is ConversationDialog.ConversationDelete -> {
deleteConversation(dialog.conversationId)
}
is ConversationDialog.ConversationPin -> {
pinConversation(dialog.conversationId, true)
}
is ConversationDialog.ConversationUnpin -> {
pinConversation(dialog.conversationId, false)
}
is ConversationDialog.ConversationArchive -> {
archiveConversation(dialog.conversationId, true)
}
is ConversationDialog.ConversationUnarchive -> {
archiveConversation(dialog.conversationId, false)
}
}
expandedConversationId.setValue { 0 }
syncUiConversation()
}
fun onDialogDismissed(dialog: ConversationDialog) {
_dialog.setValue { null }
}
fun onDialogItemPicked(dialog: ConversationDialog, bundle: Bundle) {
when (dialog) {
is ConversationDialog.ConversationDelete -> Unit
is ConversationDialog.ConversationPin -> Unit
is ConversationDialog.ConversationUnpin -> Unit
is ConversationDialog.ConversationArchive -> Unit
is ConversationDialog.ConversationUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseError.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
_currentOffset.update { conversations.value.size }
loadConversations()
}
fun onRefresh() {
onErrorConsumed()
loadConversations(offset = 0)
}
fun onConversationItemClick(conversation: UiConversation) {
collapseConversations()
_navigation.setValue { ConversationNavigation.MessagesHistory(peerId = conversation.id) }
}
fun onConversationItemLongClick(conversation: UiConversation) {
expandedConversationId.setValue {
if (conversation.isExpanded) 0
else conversation.id
}
syncUiConversation()
}
fun onOptionClicked(
conversation: UiConversation,
option: ConversationOption
) {
when (option) {
ConversationOption.Delete -> {
_dialog.setValue { ConversationDialog.ConversationDelete(conversation.id) }
}
ConversationOption.MarkAsRead -> {
conversation.lastMessageId?.let { lastMessageId ->
readConversation(
peerId = conversation.id,
startMessageId = lastMessageId
)
collapseConversations()
}
}
ConversationOption.Pin -> {
_dialog.setValue { ConversationDialog.ConversationPin(conversation.id) }
}
ConversationOption.Unpin -> {
_dialog.setValue { ConversationDialog.ConversationUnpin(conversation.id) }
}
ConversationOption.Archive -> {
_dialog.setValue { ConversationDialog.ConversationArchive(conversation.id) }
}
ConversationOption.Unarchive -> {
_dialog.setValue { ConversationDialog.ConversationUnarchive(conversation.id) }
}
}
}
fun onErrorConsumed() {
_baseError.setValue { null }
}
fun setScrollIndex(index: Int) {
_screenState.setValue { old -> old.copy(scrollIndex = index) }
}
fun setScrollOffset(offset: Int) {
_screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
fun onCreateChatButtonClicked() {
_navigation.setValue { ConversationNavigation.CreateChat }
}
private fun collapseConversations() {
expandedConversationId.setValue { 0 }
syncUiConversation()
}
private fun loadConversations(
offset: Int = currentOffset.value
) {
conversationsUseCase.getConversations(
count = LOAD_COUNT,
offset = offset,
filter = filter
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
val newBaseError = VkUtils.parseError(error)
_baseError.update { newBaseError }
},
success = { response ->
val conversations = response
val fullConversations = if (offset == 0) {
conversations
} else {
this.conversations.value.plus(conversations)
}
val itemsCountSufficient = response.size == LOAD_COUNT
val paginationExhausted = !itemsCountSufficient &&
this.conversations.value.isNotEmpty()
_screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted)
}
val imagesToPreload =
response.mapNotNull { it.extractAvatar().extractUrl() }
imagesToPreload.forEach { url ->
imageLoader.enqueue(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
)
}
conversationsUseCase.storeConversations(response)
_conversations.emit(fullConversations)
syncUiConversation()
_canPaginate.setValue { itemsCountSufficient }
}
)
_screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun deleteConversation(peerId: Long) {
conversationsUseCase.delete(peerId).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == peerId }
?: return@processState
newConversations.removeAt(conversationIndex)
_conversations.update { newConversations.sorted() }
syncUiConversation()
}
)
_screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
}
}
private fun pinConversation(peerId: Long, pin: Boolean) {
conversationsUseCase.changePinState(peerId, pin)
.listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
handleChatMajorChanged(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = if (pin) {
pinnedConversationsCount.value.plus(1) * 16
} else {
0
}
)
)
}
)
_screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
}
}
private fun archiveConversation(peerId: Long, archive: Boolean) {
conversationsUseCase.changeArchivedState(peerId, archive)
.listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
conversations.value.find { it.id == peerId }?.let { conversation ->
handleChatArchived(
LongPollParsedEvent.ChatArchived(
conversation = conversation,
archived = archive
)
)
}
}
)
}
}
// TODO: 03-Apr-25, Danil Nikolaev: handle business messages
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == message.peerId }
if (conversationIndex == null) {
if (event.inArchive != (filter == ConversationsFilter.ARCHIVE)) return
loadConversationsByIdUseCase(
peerIds = listOf(message.peerId),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = { response ->
val conversation = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
newConversations.add(pinnedConversationsCount.value, conversation)
_conversations.update { newConversations.sorted() }
syncUiConversation()
}
)
}
} else {
val conversation = newConversations[conversationIndex]
var newConversation = conversation.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId,
unreadCount = if (message.isOut) conversation.unreadCount
else conversation.unreadCount + 1
)
interactionsTimers[conversation.id]?.let { job ->
if (job.interactionType == InteractionType.Typing
&& message.fromId in conversation.interactionIds
) {
val newInteractionIds = newConversation.interactionIds.filter { id ->
id != message.fromId
}
newConversation = newConversation.copy(
interactionType = if (newInteractionIds.isEmpty()) -1 else {
newConversation.interactionType
},
interactionIds = newInteractionIds
)
}
}
if (conversation.isPinned()) {
newConversations[conversationIndex] = newConversation
} else {
newConversations.removeAt(conversationIndex)
val toPosition = pinnedConversationsCount.value
newConversations.add(toPosition, newConversation)
}
_conversations.update { newConversations.sorted() }
syncUiConversation()
}
}
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message
val newConversations = conversations.value.toMutableList()
val conversationIndex = newConversations.indexOfFirstOrNull { it.id == message.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
val conversation = newConversations[conversationIndex]
newConversations[conversationIndex] = conversation.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId
)
_conversations.update { newConversations }
syncUiConversation()
}
}
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
inReadCmId = event.cmId,
unreadCount = event.unreadCount
)
_conversations.update { newConversations }
syncUiConversation()
}
}
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
outReadCmId = event.cmId,
unreadCount = event.unreadCount
)
_conversations.update { newConversations }
syncUiConversation()
}
}
private fun handleInteraction(event: LongPollParsedEvent.Interaction) {
val interactionType = event.interactionType
val peerId = event.peerId
val userIds = event.userIds
val newConversations = conversations.value.toMutableList()
val conversationAndIndex =
newConversations.findWithIndex { it.id == peerId }
if (conversationAndIndex != null) {
newConversations[conversationAndIndex.first] =
conversationAndIndex.second.copy(
interactionType = interactionType.value,
interactionIds = userIds
)
_conversations.update { newConversations }
syncUiConversation()
interactionsTimers[peerId]?.let { interactionJob ->
if (interactionJob.interactionType == interactionType) {
interactionJob.timerJob.cancel(NewInteractionException())
}
}
var timeoutAction: (() -> Unit)? = null
val timerJob = createTimerFlow(
time = 6,
onTimeoutAction = { timeoutAction?.invoke() }
).launchIn(viewModelScope)
val newInteractionJob = InteractionJob(
interactionType = interactionType,
timerJob = timerJob
)
interactionsTimers[peerId] = newInteractionJob
timeoutAction = {
stopInteraction(peerId, newInteractionJob)
}
}
}
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return
val newConversations = conversations.value.toMutableList()
val conversationAndIndex =
newConversations.findWithIndex { it.id == peerId } ?: return
newConversations[conversationAndIndex.first] =
conversationAndIndex.second.copy(
interactionType = -1,
interactionIds = emptyList()
)
_conversations.update { newConversations }
syncUiConversation()
interactionJob.timerJob.cancel()
interactionsTimers[peerId] = null
}
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(majorId = event.majorId)
_conversations.setValue { newConversations.sorted() }
syncUiConversation()
}
}
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(minorId = event.minorId)
_conversations.setValue { newConversations.sorted() }
syncUiConversation()
}
}
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConversations = conversations.value.toMutableList()
val conversationIndex = newConversations.indexOfFirstOrNull { it.id == event.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations.removeAt(conversationIndex)
_conversations.setValue { newConversations.sorted() }
syncUiConversation()
}
}
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
val conversation = event.conversation
val newConversations = conversations.value.toMutableList()
when (filter) {
ConversationsFilter.BUSINESS_NOTIFY -> Unit
ConversationsFilter.ARCHIVE -> {
if (event.archived) {
newConversations.add(0, conversation)
} else {
val index = newConversations.indexOfFirstOrNull { it.id == conversation.id }
if (index == null) return
newConversations.removeAt(index)
}
_conversations.update { newConversations }
syncUiConversation()
}
else -> {
if (event.archived) {
val index = newConversations.indexOfFirstOrNull { it.id == conversation.id }
if (index == null) return
newConversations.removeAt(index)
} else {
newConversations.add(pinnedConversationsCount.value, conversation)
}
_conversations.update { newConversations.sorted() }
syncUiConversation()
}
}
}
private fun readConversation(peerId: Long, startMessageId: Long) {
messagesUseCase.markAsRead(
peerId = peerId,
startMessageId = startMessageId
).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(inRead = startMessageId)
_conversations.update { newConversations }
syncUiConversation()
}
)
}
}
private fun List<VkConversation>.sorted(): List<VkConversation> {
val newConversations = toMutableList()
val pinnedConversations = newConversations
.filter(VkConversation::isPinned)
.sortedWith { c1, c2 ->
val diff = c2.majorId - c1.majorId
if (diff == 0) {
c2.minorId - c1.minorId
} else {
diff
}
}
newConversations.removeAll(pinnedConversations)
newConversations.sortWith { c1, c2 ->
(c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0)
}
newConversations.addAll(0, pinnedConversations)
return newConversations
}
private fun syncUiConversation(): List<UiConversation> {
val conversations = conversations.value
val newUiConversations = conversations.map { conversation ->
val options = mutableListOf<ConversationOption>()
conversation.lastMessage?.run {
if (!conversation.isRead() && !this.isOut) {
options += ConversationOption.MarkAsRead
}
}
val conversationsSize = this.conversations.value.size
val pinnedCount = pinnedConversationsCount.value
val canPinOneMoreDialog =
conversationsSize > 4 && pinnedCount < 5 && !conversation.isPinned()
if (conversation.isPinned()) {
options += ConversationOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConversationOption.Pin
}
when (filter) {
ConversationsFilter.ARCHIVE -> ConversationOption.Unarchive
ConversationsFilter.UNREAD,
ConversationsFilter.ALL -> ConversationOption.Archive
ConversationsFilter.BUSINESS_NOTIFY -> null
}?.let(options::add)
options += ConversationOption.Delete
conversation.asPresentation(
resources = resources,
useContactName = useContactNames,
isExpanded = expandedConversationId.value == conversation.id,
options = options.toImmutableList()
)
}
_uiConversations.setValue { newUiConversations }
return newUiConversations
}
companion object {
const val LOAD_COUNT = 30
}
}
@@ -1,37 +0,0 @@
package dev.meloda.fast.conversations.di
import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.ConversationsUseCaseImpl
import dev.meloda.fast.model.ConversationsFilter
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.core.scope.Scope
import org.koin.dsl.bind
import org.koin.dsl.module
val conversationsModule = module {
viewModel(named(ConversationsFilter.ALL)) {
createConversationsViewModel(ConversationsFilter.ALL)
}
viewModel(named(ConversationsFilter.ARCHIVE)) {
createConversationsViewModel(ConversationsFilter.ARCHIVE)
}
singleOf(::ConversationsUseCaseImpl) bind ConversationsUseCase::class
}
private fun Scope.createConversationsViewModel(filter: ConversationsFilter): ConversationsViewModel {
return ConversationsViewModel(
filter = filter,
updatesParser = get(),
conversationsUseCase = get(),
messagesUseCase = get(),
resources = get(),
userSettings = get(),
imageLoader = get(),
applicationContext = get(),
loadConversationsByIdUseCase = get()
)
}
@@ -1,12 +0,0 @@
package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConversationDialog {
data class ConversationPin(val conversationId: Long) : ConversationDialog()
data class ConversationUnpin(val conversationId: Long) : ConversationDialog()
data class ConversationDelete(val conversationId: Long) : ConversationDialog()
data class ConversationArchive(val conversationId: Long) : ConversationDialog()
data class ConversationUnarchive(val conversationId: Long) : ConversationDialog()
}
@@ -1,11 +0,0 @@
package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConversationNavigation {
data class MessagesHistory(val peerId: Long) : ConversationNavigation()
data object CreateChat : ConversationNavigation()
}
@@ -1,876 +0,0 @@
package dev.meloda.fast.conversations.util
import android.content.res.Resources
import androidx.compose.ui.graphics.Color
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.common.util.TimeUtils
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.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkVideoDomain
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.ActionState
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import java.util.Calendar
import java.util.Locale
import kotlin.math.ln
import kotlin.math.pow
fun VkConversation.asPresentation(
resources: Resources,
useContactName: Boolean,
isExpanded: Boolean = false,
options: ImmutableList<ConversationOption> = emptyImmutableList()
): UiConversation = UiConversation(
id = id,
lastMessageId = lastMessageId,
avatar = extractAvatar(),
title = extractTitle(this, 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 getAttachmentConversationIcon(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
)
fun VkConversation.extractAvatar() = 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)
private fun extractTitle(
conversation: VkConversation,
useContactName: Boolean,
resources: Resources
) = when (conversation.peerType) {
PeerType.USER -> {
if (isAccount(conversation.id)) {
UiText.Resource(R.string.favorites)
} else {
val userName = conversation.user?.let { user ->
if (useContactName) {
VkMemoryCache.getContact(user.id)?.name ?: user.fullName
} else {
user.fullName
}
}
UiText.Simple(userName.orDots())
}
}
PeerType.GROUP -> UiText.Simple(conversation.group?.name.orDots())
PeerType.CHAT -> UiText.Simple(conversation.title.orDots())
}.parseString(resources).orDots()
private fun extractUnreadCount(
lastMessage: VkMessage?,
conversation: VkConversation
): String? = when {
lastMessage?.isOut == false && conversation.isInRead() -> null
conversation.unreadCount == 0 -> null
conversation.unreadCount < 1000 -> conversation.unreadCount.toString()
else -> {
val exp = (ln(conversation.unreadCount.toDouble()) / ln(1000.0)).toInt()
val suffix = "KMBT"[exp - 1]
val result = conversation.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)
}
}
}
private fun extractMessage(
resources: Resources,
lastMessage: VkMessage?,
peerId: Long,
peerType: PeerType
): 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 {
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
)
}
.let { text -> prefix + text }
}
return finalText
}
private 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)
}
}
}
}
private 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
}
private 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 extractTextWithVisualizedMentions(
isOut: Boolean,
originalText: String
): AnnotatedString = buildAnnotatedString {
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
val result = 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
}
append(result)
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
addStyle(
style = SpanStyle(color = Color.Red),
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = mention.idPrefix,
annotation = mention.id.toString(),
start = startIndex,
end = endIndex
)
}
}
data class MentionIndex(
val id: Long,
val idPrefix: String,
val indexRange: IntRange
)
private 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)
}
private fun getAttachmentConversationIcon(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)
}
}
}
private fun extractBirthday(conversation: VkConversation): Boolean {
val birthday = conversation.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
}
private fun extractReadCondition(
conversation: VkConversation,
lastMessage: VkMessage?
): Boolean = !conversation.isRead(lastMessage)
private fun isAccount(peerId: Long) = peerId == UserConfig.userId
private fun extractInteractionText(
resources: Resources,
conversation: VkConversation
): String? {
val interactionType = InteractionType.parse(conversation.interactionType)
val interactiveUsers = extractInteractionUsers(conversation)
val typingText =
if (interactionType == null) {
null
} else {
if (!conversation.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
}
private fun extractInteractionUsers(conversation: VkConversation): List<String> {
return conversation.interactionIds.mapNotNull { id ->
when {
id > 0 -> VkMemoryCache.getUser(id)?.fullName
id < 0 -> VkMemoryCache.getGroup(id)?.name
else -> null
}
}
}
@@ -4,7 +4,7 @@ plugins {
}
android {
namespace = "dev.meloda.fast.conversations"
namespace = "dev.meloda.fast.convos"
}
dependencies {
@@ -0,0 +1,746 @@
package dev.meloda.fast.convos
import android.content.Context
import android.content.res.Resources
import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.model.InteractionJob
import dev.meloda.fast.convos.model.NewInteractionException
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConvoUseCase
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.model.BaseError
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class ConvosViewModel(
updatesParser: LongPollUpdatesParser,
private val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
private val userSettings: UserSettings,
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val loadConvosByIdUseCase: LoadConvosByIdUseCase
) : ViewModel() {
private val _screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val _navigation = MutableStateFlow<ConvoNavigation?>(null)
val navigation = _navigation.asStateFlow()
private val _dialog = MutableStateFlow<ConvoDialog?>(null)
val dialog = _dialog.asStateFlow()
private val _convos = MutableStateFlow<List<VkConvo>>(emptyList())
val convos = _convos.asStateFlow()
private val _uiConvos = MutableStateFlow<List<UiConvo>>(emptyList())
val uiConvos = _uiConvos.asStateFlow()
private val pinnedConvosCount = convos.map { convos ->
convos.count(VkConvo::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
private val _currentOffset = MutableStateFlow(0)
val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConvoId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init {
_screenState.updateValue { copy(isArchive = filter == ConvosFilter.ARCHIVE) }
loadConvos()
updatesParser.onNewMessage(::handleNewMessage)
updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingMessage)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage)
updatesParser.onInteractions(::handleInteraction)
updatesParser.onChatMajorChanged(::handleChatMajorChanged)
updatesParser.onChatMinorChanged(::handleChatMinorChanged)
updatesParser.onChatCleared(::handleChatClearing)
updatesParser.onChatArchived(::handleChatArchived)
userSettings.useContactNames.listenValue(viewModelScope) {
syncUiConvos()
}
}
fun onNavigationConsumed() {
_navigation.setValue { null }
}
fun onDialogConfirmed(dialog: ConvoDialog, bundle: Bundle) {
onDialogDismissed(dialog)
when (dialog) {
is ConvoDialog.ConvoDelete -> {
deleteConvo(dialog.convoId)
}
is ConvoDialog.ConvoPin -> {
pinConvo(dialog.convoId, true)
}
is ConvoDialog.ConvoUnpin -> {
pinConvo(dialog.convoId, false)
}
is ConvoDialog.ConvoArchive -> {
archiveConvo(dialog.convoId, true)
}
is ConvoDialog.ConvoUnarchive -> {
archiveConvo(dialog.convoId, false)
}
}
expandedConvoId.setValue { 0 }
syncUiConvos()
}
fun onDialogDismissed(dialog: ConvoDialog) {
_dialog.setValue { null }
}
fun onDialogItemPicked(dialog: ConvoDialog, bundle: Bundle) {
when (dialog) {
is ConvoDialog.ConvoDelete -> Unit
is ConvoDialog.ConvoPin -> Unit
is ConvoDialog.ConvoUnpin -> Unit
is ConvoDialog.ConvoArchive -> Unit
is ConvoDialog.ConvoUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseError.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
_currentOffset.update { convos.value.size }
loadConvos()
}
fun onRefresh() {
onErrorConsumed()
loadConvos(offset = 0)
}
fun onConvoItemClick(convo: UiConvo) {
collapseConvos()
_navigation.setValue { ConvoNavigation.MessagesHistory(peerId = convo.id) }
}
fun onConvoItemLongClick(convo: UiConvo) {
expandedConvoId.setValue {
if (convo.isExpanded) 0
else convo.id
}
syncUiConvos()
}
fun onOptionClicked(
convo: UiConvo,
option: ConvoOption
) {
when (option) {
ConvoOption.Delete -> {
_dialog.setValue { ConvoDialog.ConvoDelete(convo.id) }
}
ConvoOption.MarkAsRead -> {
convo.lastMessageId?.let { lastMessageId ->
readConvo(
peerId = convo.id,
startMessageId = lastMessageId
)
collapseConvos()
}
}
ConvoOption.Pin -> {
_dialog.setValue { ConvoDialog.ConvoPin(convo.id) }
}
ConvoOption.Unpin -> {
_dialog.setValue { ConvoDialog.ConvoUnpin(convo.id) }
}
ConvoOption.Archive -> {
_dialog.setValue { ConvoDialog.ConvoArchive(convo.id) }
}
ConvoOption.Unarchive -> {
_dialog.setValue { ConvoDialog.ConvoUnarchive(convo.id) }
}
}
}
fun onErrorConsumed() {
_baseError.setValue { null }
}
fun setScrollIndex(index: Int) {
_screenState.setValue { old -> old.copy(scrollIndex = index) }
}
fun setScrollOffset(offset: Int) {
_screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
fun onCreateChatButtonClicked() {
_navigation.setValue { ConvoNavigation.CreateChat }
}
private fun collapseConvos() {
expandedConvoId.setValue { 0 }
syncUiConvos()
}
private fun loadConvos(
offset: Int = currentOffset.value
) {
convoUseCase.getConvos(
count = LOAD_COUNT,
offset = offset,
filter = filter
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
val newBaseError = VkUtils.parseError(error)
_baseError.update { newBaseError }
},
success = { response ->
val convos = response
val fullConvos = if (offset == 0) {
convos
} else {
this.convos.value.plus(convos)
}
val itemsCountSufficient = response.size == LOAD_COUNT
val paginationExhausted = !itemsCountSufficient &&
this.convos.value.isNotEmpty()
_screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted)
}
val imagesToPreload =
response.mapNotNull { it.extractAvatar().extractUrl() }
imagesToPreload.forEach { url ->
imageLoader.enqueue(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
)
}
convoUseCase.storeConvos(response)
_convos.emit(fullConvos)
syncUiConvos()
_canPaginate.setValue { itemsCountSufficient }
}
)
_screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun deleteConvo(peerId: Long) {
convoUseCase.delete(peerId).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@processState
newConvos.removeAt(convoIndex)
_convos.update { newConvos.sorted() }
syncUiConvos()
}
)
_screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
}
}
private fun pinConvo(peerId: Long, pin: Boolean) {
convoUseCase.changePinState(peerId, pin)
.listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
handleChatMajorChanged(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = if (pin) {
pinnedConvosCount.value.plus(1) * 16
} else {
0
}
)
)
}
)
_screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
}
}
private fun archiveConvo(peerId: Long, archive: Boolean) {
convoUseCase.changeArchivedState(peerId, archive)
.listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
convos.value.find { it.id == peerId }?.let { convo ->
handleChatArchived(
LongPollParsedEvent.ChatArchived(
convo = convo,
archived = archive
)
)
}
}
)
}
}
// TODO: 03-Apr-25, Danil Nikolaev: handle business messages
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) {
if (event.inArchive != (filter == ConvosFilter.ARCHIVE)) return
loadConvosByIdUseCase(
peerIds = listOf(message.peerId),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = { response ->
val convo = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
newConvos.add(pinnedConvosCount.value, convo)
_convos.update { newConvos.sorted() }
syncUiConvos()
}
)
}
} else {
val convo = newConvos[convoIndex]
var newConvo = convo.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId,
unreadCount = if (message.isOut) convo.unreadCount
else convo.unreadCount + 1
)
interactionsTimers[convo.id]?.let { job ->
if (job.interactionType == InteractionType.Typing
&& message.fromId in convo.interactionIds
) {
val newInteractionIds = newConvo.interactionIds.filter { id ->
id != message.fromId
}
newConvo = newConvo.copy(
interactionType = if (newInteractionIds.isEmpty()) -1 else {
newConvo.interactionType
},
interactionIds = newInteractionIds
)
}
}
if (convo.isPinned()) {
newConvos[convoIndex] = newConvo
} else {
newConvos.removeAt(convoIndex)
val toPosition = pinnedConvosCount.value
newConvos.add(toPosition, newConvo)
}
_convos.update { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message
val newConvos = convos.value.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
val convo = newConvos[convoIndex]
newConvos[convoIndex] = convo.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId
)
_convos.update { newConvos }
syncUiConvos()
}
}
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(
inReadCmId = event.cmId,
unreadCount = event.unreadCount
)
_convos.update { newConvos }
syncUiConvos()
}
}
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(
outReadCmId = event.cmId,
unreadCount = event.unreadCount
)
_convos.update { newConvos }
syncUiConvos()
}
}
private fun handleInteraction(event: LongPollParsedEvent.Interaction) {
val interactionType = event.interactionType
val peerId = event.peerId
val userIds = event.userIds
val newConvos = convos.value.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId }
if (convoAndIndex != null) {
newConvos[convoAndIndex.first] =
convoAndIndex.second.copy(
interactionType = interactionType.value,
interactionIds = userIds
)
_convos.update { newConvos }
syncUiConvos()
interactionsTimers[peerId]?.let { interactionJob ->
if (interactionJob.interactionType == interactionType) {
interactionJob.timerJob.cancel(NewInteractionException())
}
}
var timeoutAction: (() -> Unit)? = null
val timerJob = createTimerFlow(
time = 6,
onTimeoutAction = { timeoutAction?.invoke() }
).launchIn(viewModelScope)
val newInteractionJob = InteractionJob(
interactionType = interactionType,
timerJob = timerJob
)
interactionsTimers[peerId] = newInteractionJob
timeoutAction = {
stopInteraction(peerId, newInteractionJob)
}
}
}
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return
val newConvos = convos.value.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } ?: return
newConvos[convoAndIndex.first] =
convoAndIndex.second.copy(
interactionType = -1,
interactionIds = emptyList()
)
_convos.update { newConvos }
syncUiConvos()
interactionJob.timerJob.cancel()
interactionsTimers[peerId] = null
}
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(majorId = event.majorId)
_convos.setValue { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(minorId = event.minorId)
_convos.setValue { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConvos = convos.value.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos.removeAt(convoIndex)
_convos.setValue { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
val convo = event.convo
val newConvos = convos.value.toMutableList()
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> {
if (event.archived) {
newConvos.add(0, convo)
} else {
val index = newConvos.indexOfFirstOrNull { it.id == convo.id }
if (index == null) return
newConvos.removeAt(index)
}
_convos.update { newConvos }
syncUiConvos()
}
else -> {
if (event.archived) {
val index = newConvos.indexOfFirstOrNull { it.id == convo.id }
if (index == null) return
newConvos.removeAt(index)
} else {
newConvos.add(pinnedConvosCount.value, convo)
}
_convos.update { newConvos.sorted() }
syncUiConvos()
}
}
}
private fun readConvo(peerId: Long, startMessageId: Long) {
messagesUseCase.markAsRead(
peerId = peerId,
startMessageId = startMessageId
).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue
newConvos[convoIndex] =
newConvos[convoIndex].copy(inRead = startMessageId)
_convos.update { newConvos }
syncUiConvos()
}
)
}
}
private fun List<VkConvo>.sorted(): List<VkConvo> {
val newConvos = toMutableList()
val pinnedConvos = newConvos
.filter(VkConvo::isPinned)
.sortedWith { c1, c2 ->
val diff = c2.majorId - c1.majorId
if (diff == 0) {
c2.minorId - c1.minorId
} else {
diff
}
}
newConvos.removeAll(pinnedConvos)
newConvos.sortWith { c1, c2 ->
(c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0)
}
newConvos.addAll(0, pinnedConvos)
return newConvos
}
private fun syncUiConvos(): List<UiConvo> {
val convos = convos.value
val newUiConvos = convos.map { convo ->
val options = mutableListOf<ConvoOption>()
convo.lastMessage?.run {
if (!convo.isRead() && !this.isOut) {
options += ConvoOption.MarkAsRead
}
}
val convosSize = this.convos.value.size
val pinnedCount = pinnedConvosCount.value
val canPinOneMoreDialog =
convosSize > 4 && pinnedCount < 5 && !convo.isPinned()
if (convo.isPinned()) {
options += ConvoOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConvoOption.Pin
}
when (filter) {
ConvosFilter.ARCHIVE -> ConvoOption.Unarchive
ConvosFilter.UNREAD,
ConvosFilter.ALL -> ConvoOption.Archive
ConvosFilter.BUSINESS_NOTIFY -> null
}?.let(options::add)
options += ConvoOption.Delete
convo.asPresentation(
resources = resources,
useContactName = useContactNames,
isExpanded = expandedConvoId.value == convo.id,
options = options.toImmutableList()
)
}
_uiConvos.setValue { newUiConvos }
return newUiConvos
}
companion object {
const val LOAD_COUNT = 30
}
}
@@ -0,0 +1,37 @@
package dev.meloda.fast.convos.di
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.ConvoUseCaseImpl
import dev.meloda.fast.model.ConvosFilter
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.core.scope.Scope
import org.koin.dsl.bind
import org.koin.dsl.module
val convosModule = module {
viewModel(named(ConvosFilter.ALL)) {
createConvosViewModel(ConvosFilter.ALL)
}
viewModel(named(ConvosFilter.ARCHIVE)) {
createConvosViewModel(ConvosFilter.ARCHIVE)
}
singleOf(::ConvoUseCaseImpl) bind ConvoUseCase::class
}
private fun Scope.createConvosViewModel(filter: ConvosFilter): ConvosViewModel {
return ConvosViewModel(
filter = filter,
updatesParser = get(),
convoUseCase = get(),
messagesUseCase = get(),
resources = get(),
userSettings = get(),
imageLoader = get(),
applicationContext = get(),
loadConvosByIdUseCase = get()
)
}
@@ -0,0 +1,12 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoDialog {
data class ConvoPin(val convoId: Long) : ConvoDialog()
data class ConvoUnpin(val convoId: Long) : ConvoDialog()
data class ConvoDelete(val convoId: Long) : ConvoDialog()
data class ConvoArchive(val convoId: Long) : ConvoDialog()
data class ConvoUnarchive(val convoId: Long) : ConvoDialog()
}
@@ -0,0 +1,11 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoNavigation {
data class MessagesHistory(val peerId: Long) : ConvoNavigation()
data object CreateChat : ConvoNavigation()
}
@@ -1,10 +1,9 @@
package dev.meloda.fast.conversations.model
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiConversation
@Immutable
data class ConversationsScreenState(
data class ConvosScreenState(
val isLoading: Boolean,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
@@ -15,7 +14,7 @@ data class ConversationsScreenState(
) {
companion object {
val EMPTY: ConversationsScreenState = ConversationsScreenState(
val EMPTY: ConvosScreenState = ConvosScreenState(
isLoading = true,
isPaginating = false,
isPaginationExhausted = false,
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model
package dev.meloda.fast.convos.model
import dev.meloda.fast.model.InteractionType
import kotlinx.coroutines.Job
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model
package dev.meloda.fast.convos.model
import kotlinx.coroutines.CancellationException
@@ -1,13 +1,13 @@
package dev.meloda.fast.conversations.navigation
package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.presentation.ConversationsRoute
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.presentation.ConvosRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController
import kotlinx.serialization.Serializable
@@ -15,32 +15,32 @@ import org.koin.androidx.viewmodel.ext.android.getViewModel
import org.koin.core.qualifier.named
@Serializable
object ConversationsGraph
object ConvoGraph
@Serializable
object Conversations
object Convos
@Serializable
object Archive
fun NavGraphBuilder.conversationsGraph(
fun NavGraphBuilder.convosGraph(
activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) {
navigation<ConversationsGraph>(
startDestination = Conversations
navigation<ConvoGraph>(
startDestination = Convos
) {
val conversationsViewModel: ConversationsViewModel = with(activity) {
getViewModel(qualifier = named(ConversationsFilter.ALL))
val convosViewModel: ConvosViewModel = with(activity) {
getViewModel(qualifier = named(ConvosFilter.ALL))
}
composable<Conversations> {
composable<Convos> {
val navController = LocalNavController.getOrThrow()
ConversationsRoute(
viewModel = conversationsViewModel,
ConvosRoute(
viewModel = convosViewModel,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
@@ -51,10 +51,10 @@ fun NavGraphBuilder.conversationsGraph(
composable<Archive> {
val navController = LocalNavController.getOrThrow()
ConversationsRoute(
ConvosRoute(
viewModel = with(activity) {
getViewModel<ConversationsViewModel>(
qualifier = named(ConversationsFilter.ARCHIVE)
getViewModel<ConvosViewModel>(
qualifier = named(ConvosFilter.ARCHIVE)
)
},
onBack = navController::navigateUp,
@@ -1,70 +1,70 @@
package dev.meloda.fast.conversations.presentation
package dev.meloda.fast.convos.presentation
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.core.os.bundleOf
import dev.meloda.fast.conversations.model.ConversationDialog
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.R
@Composable
fun HandleDialogs(
screenState: ConversationsScreenState,
dialog: ConversationDialog?,
onConfirmed: (ConversationDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConversationDialog) -> Unit = {},
onItemPicked: (ConversationDialog, Bundle) -> Unit = { _, _ -> }
screenState: ConvosScreenState,
dialog: ConvoDialog?,
onConfirmed: (ConvoDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConvoDialog) -> Unit = {},
onItemPicked: (ConvoDialog, Bundle) -> Unit = { _, _ -> }
) {
when (dialog) {
null -> Unit
is ConversationDialog.ConversationArchive -> {
is ConvoDialog.ConvoArchive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_archive_conversation),
title = stringResource(id = R.string.confirm_archive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = R.string.cancel)
)
}
is ConversationDialog.ConversationUnarchive -> {
is ConvoDialog.ConvoUnarchive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_unarchive_conversation),
title = stringResource(id = R.string.confirm_unarchive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = R.string.cancel)
)
}
is ConversationDialog.ConversationDelete -> {
is ConvoDialog.ConvoDelete -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_delete_conversation),
title = stringResource(id = R.string.confirm_delete_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_delete),
cancelText = stringResource(id = R.string.cancel)
)
}
is ConversationDialog.ConversationPin -> {
is ConvoDialog.ConvoPin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_pin_conversation),
title = stringResource(id = R.string.confirm_pin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = R.string.cancel)
)
}
is ConversationDialog.ConversationUnpin -> {
is ConvoDialog.ConvoUnpin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_unpin_conversation),
title = stringResource(id = R.string.confirm_unpin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = R.string.cancel)
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.presentation
package dev.meloda.fast.convos.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
@@ -48,8 +48,8 @@ import coil.compose.AsyncImage
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.DotsFlashing
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.util.getResourcePainter
import dev.meloda.fast.ui.util.getString
@@ -59,19 +59,19 @@ val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConversationItem(
onItemClick: (UiConversation) -> Unit,
onItemLongClick: (conversation: UiConversation) -> Unit,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
fun ConvoItem(
onItemClick: (UiConvo) -> Unit,
onItemLongClick: (convo: UiConvo) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
maxLines: Int,
isUserAccount: Boolean,
conversation: UiConversation,
convo: UiConvo,
modifier: Modifier = Modifier
) {
val hapticFeedback = LocalHapticFeedback.current
val bottomStartCornerRadius by animateDpAsState(
targetValue = if (conversation.isExpanded) 10.dp else 34.dp,
targetValue = if (convo.isExpanded) 10.dp else 34.dp,
label = "bottomStartCornerRadius"
)
@@ -79,15 +79,15 @@ fun ConversationItem(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onItemClick(conversation) },
onClick = { onItemClick(convo) },
onLongClick = {
onItemLongClick(conversation)
onItemLongClick(convo)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
)
) {
val showBackground by remember(conversation) {
derivedStateOf { conversation.isUnread || conversation.isExpanded }
val showBackground by remember(convo) {
derivedStateOf { convo.isUnread || convo.isExpanded }
}
AnimatedVisibility(
@@ -133,7 +133,7 @@ fun ConversationItem(
)
}
} else {
val avatarImage = conversation.avatar?.getImage()
val avatarImage = convo.avatar?.getImage()
if (avatarImage is Painter) {
Icon(
modifier = Modifier
@@ -155,7 +155,7 @@ fun ConversationItem(
}
}
if (conversation.isPinned) {
if (convo.isPinned) {
Box(
modifier = Modifier
.clip(CircleShape)
@@ -173,13 +173,13 @@ fun ConversationItem(
}
}
if (conversation.isOnline) {
if (convo.isOnline) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(18.dp)
.background(
if (conversation.isUnread) {
if (convo.isUnread) {
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
} else {
MaterialTheme.colorScheme.background
@@ -197,13 +197,13 @@ fun ConversationItem(
}
}
if (conversation.isBirthday) {
if (convo.isBirthday) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(16.dp)
.background(
if (conversation.isUnread) {
if (convo.isUnread) {
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
} else {
MaterialTheme.colorScheme.background
@@ -237,16 +237,16 @@ fun ConversationItem(
modifier = Modifier.weight(1f)
) {
Text(
text = conversation.title,
text = convo.title,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
)
Row {
if (conversation.interactionText != null) {
if (convo.interactionText != null) {
Text(
text = conversation.interactionText.orEmpty(),
text = convo.interactionText.orEmpty(),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
@@ -258,7 +258,7 @@ fun ConversationItem(
dotColor = MaterialTheme.colorScheme.primary
)
} else {
conversation.attachmentImage?.getResourcePainter()?.let { painter ->
convo.attachmentImage?.getResourcePainter()?.let { painter ->
Column {
Spacer(modifier = Modifier.height(4.dp))
Icon(
@@ -277,9 +277,9 @@ fun ConversationItem(
modifier = Modifier.weight(1f),
text = kotlin.run {
val builder =
AnnotatedString.Builder(conversation.message.text)
AnnotatedString.Builder(convo.message.text)
conversation.message.spanStyles.map { spanStyleRange ->
convo.message.spanStyles.map { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
@@ -294,7 +294,7 @@ fun ConversationItem(
)
}
conversation.message.paragraphStyles.forEach { style ->
convo.message.paragraphStyles.forEach { style ->
builder.addStyle(
style = style.item,
start = style.start,
@@ -318,12 +318,12 @@ fun ConversationItem(
Column {
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = conversation.date,
text = convo.date,
style = MaterialTheme.typography.bodySmall
)
}
conversation.unreadCount?.let { count ->
convo.unreadCount?.let { count ->
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier
@@ -351,7 +351,7 @@ fun ConversationItem(
Spacer(modifier = Modifier.width(24.dp))
}
AnimatedVisibility(conversation.isExpanded) {
AnimatedVisibility(convo.isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -367,9 +367,9 @@ fun ConversationItem(
.fillMaxWidth()
.padding(horizontal = 10.dp)
) {
items(conversation.options.toList()) { option ->
items(convo.options.toList()) { option ->
ElevatedAssistChip(
onClick = { onOptionClicked(conversation, option) },
onClick = { onOptionClicked(convo, option) },
leadingIcon = {
option.icon.getResourcePainter()?.let { painter ->
Icon(
@@ -390,7 +390,7 @@ fun ConversationItem(
}
val bottomSpacerHeight by animateDpAsState(
targetValue = if (conversation.isExpanded) 4.dp else 8.dp,
targetValue = if (convo.isExpanded) 4.dp else 8.dp,
label = "bottomSpacerHeight"
)
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.presentation
package dev.meloda.fast.convos.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -21,11 +21,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
@@ -33,15 +33,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ConversationsList(
fun ConvosList(
modifier: Modifier = Modifier,
conversations: ImmutableList<UiConversation>,
onConversationsClick: (UiConversation) -> Unit,
onConversationsLongClick: (UiConversation) -> Unit,
screenState: ConversationsScreenState,
convos: ImmutableList<UiConvo>,
onConvosClick: (UiConvo) -> Unit,
onConvosLongClick: (UiConvo) -> Unit,
screenState: ConvosScreenState,
state: LazyListState,
maxLines: Int,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
padding: PaddingValues
) {
val theme = LocalThemeConfig.current
@@ -56,22 +56,22 @@ fun ConversationsList(
Spacer(modifier = Modifier.height(8.dp))
}
items(
items = conversations.values,
key = UiConversation::id,
) { conversation ->
val isUserAccount by remember(conversation) {
items = convos.values,
key = UiConvo::id,
) { convo ->
val isUserAccount by remember(convo) {
derivedStateOf {
conversation.id == UserConfig.userId
convo.id == UserConfig.userId
}
}
ConversationItem(
onItemClick = onConversationsClick,
onItemLongClick = onConversationsLongClick,
ConvoItem(
onItemClick = onConvosClick,
onItemLongClick = onConvosLongClick,
onOptionClicked = onOptionClicked,
maxLines = maxLines,
isUserAccount = isUserAccount,
conversation = conversation,
convo = convo,
modifier =
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
@@ -1,27 +1,27 @@
package dev.meloda.fast.conversations.presentation
package dev.meloda.fast.convos.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.model.ConversationNavigation
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable
fun ConversationsRoute(
viewModel: ConversationsViewModel,
fun ConvosRoute(
viewModel: ConvosViewModel,
onBack: (() -> Unit)? = null,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (conversationId: Long) -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
val conversations by viewModel.uiConversations.collectAsStateWithLifecycle()
val convos by viewModel.uiConvos.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
@@ -30,12 +30,12 @@ fun ConversationsRoute(
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
null -> false
is ConversationNavigation.CreateChat -> {
is ConvoNavigation.CreateChat -> {
onNavigateToCreateChat?.invoke()
true
}
is ConversationNavigation.MessagesHistory -> {
is ConvoNavigation.MessagesHistory -> {
onNavigateToMessagesHistory(navigation.peerId)
true
}
@@ -44,14 +44,14 @@ fun ConversationsRoute(
if (shouldBeConsumed) viewModel.onNavigationConsumed()
}
ConversationsScreen(
ConvosScreen(
onBack = { onBack?.invoke() },
screenState = screenState,
conversations = conversations.toImmutableList(),
convos = convos.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
onConversationItemClicked = viewModel::onConversationItemClick,
onConversationItemLongClicked = viewModel::onConversationItemLongClick,
onConvoItemClicked = viewModel::onConvoItemClick,
onConvoItemLongClicked = viewModel::onConvoItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefreshDropdownItemClicked = viewModel::onRefresh,
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.presentation
package dev.meloda.fast.convos.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@@ -57,16 +57,16 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab
@@ -82,15 +82,15 @@ import kotlinx.coroutines.flow.debounce
ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class,
)
@Composable
fun ConversationsScreen(
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
conversations: ImmutableList<UiConversation> = emptyImmutableList(),
fun ConvosScreen(
screenState: ConvosScreenState = ConvosScreenState.EMPTY,
convos: ImmutableList<UiConvo> = emptyImmutableList(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onConversationItemClicked: (conversation: UiConversation) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
onConvoItemClicked: (convo: UiConvo) -> Unit = {},
onConvoItemLongClicked: (convo: UiConvo) -> Unit = {},
onOptionClicked: (UiConvo, ConvoOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
@@ -109,7 +109,7 @@ fun ConversationsScreen(
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
)
val currentTabReselected = LocalReselectedTab.current[ConversationsGraph] == true
val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true
LaunchedEffect(currentTabReselected) {
if (currentTabReselected) {
if (screenState.isArchive) {
@@ -182,7 +182,7 @@ fun ConversationsScreen(
id = when {
screenState.isLoading -> R.string.title_loading
screenState.isArchive -> R.string.title_archive
else -> R.string.title_conversations
else -> R.string.title_convos
}
),
maxLines = 1,
@@ -268,7 +268,7 @@ fun ConversationsScreen(
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && conversations.isNotEmpty() }
derivedStateOf { screenState.isLoading && convos.isNotEmpty() }
}
AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -310,7 +310,7 @@ fun ConversationsScreen(
)
}
screenState.isLoading && conversations.isEmpty() -> FullScreenContainedLoader()
screenState.isLoading && convos.isEmpty() -> FullScreenContainedLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -334,10 +334,10 @@ fun ConversationsScreen(
)
}
) {
ConversationsList(
conversations = conversations,
onConversationsClick = onConversationItemClicked,
onConversationsLongClick = onConversationItemLongClicked,
ConvosList(
convos = convos,
onConvosClick = onConvoItemClicked,
onConvosLongClick = onConvoItemLongClicked,
screenState = screenState,
state = listState,
maxLines = maxLines,
@@ -350,7 +350,7 @@ fun ConversationsScreen(
padding = padding
)
if (conversations.isEmpty()) {
if (convos.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations
package dev.meloda.fast.convos
import android.content.Context
import androidx.lifecycle.ViewModel
@@ -7,7 +7,7 @@ import coil.ImageLoader
import coil.request.ImageRequest
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.convos.model.CreateChatScreenState
import dev.meloda.fast.data.State
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
@@ -19,7 +19,7 @@ import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.model.vk.UiFriend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -1,6 +1,6 @@
package dev.meloda.fast.conversations.di
package dev.meloda.fast.convos.di
import dev.meloda.fast.conversations.CreateChatViewModel
import dev.meloda.fast.convos.CreateChatViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
@@ -1,7 +1,7 @@
package dev.meloda.fast.conversations.model
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.model.vk.UiFriend
@Immutable
data class CreateChatScreenState(
@@ -1,12 +1,12 @@
package dev.meloda.fast.conversations.navigation
package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.conversations.CreateChatViewModel
import dev.meloda.fast.conversations.presentation.CreateChatRoute
import dev.meloda.fast.convos.CreateChatViewModel
import dev.meloda.fast.convos.presentation.CreateChatRoute
import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.presentation
package dev.meloda.fast.convos.presentation
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.model.vk.UiFriend
@Composable
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.presentation
package dev.meloda.fast.convos.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -19,9 +19,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.convos.model.CreateChatScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.model.vk.UiFriend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.presentation
package dev.meloda.fast.convos.presentation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
@@ -58,8 +58,8 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.CreateChatViewModel
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.convos.CreateChatViewModel
import dev.meloda.fast.convos.model.CreateChatScreenState
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader
@@ -121,8 +121,8 @@ abstract class BaseFriendsViewModelImpl : ViewModel(), FriendsViewModel {
val friends = friends.value
if (friends.isEmpty()) return
val uiFriends = friends.map { conversation ->
conversation.asPresentation(useContactNames)
val uiFriends = friends.map { convo ->
convo.asPresentation(useContactNames)
}
screenState.setValue { old ->
@@ -1,7 +1,7 @@
package dev.meloda.fast.friends.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.model.vk.UiFriend
@Immutable
data class FriendsScreenState(
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.model.vk.UiFriend
@Composable
fun FriendItem(
@@ -22,7 +22,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.model.vk.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
@@ -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)
}