* 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
+1 -1
View File
@@ -82,7 +82,7 @@ dependencies {
implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials)
implementation(projects.feature.conversations)
implementation(projects.feature.convos)
implementation(projects.feature.languagepicker)
implementation(projects.feature.messageshistory)
implementation(projects.feature.photoviewer)
@@ -16,8 +16,8 @@ import dev.meloda.fast.common.LongPollControllerImpl
import dev.meloda.fast.common.provider.Provider
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.common.provider.ResourceProviderImpl
import dev.meloda.fast.conversations.di.conversationsModule
import dev.meloda.fast.conversations.di.createChatModule
import dev.meloda.fast.convos.di.convosModule
import dev.meloda.fast.convos.di.createChatModule
import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule
@@ -41,7 +41,7 @@ val applicationModule = module {
loginModule,
validationModule,
captchaModule,
conversationsModule,
convosModule,
settingsModule,
messagesHistoryModule,
photoViewModule,
@@ -2,7 +2,7 @@ package dev.meloda.fast.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem
@@ -21,7 +21,7 @@ object Main
fun NavGraphBuilder.mainScreen(
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit,
onNavigateToMessagesHistory: (conversationId: Long) -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userid: Long) -> Unit,
onNavigateToCreateChat: () -> Unit
@@ -34,10 +34,10 @@ fun NavGraphBuilder.mainScreen(
route = Friends,
),
BottomNavigationItem(
titleResId = R.string.title_conversations,
titleResId = R.string.title_convos,
selectedIconResId = R.drawable.baseline_chat_24,
unselectedIconResId = R.drawable.outline_chat_24,
route = ConversationsGraph
route = ConvoGraph
),
BottomNavigationItem(
titleResId = R.string.title_profile,
@@ -38,8 +38,8 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.conversations.navigation.conversationsGraph
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen
import dev.meloda.fast.model.BaseError
@@ -60,7 +60,7 @@ fun MainScreen(
navigationItems: ImmutableList<BottomNavigationItem>,
onError: (BaseError) -> Unit = {},
onSettingsButtonClicked: () -> Unit = {},
onNavigateToMessagesHistory: (conversationId: Long) -> Unit = {},
onNavigateToMessagesHistory: (convoId: Long) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {},
onNavigateToCreateChat: () -> Unit = {}
@@ -197,14 +197,14 @@ fun MainScreen(
}
},
)
conversationsGraph(
convosGraph(
activity = activity,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also {
it[ConversationsGraph] = false
it[ConvoGraph] = false
}
}
)
@@ -46,8 +46,8 @@ import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.conversations.navigation.createChatScreen
import dev.meloda.fast.conversations.navigation.navigateToCreateChat
import dev.meloda.fast.convos.navigation.createChatScreen
import dev.meloda.fast.convos.navigation.navigateToCreateChat
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
@@ -354,9 +354,9 @@ fun RootScreen(
}
)
createChatScreen(
onChatCreated = { conversationId ->
onChatCreated = { convoId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
navController.navigateToMessagesHistory(convoId)
},
navController = navController
)
@@ -55,7 +55,8 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
"-opt-in=kotlin.RequiresOptIn",
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview"
"-opt-in=kotlinx.coroutines.FlowPreview",
"-Xannotation-default-target=param-property",
)
}
}
@@ -138,3 +138,17 @@ fun <T : Any> Bundle.getParcelableCompat(key: String, clazz: KClass<T>): T? {
getParcelable(key)
}
}
infix fun ClosedRange<Int>.collidesWith(other: ClosedRange<Int>): Boolean {
return this.start < other.endInclusive && other.start < this.endInclusive
}
fun dickPizda(a: Int): String = ""
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)
}
@@ -1,7 +1,7 @@
package dev.meloda.fast.data
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import kotlin.math.abs
@@ -16,9 +16,9 @@ class VkGroupsMap(
fun groups(): List<VkGroupDomain> = map.values.toList()
fun conversationGroup(conversation: VkConversation): VkGroupDomain? =
if (!conversation.peerType.isGroup()) null
else map[abs(conversation.id)]
fun convoGroup(convo: VkConvo): VkGroupDomain? =
if (!convo.peerType.isGroup()) null
else map[abs(convo.id)]
fun messageActionGroup(message: VkMessage): VkGroupDomain? =
if (message.actionMemberId == null || message.actionMemberId!! >= 0) null
@@ -2,7 +2,7 @@ package dev.meloda.fast.data
import dev.meloda.fast.data.UserConfig.userId
import dev.meloda.fast.model.api.domain.VkContactDomain
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
@@ -13,7 +13,7 @@ object VkMemoryCache {
private val users: HashMap<Long, VkUser> = hashMapOf()
private val groups: HashMap<Long, VkGroupDomain> = hashMapOf()
private val messages: HashMap<Long, VkMessage> = hashMapOf()
private val conversations: HashMap<Long, VkConversation> = hashMapOf()
private val convos: HashMap<Long, VkConvo> = hashMapOf()
private val contacts: HashMap<Long, VkContactDomain> = hashMapOf()
fun appendUsers(users: List<VkUser>) {
@@ -28,9 +28,9 @@ object VkMemoryCache {
messages.forEach { message -> VkMemoryCache.messages[message.id] = message }
}
fun appendConversations(conversations: List<VkConversation>) {
conversations.forEach { conversation ->
VkMemoryCache.conversations[conversation.id] = conversation
fun appendConvos(convos: List<VkConvo>) {
convos.forEach { convo ->
VkMemoryCache.convos[convo.id] = convo
}
}
@@ -50,8 +50,8 @@ object VkMemoryCache {
messages[messageId] = message
}
operator fun set(conversationId: Long, conversation: VkConversation) {
conversations[conversationId] = conversation
operator fun set(convoId: Long, convo: VkConvo) {
convos[convoId] = convo
}
operator fun set(contactId: Long, contact: VkContactDomain) {
@@ -94,16 +94,16 @@ object VkMemoryCache {
return ids.mapNotNull { id -> messages[id] }
}
fun getConversation(id: Long): VkConversation? {
return getConversations(id).firstOrNull()
fun getConvo(id: Long): VkConvo? {
return getConvos(id).firstOrNull()
}
fun getConversations(vararg ids: Long): List<VkConversation> {
return getConversations(ids.toList())
fun getConvos(vararg ids: Long): List<VkConvo> {
return getConvos(ids.toList())
}
fun getConversations(ids: List<Long>): List<VkConversation> {
return ids.mapNotNull { id -> conversations[id] }
fun getConvos(ids: List<Long>): List<VkConvo> {
return ids.mapNotNull { id -> convos[id] }
}
fun getContact(id: Long): VkContactDomain? {
@@ -1,8 +1,7 @@
package dev.meloda.fast.data
import dev.meloda.fast.data.UserConfig.userId
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
@@ -16,9 +15,9 @@ class VkUsersMap(
fun users(): List<VkUser> = map.values.toList()
fun conversationUser(conversation: VkConversation): VkUser? =
if (!conversation.peerType.isUser()) null
else map[conversation.id]
fun convoUser(convo: VkConvo): VkUser? =
if (!convo.peerType.isUser()) null
else map[convo.id]
fun messageActionUser(message: VkMessage): VkUser? =
if (message.actionMemberId == null || message.actionMemberId!! <= 0) null
@@ -1,25 +1,25 @@
package dev.meloda.fast.data.api.conversations
package dev.meloda.fast.data.api.convos
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.network.RestApiErrorDomain
interface ConversationsRepository {
interface ConvosRepository {
suspend fun storeConversations(conversations: List<VkConversation>)
suspend fun storeConvos(convos: List<VkConvo>)
suspend fun getConversations(
suspend fun getConvos(
count: Int?,
offset: Int?,
filter: ConversationsFilter
): ApiResult<List<VkConversation>, RestApiErrorDomain>
filter: ConvosFilter
): ApiResult<List<VkConvo>, RestApiErrorDomain>
suspend fun getConversationsById(
suspend fun getConvosById(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): ApiResult<List<VkConversation>, RestApiErrorDomain>
): ApiResult<List<VkConvo>, RestApiErrorDomain>
suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain>
suspend fun pin(peerId: Long): ApiResult<Int, RestApiErrorDomain>
@@ -1,51 +1,51 @@
package dev.meloda.fast.data.api.conversations
package dev.meloda.fast.data.api.convos
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap
import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.ConversationsGetRequest
import dev.meloda.fast.model.api.requests.ConvosGetRequest
import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.conversations.ConversationsService
import dev.meloda.fast.network.service.convos.ConvosService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ConversationsRepositoryImpl(
private val conversationsService: ConversationsService,
class ConvosRepositoryImpl(
private val convosService: ConvosService,
private val messageDao: MessageDao,
private val userDao: UserDao,
private val groupDao: GroupDao,
private val conversationDao: ConversationDao
) : ConversationsRepository {
private val convoDao: ConvoDao
) : ConvosRepository {
override suspend fun storeConversations(conversations: List<VkConversation>) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
override suspend fun storeConvos(convos: List<VkConvo>) {
convoDao.insertAll(convos.map(VkConvo::asEntity))
}
override suspend fun getConversations(
override suspend fun getConvos(
count: Int?,
offset: Int?,
filter: ConversationsFilter
): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConversationsGetRequest(
filter: ConvosFilter
): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConvosGetRequest(
count = count,
offset = offset,
fields = VkConstants.ALL_FIELDS,
@@ -54,7 +54,7 @@ class ConversationsRepositoryImpl(
startMessageId = null
)
conversationsService.getConversations(requestModel.map).mapApiResult(
convosService.getConvos(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
@@ -69,7 +69,7 @@ class ConversationsRepositoryImpl(
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
val conversations = response.items.map { item ->
val convos = response.items.map { item ->
val lastMessage = item.lastMessage?.asDomain()?.let { message ->
message.copy(
user = usersMap.messageUser(message),
@@ -84,24 +84,24 @@ class ConversationsRepositoryImpl(
)
).also { VkMemoryCache[message.id] = it }
}
item.conversation.asDomain(lastMessage).let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
item.convo.asDomain(lastMessage).let { convo ->
convo.copy(
user = usersMap.convoUser(convo),
group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[convo.id] = it }
}
}
val messages = conversations.mapNotNull(VkConversation::lastMessage)
val messages = convos.mapNotNull(VkConvo::lastMessage)
launch(Dispatchers.IO) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
convoDao.insertAll(convos.map(VkConvo::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
conversations
convos
},
errorMapper = { error ->
error?.toDomain()
@@ -109,11 +109,11 @@ class ConversationsRepositoryImpl(
)
}
override suspend fun getConversationsById(
override suspend fun getConvosById(
peerIds: List<Long>,
extended: Boolean?,
fields: String?
): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestParams = mutableMapOf(
"peer_ids" to peerIds.joinToString(separator = ",")
).apply {
@@ -121,7 +121,7 @@ class ConversationsRepositoryImpl(
fields?.let { this["fields"] = it }
}
conversationsService.getConversationsById(requestParams).mapApiResult(
convosService.getConvosById(requestParams).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
@@ -132,17 +132,17 @@ class ConversationsRepositoryImpl(
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
val conversations = response.items.map { item ->
item.asDomain().let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
val convos = response.items.map { item ->
item.asDomain().let { convo ->
convo.copy(
user = usersMap.convoUser(convo),
group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[convo.id] = it }
}
}
launch(Dispatchers.IO) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
convoDao.insertAll(convos.map(VkConvo::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
@@ -151,7 +151,7 @@ class ConversationsRepositoryImpl(
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
conversations
convos
},
errorMapper = { error ->
error?.toDomain()
@@ -161,7 +161,7 @@ class ConversationsRepositoryImpl(
override suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
conversationsService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult(
convosService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult(
successMapper = { response -> response.requireResponse().lastDeletedId },
errorMapper = { error -> error?.toDomain() }
)
@@ -170,19 +170,19 @@ class ConversationsRepositoryImpl(
override suspend fun pin(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
convosService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
override suspend fun unpin(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
convosService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
override suspend fun reorderPinned(
peerIds: List<Long>
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService
convosService
.reorderPinned(mapOf("peer_ids" to peerIds.joinToString(",")))
.mapApiDefault()
}
@@ -190,12 +190,12 @@ class ConversationsRepositoryImpl(
override suspend fun archive(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
convosService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
override suspend fun unarchive(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
conversationsService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
}
@@ -1,9 +1,9 @@
package dev.meloda.fast.data.api.messages
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
data class MessagesHistoryInfo(
val messages: List<VkMessage>,
val conversations: List<VkConversation>
val convos: List<VkConvo>
)
@@ -5,7 +5,7 @@ import dev.meloda.fast.model.api.data.VkChatData
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain
@@ -15,7 +15,7 @@ interface MessagesRepository {
suspend fun storeMessages(messages: List<VkMessage>)
suspend fun getHistory(
conversationId: Long,
convoId: Long,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain>
@@ -99,13 +99,13 @@ interface MessagesRepository {
fields: String? = null
): ApiResult<VkChatData, RestApiErrorDomain>
suspend fun getConversationMembers(
suspend fun getConvoMembers(
peerId: Long,
offset: Int? = null,
count: Int? = null,
extended: Boolean? = null,
fields: String? = null
): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain>
): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain>
suspend fun removeChatUser(
chatId: Long,
@@ -5,7 +5,7 @@ import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap
import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao
@@ -17,7 +17,7 @@ import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
@@ -27,7 +27,7 @@ import dev.meloda.fast.model.api.requests.MessagesDeleteRequest
import dev.meloda.fast.model.api.requests.MessagesEditRequest
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetChatRequest
import dev.meloda.fast.model.api.requests.MessagesGetConversationMembersRequest
import dev.meloda.fast.model.api.requests.MessagesGetConvoMembersRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest
@@ -36,7 +36,7 @@ import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest
import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest
import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain
@@ -52,18 +52,18 @@ class MessagesRepositoryImpl(
private val messageDao: MessageDao,
private val userDao: UserDao,
private val groupDao: GroupDao,
private val conversationDao: ConversationDao
private val convoDao: ConvoDao
) : MessagesRepository {
override suspend fun getHistory(
conversationId: Long,
convoId: Long,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetHistoryRequest(
count = count,
offset = offset,
peerId = conversationId,
peerId = convoId,
extended = true,
startMessageId = null,
rev = null,
@@ -104,19 +104,19 @@ class MessagesRepositoryImpl(
}
}
val conversations = response.conversations.orEmpty().map { item ->
val convos = response.convos.orEmpty().map { item ->
val message = messages.firstOrNull { it.id == item.lastMessageId }
item.asDomain(message)
.let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
.let { convo ->
convo.copy(
user = usersMap.convoUser(convo),
group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[convo.id] = it }
}
}
launch(Dispatchers.IO) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
convoDao.insertAll(convos.map(VkConvo::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
@@ -124,7 +124,7 @@ class MessagesRepositoryImpl(
MessagesHistoryInfo(
messages = messages,
conversations = conversations
convos = convos
)
},
errorMapper = { error ->
@@ -243,7 +243,7 @@ class MessagesRepositoryImpl(
offset = offset,
preserveOrder = true,
attachmentTypes = attachmentTypes,
conversationMessageId = cmId,
cmId = cmId,
fields = VkConstants.ALL_FIELDS
)
@@ -297,7 +297,7 @@ class MessagesRepositoryImpl(
val requestModel = MessagesPinMessageRequest(
peerId = peerId,
messageId = messageId,
conversationMessageId = cmId
cmId = cmId
)
messagesService.pin(requestModel.map).mapApiResult(
@@ -343,7 +343,7 @@ class MessagesRepositoryImpl(
val requestModel = MessagesDeleteRequest(
peerId = peerId,
messagesIds = messageIds,
conversationsMessagesIds = cmIds,
cmIds = cmIds,
isSpam = spam,
deleteForAll = deleteForAll
)
@@ -394,15 +394,15 @@ class MessagesRepositoryImpl(
messagesService.getChat(requestModel.map).mapApiDefault()
}
override suspend fun getConversationMembers(
override suspend fun getConvoMembers(
peerId: Long,
offset: Int?,
count: Int?,
extended: Boolean?,
fields: String?
): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain> =
): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = MessagesGetConversationMembersRequest(
val requestModel = MessagesGetConvoMembersRequest(
peerId = peerId,
offset = offset,
count = count,
@@ -410,7 +410,7 @@ class MessagesRepositoryImpl(
fields = fields
)
messagesService.getConversationMembers(requestModel.map).mapApiDefault()
messagesService.getConvoMembers(requestModel.map).mapApiDefault()
}
override suspend fun removeChatUser(
@@ -6,8 +6,8 @@ import dev.meloda.fast.data.api.account.AccountRepositoryImpl
import dev.meloda.fast.data.api.audios.AudiosRepository
import dev.meloda.fast.data.api.auth.AuthRepository
import dev.meloda.fast.data.api.auth.AuthRepositoryImpl
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.api.conversations.ConversationsRepositoryImpl
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.api.convos.ConvosRepositoryImpl
import dev.meloda.fast.data.api.files.FilesRepository
import dev.meloda.fast.data.api.friends.FriendsRepository
import dev.meloda.fast.data.api.friends.FriendsRepositoryImpl
@@ -45,7 +45,7 @@ val dataModule = module {
singleOf(::AuthRepositoryImpl) bind AuthRepository::class
singleOf(::ConversationsRepositoryImpl) bind ConversationsRepository::class
singleOf(::ConvosRepositoryImpl) bind ConvosRepository::class
singleOf(::FilesRepository)
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "fa307a5eb2e1f7d601bd1374174635cd",
"identityHash": "6c315b7f800694f635318d86032746ec",
"entities": [
{
"tableName": "users",
@@ -41,50 +41,42 @@
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
}
],
"primaryKey": {
@@ -92,9 +84,7 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "groups",
@@ -121,26 +111,22 @@
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
}
],
"primaryKey": {
@@ -148,13 +134,11 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversationMessageId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -163,16 +147,15 @@
"notNull": true
},
{
"fieldPath": "conversationMessageId",
"columnName": "conversationMessageId",
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "isOut",
@@ -207,38 +190,32 @@
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "actionConversationMessageId",
"columnName": "actionConversationMessageId",
"affinity": "INTEGER",
"notNull": false
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "important",
@@ -249,32 +226,27 @@
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
@@ -288,13 +260,11 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -311,32 +281,27 @@
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
@@ -345,8 +310,8 @@
"notNull": true
},
{
"fieldPath": "lastConversationMessageId",
"columnName": "lastConversationMessageId",
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
@@ -377,8 +342,7 @@
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
@@ -389,8 +353,7 @@
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
@@ -419,8 +382,7 @@
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER",
"notNull": false
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
@@ -440,15 +402,12 @@
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fa307a5eb2e1f7d601bd1374174635cd')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6c315b7f800694f635318d86032746ec')"
]
}
}
@@ -0,0 +1,413 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "a746865995959331f8a1b512c049dacb",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "screenName",
"columnName": "screenName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "isOut",
"columnName": "isOut",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "peerId",
"columnName": "peerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fromId",
"columnName": "fromId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "randomId",
"columnName": "randomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT"
},
{
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER"
},
{
"fieldPath": "important",
"columnName": "important",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "convos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a746865995959331f8a1b512c049dacb')"
]
}
}
@@ -3,12 +3,12 @@ package dev.meloda.fast.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.database.typeconverters.Converters
import dev.meloda.fast.model.database.VkConversationEntity
import dev.meloda.fast.model.database.VkConvoEntity
import dev.meloda.fast.model.database.VkGroupEntity
import dev.meloda.fast.model.database.VkMessageEntity
import dev.meloda.fast.model.database.VkUserEntity
@@ -18,15 +18,15 @@ import dev.meloda.fast.model.database.VkUserEntity
VkUserEntity::class,
VkGroupEntity::class,
VkMessageEntity::class,
VkConversationEntity::class
VkConvoEntity::class
],
version = 10
version = 11
)
@TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun groupDao(): GroupDao
abstract fun messageDao(): MessageDao
abstract fun conversationDao(): ConversationDao
abstract fun convoDao(): ConvoDao
}
@@ -1,30 +0,0 @@
package dev.meloda.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import dev.meloda.fast.model.database.ConversationWithMessage
import dev.meloda.fast.model.database.VkConversationEntity
@Dao
abstract class ConversationDao : EntityDao<VkConversationEntity> {
@Query("SELECT * FROM conversations")
abstract suspend fun getAll(): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConversationEntity?
@Transaction
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Long): ConversationWithMessage?
@Query("DELETE FROM conversations WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -0,0 +1,30 @@
package dev.meloda.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import dev.meloda.fast.model.database.ConvoWithMessage
import dev.meloda.fast.model.database.VkConvoEntity
@Dao
abstract class ConvoDao : EntityDao<VkConvoEntity> {
@Query("SELECT * FROM convos")
abstract suspend fun getAll(): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConvoEntity?
@Transaction
@Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
@Query("DELETE FROM convos WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -10,8 +10,8 @@ abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages")
abstract suspend fun getAll(): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:conversationId)")
abstract suspend fun getAll(conversationId: Long): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:convoId)")
abstract suspend fun getAll(convoId: Long): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkMessageEntity>
@@ -23,7 +23,7 @@ val databaseModule = module {
single { cacheDB().userDao() }
single { cacheDB().groupDao() }
single { cacheDB().messageDao() }
single { cacheDB().conversationDao() }
single { cacheDB().convoDao() }
}
private fun Scope.cacheDB(): CacheDatabase = get()
+5
View File
@@ -8,6 +8,7 @@ android {
}
dependencies {
api(projects.core.common)
api(projects.core.data)
api(projects.core.model)
@@ -15,4 +16,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core)
implementation(libs.eithernet)
implementation(libs.bundles.nanokt)
implementation(libs.compose.ui)
}
@@ -1,25 +1,25 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.flow.Flow
interface ConversationsUseCase : BaseUseCase {
interface ConvoUseCase : BaseUseCase {
suspend fun storeConversations(conversations: List<VkConversation>)
suspend fun storeConvos(convos: List<VkConvo>)
fun getConversations(
fun getConvos(
count: Int? = null,
offset: Int? = null,
filter: ConversationsFilter
): Flow<State<List<VkConversation>>>
filter: ConvosFilter
): Flow<State<List<VkConvo>>>
fun getById(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): Flow<State<List<VkConversation>>>
): Flow<State<List<VkConvo>>>
fun delete(peerId: Long): Flow<State<Long>>
@@ -1,30 +1,30 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
class ConversationsUseCaseImpl(
private val repository: ConversationsRepository,
) : ConversationsUseCase {
class ConvoUseCaseImpl(
private val repository: ConvosRepository,
) : ConvoUseCase {
override suspend fun storeConversations(
conversations: List<VkConversation>
override suspend fun storeConvos(
convos: List<VkConvo>
) = withContext(Dispatchers.IO) {
repository.storeConversations(conversations)
repository.storeConvos(convos)
}
override fun getConversations(
override fun getConvos(
count: Int?,
offset: Int?,
filter: ConversationsFilter
): Flow<State<List<VkConversation>>> = flowNewState {
repository.getConversations(
filter: ConvosFilter
): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConvos(
count = count,
offset = offset,
filter = filter
@@ -35,8 +35,8 @@ class ConversationsUseCaseImpl(
peerIds: List<Long>,
extended: Boolean?,
fields: String?
): Flow<State<List<VkConversation>>> = flowNewState {
repository.getConversationsById(
): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConvosById(
peerIds = peerIds,
extended = extended,
fields = fields
@@ -1,22 +1,22 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import kotlinx.coroutines.flow.Flow
class LoadConversationsByIdUseCase(
private val conversationsRepository: ConversationsRepository
class LoadConvosByIdUseCase(
private val convosRepository: ConvosRepository
) : BaseUseCase {
operator fun invoke(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): Flow<State<List<VkConversation>>> = flowNewState {
conversationsRepository
.getConversationsById(
): Flow<State<List<VkConvo>>> = flowNewState {
convosRepository
.getConvosById(
peerIds = peerIds,
extended = extended,
fields = fields,
@@ -9,12 +9,12 @@ import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConversationFlags
import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -28,7 +28,7 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser(
private val conversationsUseCase: ConversationsUseCase,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase
) {
private val job = SupervisorJob()
@@ -271,9 +271,9 @@ class LongPollUpdatesParser(
val message =
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val conversation =
val convo =
async {
loadConversation(
loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
@@ -287,7 +287,7 @@ class LongPollUpdatesParser(
.onEvent(
LongPollParsedEvent.NewMessage(
message = message,
inArchive = conversation?.isArchived == true
inArchive = convo?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with
// enabled notifications from archive
@@ -368,13 +368,13 @@ class LongPollUpdatesParser(
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConversationFlags.parse(flags)
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
@@ -382,11 +382,11 @@ class LongPollUpdatesParser(
val message = loadMessage(
peerId = peerId,
cmId = conversation.lastCmId
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
conversation = conversation.copy(lastMessage = message),
convo = convo.copy(lastMessage = message),
archived = false
)
eventsToSend += eventToSend
@@ -423,13 +423,13 @@ class LongPollUpdatesParser(
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConversationFlags.parse(flags)
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
@@ -437,11 +437,11 @@ class LongPollUpdatesParser(
val message = loadMessage(
peerId = peerId,
cmId = conversation.lastCmId
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
conversation = conversation.copy(lastMessage = message),
convo = convo.copy(lastMessage = message),
archived = true
)
eventsToSend += eventToSend
@@ -673,29 +673,29 @@ class LongPollUpdatesParser(
}
}
private suspend fun loadConversation(
private suspend fun loadConvo(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConversation? = suspendCoroutine { continuation ->
): VkConvo? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
conversationsUseCase.getById(
convoUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
Log.e("LongPollUpdatesParser", "loadConversation: error: $error")
Log.e("LongPollUpdatesParser", "loadConvo: error: $error")
continuation.resume(null)
},
success = { response ->
val conversation = response.singleOrNull() ?: run {
val convo = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(conversation)
continuation.resume(convo)
}
)
}
@@ -14,7 +14,7 @@ interface MessagesUseCase : BaseUseCase {
suspend fun storeMessages(messages: List<VkMessage>)
fun getMessagesHistory(
conversationId: Long,
convoId: Long,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryInfo>>
@@ -23,12 +23,12 @@ class MessagesUseCaseImpl(
}
override fun getMessagesHistory(
conversationId: Long,
convoId: Long,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryInfo>> = flowNewState {
repository.getHistory(
conversationId = conversationId,
convoId = convoId,
offset = offset,
count = count
).mapToState()
@@ -7,7 +7,7 @@ import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.StoreUsersUseCase
@@ -27,7 +27,7 @@ val domainModule = module {
singleOf(::AccountUseCaseImpl) bind AccountUseCase::class
singleOf(::GetCurrentAccountUseCase)
singleOf(::LoadConversationsByIdUseCase)
singleOf(::LoadConvosByIdUseCase)
singleOf(::GetMessageReadPeersUseCase)
}
@@ -1,7 +1,6 @@
package dev.meloda.fast.conversations.util
package dev.meloda.fast.domain.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
@@ -13,64 +12,22 @@ 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.VkConvo
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) {
fun VkConvo.extractAvatar(): UiImage = when (peerType) {
PeerType.USER -> {
if (isAccount(id)) null
else user?.photo200
@@ -85,18 +42,17 @@ fun VkConversation.extractAvatar() = when (peerType) {
}
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_cut)
private fun extractTitle(
conversation: VkConversation,
fun VkConvo.extractTitle(
useContactName: Boolean,
resources: Resources
) = when (conversation.peerType) {
) = when (peerType) {
PeerType.USER -> {
if (isAccount(conversation.id)) {
if (isAccount(id)) {
UiText.Resource(R.string.favorites)
} else {
val userName = conversation.user?.let { user ->
val userName = user?.let { user ->
if (useContactName) {
VkMemoryCache.getContact(user.id)?.name ?: user.fullName
VkMemoryCache.getContact(user.id)?.name
} else {
user.fullName
}
@@ -106,22 +62,22 @@ private fun extractTitle(
}
}
PeerType.GROUP -> UiText.Simple(conversation.group?.name.orDots())
PeerType.CHAT -> UiText.Simple(conversation.title.orDots())
PeerType.GROUP -> UiText.Simple(group?.name.orDots())
PeerType.CHAT -> UiText.Simple(title.orDots())
}.parseString(resources).orDots()
private fun extractUnreadCount(
fun extractUnreadCount(
lastMessage: VkMessage?,
conversation: VkConversation
convo: VkConvo
): String? = when {
lastMessage?.isOut == false && conversation.isInRead() -> null
conversation.unreadCount == 0 -> null
conversation.unreadCount < 1000 -> conversation.unreadCount.toString()
lastMessage?.isOut == false && convo.isInRead() -> null
convo.unreadCount == 0 -> null
convo.unreadCount < 1000 -> convo.unreadCount.toString()
else -> {
val exp = (ln(conversation.unreadCount.toDouble()) / ln(1000.0)).toInt()
val exp = (ln(convo.unreadCount.toDouble()) / ln(1000.0)).toInt()
val suffix = "KMBT"[exp - 1]
val result = conversation.unreadCount / 1000.0.pow(exp.toDouble())
val result = convo.unreadCount / 1000.0.pow(exp.toDouble())
if (result.toLong().toDouble() == result) {
String.format(Locale.getDefault(), "%.0f%s", result, suffix)
@@ -131,11 +87,12 @@ private fun extractUnreadCount(
}
}
private fun extractMessage(
fun extractMessage(
resources: Resources,
lastMessage: VkMessage?,
peerId: Long,
peerType: PeerType
peerType: PeerType,
showPeer: Boolean = true
): AnnotatedString {
val youPrefix = UiText.Resource(R.string.you_message_prefix)
.parseString(resources)
@@ -160,6 +117,8 @@ private fun extractMessage(
val messageText = lastMessage?.text.orEmpty()
val prefixText: AnnotatedString? = when {
!showPeer -> null
actionMessage != null -> null
lastMessage == null -> null
@@ -226,16 +185,17 @@ private fun extractMessage(
.let { text ->
extractTextWithVisualizedMentions(
isOut = lastMessage?.isOut == true,
originalText = text
originalText = text,
formatData = null
)
}
.let { text -> prefix + text }
.let { text -> prefix + text.orEmpty() }
}
return finalText
}
private fun extractActionText(
fun extractActionText(
lastMessage: VkMessage?,
resources: Resources,
youPrefix: String
@@ -539,7 +499,7 @@ private fun extractAttachmentIcon(
}
}
private fun extractAttachmentText(
fun extractAttachmentText(
resources: Resources,
lastMessage: VkMessage?
): AnnotatedString? = when {
@@ -649,7 +609,7 @@ private fun isAttachmentsHaveOneType(attachments: List<VkAttachment>): Boolean {
return true
}
private fun extractForwardsText(
fun extractForwardsText(
resources: Resources,
lastMessage: VkMessage?
): AnnotatedString? = when {
@@ -670,69 +630,7 @@ private fun extractForwardsText(
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(
fun getAttachmentUiText(
attachment: VkAttachment,
size: Int = 1,
): UiText {
@@ -787,7 +685,7 @@ private fun getAttachmentUiText(
}.let(UiText::Resource)
}
private fun getAttachmentConversationIcon(message: VkMessage?): UiImage? {
fun getAttachmentConvoIcon(message: VkMessage?): UiImage? {
return message?.attachments?.let { attachments ->
if (attachments.isEmpty()) return null
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
@@ -801,8 +699,8 @@ private fun getAttachmentConversationIcon(message: VkMessage?): UiImage? {
}
}
private fun extractBirthday(conversation: VkConversation): Boolean {
val birthday = conversation.user?.birthday ?: return false
fun extractBirthday(convo: VkConvo): Boolean {
val birthday = convo.user?.birthday ?: return false
val splitBirthday = birthday.split(".").mapNotNull(String::toIntOrNull)
if (splitBirthday.isEmpty()) return false
@@ -822,25 +720,23 @@ private fun extractBirthday(conversation: VkConversation): Boolean {
} else false
}
private fun extractReadCondition(
conversation: VkConversation,
fun extractReadCondition(
convo: VkConvo,
lastMessage: VkMessage?
): Boolean = !conversation.isRead(lastMessage)
): Boolean = !convo.isRead(lastMessage)
private fun isAccount(peerId: Long) = peerId == UserConfig.userId
private fun extractInteractionText(
fun extractInteractionText(
resources: Resources,
conversation: VkConversation
convo: VkConvo
): String? {
val interactionType = InteractionType.parse(conversation.interactionType)
val interactiveUsers = extractInteractionUsers(conversation)
val interactionType = InteractionType.parse(convo.interactionType)
val interactiveUsers = extractInteractionUsers(convo)
val typingText =
if (interactionType == null) {
null
} else {
if (!conversation.peerType.isChat() && interactiveUsers.size == 1) {
if (!convo.peerType.isChat() && interactiveUsers.size == 1) {
when (interactionType) {
InteractionType.File -> R.string.chat_interaction_uploading_file
InteractionType.Photo -> R.string.chat_interaction_uploading_photo
@@ -865,8 +761,8 @@ private fun extractInteractionText(
return typingText
}
private fun extractInteractionUsers(conversation: VkConversation): List<String> {
return conversation.interactionIds.mapNotNull { id ->
fun extractInteractionUsers(convo: VkConvo): List<String> {
return convo.interactionIds.mapNotNull { id ->
when {
id > 0 -> VkMemoryCache.getUser(id)?.fullName
id < 0 -> VkMemoryCache.getGroup(id)?.name
@@ -0,0 +1,47 @@
package dev.meloda.fast.domain.util
import android.content.res.Resources
import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.ActionState
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
fun VkConvo.asPresentation(
resources: Resources,
useContactName: Boolean,
isExpanded: Boolean = false,
options: ImmutableList<ConvoOption> = emptyImmutableList()
): UiConvo = UiConvo(
id = id,
lastMessageId = lastMessageId,
avatar = extractAvatar(),
title = extractTitle(useContactName, resources),
unreadCount = extractUnreadCount(lastMessage, this),
date = TimeUtils.getLocalizedTime(
date = (lastMessage?.date ?: -1) * 1000L,
yearShort = { resources.getString(R.string.year_short) },
monthShort = { resources.getString(R.string.month_short) },
weekShort = { resources.getString(R.string.week_short) },
dayShort = { resources.getString(R.string.day_short) },
now = { resources.getString(R.string.time_now) },
),
message = extractMessage(resources, lastMessage, id, peerType),
attachmentImage = if (lastMessage?.text == null) null
else getAttachmentConvoIcon(lastMessage),
isPinned = majorId > 0,
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
isBirthday = extractBirthday(this),
isUnread = !isRead(),
isAccount = isAccount(id),
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
lastMessage = lastMessage,
peerType = peerType,
interactionText = extractInteractionText(resources, this),
isExpanded = isExpanded,
isArchived = isArchived,
options = options
)
@@ -3,7 +3,7 @@ package dev.meloda.fast.domain.util
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.model.vk.UiFriend
fun VkUser.asPresentation(
useContactNames: Boolean = false
@@ -1,35 +1,21 @@
package dev.meloda.fast.messageshistory.util
package dev.meloda.fast.domain.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.PeerType.Companion.getPeerType
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
@@ -59,111 +45,15 @@ fun VkMessage.extractTitle(): String = when {
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
fun VkMessage.extractReplySummary(resources: Resources): AnnotatedString? =
extractMessage(
resources = resources,
lastMessage = this,
peerId = peerId,
peerType = getPeerType(),
showPeer = false
)
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
@@ -569,153 +459,3 @@ fun VkMessage.extractActionText(
}
}
}
// TODO: 04-Apr-25, Danil Nikolaev: get rid of method duplication
fun extractTextWithVisualizedMentions(
isOut: Boolean,
originalText: String?,
formatData: VkMessage.FormatData?
): AnnotatedString? {
if (originalText == null) return null
val annotations =
mutableListOf<AnnotatedString.Range<out Annotation>>()
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
val newText = regex.replace(originalText) { matchResult ->
val idPrefix = matchResult.groups[1]?.value.orEmpty()
val startIndex = matchResult.range.first
val endIndex = matchResult.range.last
val id = matchResult.groups[2]?.value ?: ""
val replaced = matchResult.groups[3]?.value.orEmpty()
val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toLongOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
currentIndex += replaced.length - (endIndex - startIndex + 1)
replaced
}
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
annotations += 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)
}
@@ -0,0 +1,65 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.text.buildAnnotatedString
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.MessageUiItem
import dev.meloda.fast.ui.model.vk.SendingStatus
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
fun VkMessage.asPresentation(
convo: VkConvo,
resourceProvider: ResourceProvider,
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean,
isSelected: Boolean
): MessageUiItem = when {
action != null -> MessageUiItem.ActionMessage(
id = id,
cmId = cmId,
text = extractActionText(
resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
showTime = showTimeInActionMessages
) ?: buildAnnotatedString { },
actionCmId = actionCmId
)
else -> MessageUiItem.Message(
id = id,
cmId = cmId,
text = extractTextWithVisualizedMentions(
isOut = isOut,
originalText = text,
formatData = formatData
),
isOut = isOut,
fromId = fromId,
date = extractDate(),
randomId = randomId,
isInChat = isPeerChat(),
name = extractTitle(),
showDate = true,
showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(),
isEdited = updateTime != null,
isRead = isRead(convo),
sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
},
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant,
attachments = attachments?.ifEmpty { null }?.toImmutableList(),
replyCmId = replyMessage?.cmId,
replyTitle = extractReplyTitle(),
replySummary = replyMessage?.extractReplySummary(resourceProvider.resources)
)
}
@@ -0,0 +1,177 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.StringAnnotation
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.collidesWith
import dev.meloda.fast.common.extensions.minus
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.vk.MentionIndex
import dev.meloda.fast.ui.model.vk.MessageUiItem
fun emptyAnnotatedString(): AnnotatedString = AnnotatedString(text = "")
fun AnnotatedString?.orEmpty(): AnnotatedString = this ?: emptyAnnotatedString()
fun String.annotated(): AnnotatedString = AnnotatedString(text = this)
fun isAccount(id: Long) = id == UserConfig.userId
fun extractTextWithVisualizedMentions(
isOut: Boolean,
originalText: String?,
formatData: VkMessage.FormatData?
): AnnotatedString? {
if (originalText == null) return null
val annotations =
mutableListOf<AnnotatedString.Range<out androidx.compose.ui.text.AnnotatedString.Annotation>>()
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
val newText = regex.replace(originalText) { matchResult ->
val idPrefix = matchResult.groups[1]?.value.orEmpty()
val startIndex = matchResult.range.first
val endIndex = matchResult.range.last
val id = matchResult.groups[2]?.value ?: ""
val replaced = matchResult.groups[3]?.value.orEmpty()
val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toLongOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
currentIndex += replaced.length - (endIndex - startIndex + 1)
replaced
}
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
annotations += if (isOut) {
AnnotatedString.Range(
item = SpanStyle(textDecoration = TextDecoration.Underline),
start = startIndex,
end = endIndex
)
} else {
AnnotatedString.Range(
item = SpanStyle(color = Color.Red),
start = startIndex,
end = endIndex
)
}
annotations += AnnotatedString.Range(
item = StringAnnotation(mention.id.toString()),
tag = mention.idPrefix,
start = startIndex,
end = endIndex
)
}
if (formatData == null) {
return AnnotatedString(text = newText, annotations = annotations)
}
var current = 0
val newOffsets = formatData.items.map { (offset, length) ->
val r = replacements.filter { (range, _) ->
(range - current) collidesWith (offset..<offset + length) || offset > range.first
}
current = r.sumOf { (range, _) -> range.last - range.first - 1 }
offset + current
}
formatData.items.forEachIndexed { index, item ->
val offset = newOffsets[index]
val spanStyle = when (item.type) {
FormatDataType.BOLD -> {
SpanStyle(fontWeight = FontWeight.SemiBold)
}
FormatDataType.ITALIC -> {
SpanStyle(fontStyle = FontStyle.Italic)
}
FormatDataType.UNDERLINE -> {
SpanStyle(textDecoration = TextDecoration.Underline)
}
FormatDataType.URL -> {
annotations += AnnotatedString.Range(
item = StringAnnotation(item.url.orEmpty()),
start = offset,
end = offset + item.length,
tag = newText.substring(offset, offset + item.length)
)
if (isOut) {
SpanStyle(
fontWeight = FontWeight.SemiBold,
textDecoration = TextDecoration.Underline
)
} else {
SpanStyle(
fontWeight = FontWeight.SemiBold,
color = Color.Red
)
}
}
}
annotations += AnnotatedString.Range(
item = spanStyle,
start = offset,
end = offset + item.length
)
}
return AnnotatedString(text = newText, annotations = annotations)
}
fun List<MessageUiItem>.firstMessage(): MessageUiItem.Message =
filterIsInstance<MessageUiItem.Message>().first()
fun List<MessageUiItem>.firstMessageOrNull(): MessageUiItem.Message? =
filterIsInstance<MessageUiItem.Message>().firstOrNull()
fun List<MessageUiItem>.indexOfMessageById(messageId: Long): Int =
indexOfFirst { it.id == messageId }
fun List<MessageUiItem>.findMessageById(messageId: Long): MessageUiItem.Message? =
firstOrNull { it.id == messageId } as MessageUiItem.Message?
fun List<MessageUiItem>.indexOfMessageByCmId(cmId: Long): Int? =
indexOfFirstOrNull { it.cmId == cmId }
fun List<MessageUiItem>.findMessageByCmId(cmId: Long): MessageUiItem.Message =
first { it.cmId == cmId } as MessageUiItem.Message
@@ -1,6 +1,6 @@
package dev.meloda.fast.model
enum class ConversationFlags(val value: Int) {
enum class ConvoFlags(val value: Int) {
DISABLE_PUSH(16),
DISABLE_SOUND(32),
INCOMING_CHAT_REQUEST(256),
@@ -17,10 +17,10 @@ enum class ConversationFlags(val value: Int) {
companion object {
fun parse(mask: Int): List<ConversationFlags> {
val flags = mutableListOf<ConversationFlags>()
fun parse(mask: Int): List<ConvoFlags> {
val flags = mutableListOf<ConvoFlags>()
ConversationFlags.entries.forEach { flag ->
ConvoFlags.entries.forEach { flag ->
if (mask and flag.value > 0) {
flags.add(flag)
}
@@ -1,5 +1,5 @@
package dev.meloda.fast.model
enum class ConversationsFilter {
enum class ConvosFilter {
ALL, UNREAD, ARCHIVE, BUSINESS_NOTIFY
}
@@ -1,6 +1,6 @@
package dev.meloda.fast.model
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
sealed interface LongPollParsedEvent {
@@ -92,7 +92,7 @@ sealed interface LongPollParsedEvent {
) : LongPollParsedEvent
data class ChatArchived(
val conversation: VkConversation,
val convo: VkConvo,
val archived: Boolean
) : LongPollParsedEvent
}
@@ -1,5 +1,7 @@
package dev.meloda.fast.model.api
import dev.meloda.fast.model.api.domain.VkMessage
enum class PeerType(val value: String) {
USER("user"),
GROUP("group"),
@@ -13,5 +15,14 @@ enum class PeerType(val value: String) {
fun parse(type: String): PeerType {
return entries.first { it.value == type }
}
fun VkMessage.getPeerType(): PeerType {
return when {
peerId > 2_000_000_000 -> CHAT
peerId > 0 -> USER
peerId < 0 -> GROUP
else -> throw IllegalArgumentException("Unknown peer type for peerId: 0")
}
}
}
}
@@ -8,7 +8,7 @@ import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
data class VkAttachmentHistoryMessageData(
@Json(name = "message_id") val messageId: Long,
@Json(name = "date") val date: Int,
@Json(name = "cmid") val conversationMessageId: Long,
@Json(name = "cmid") val cmId: Long,
@Json(name = "from_id") val fromId: Long,
@Json(name = "position") val position: Int,
@Json(name = "attachment") val attachment: VkAttachmentItemData
@@ -16,7 +16,7 @@ data class VkAttachmentHistoryMessageData(
fun toDomain(): VkAttachmentHistoryMessage = VkAttachmentHistoryMessage(
messageId = messageId,
conversationMessageId = conversationMessageId,
cmId = cmId,
date = date,
fromId = fromId,
position = position,
@@ -3,19 +3,19 @@ package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
@JsonClass(generateAdapter = true)
data class VkConversationData(
data class VkConvoData(
@Json(name = "peer") val peer: Peer,
@Json(name = "last_message_id") val lastMessageId: Long?,
@Json(name = "in_read") val inRead: Long,
@Json(name = "out_read") val outRead: Long,
@Json(name = "in_read_cmid") val inReadConversationMessageId: Long,
@Json(name = "out_read_cmid") val outReadConversationMessageId: Long,
@Json(name = "in_read_cmid") val inReadCmId: Long,
@Json(name = "out_read_cmid") val outReadCmId: Long,
@Json(name = "sort_id") val sortId: SortId,
@Json(name = "last_conversation_message_id") val lastConversationMessageId: Long,
@Json(name = "last_conversation_message_id") val lastCmId: Long,
@Json(name = "is_marked_unread") val isMarkedUnread: Boolean,
@Json(name = "important") val important: Boolean,
@Json(name = "push_settings") val pushSettings: PushSettings?,
@@ -111,7 +111,7 @@ data class VkConversationData(
fun asDomain(
lastMessage: VkMessage? = null,
): VkConversation = VkConversation(
): VkConvo = VkConvo(
id = peer.id,
localId = peer.localId,
title = chatSettings?.title,
@@ -120,7 +120,7 @@ data class VkConversationData(
photo200 = chatSettings?.photo?.photo200,
isCallInProgress = callInProgress != null,
isPhantom = chatSettings?.isDisappearing == true,
lastCmId = lastConversationMessageId,
lastCmId = lastCmId,
inRead = inRead,
outRead = outRead,
lastMessageId = lastMessageId,
@@ -132,8 +132,8 @@ data class VkConversationData(
canChangePin = chatSettings?.acl?.canChangePin == true,
canChangeInfo = chatSettings?.acl?.canChangeInfo == true,
pinnedMessageId = chatSettings?.pinnedMessage?.id,
inReadCmId = inReadConversationMessageId,
outReadCmId = outReadConversationMessageId,
inReadCmId = inReadCmId,
outReadCmId = outReadCmId,
interactionType = -1,
interactionIds = emptyList(),
peerType = PeerType.parse(peer.type),
@@ -56,7 +56,7 @@ data class VkMessageData(
@Json(name = "type") val type: String,
@Json(name = "member_id") val memberId: Long?,
@Json(name = "text") val text: String?,
@Json(name = "conversation_message_id") val conversationMessageId: Long?,
@Json(name = "conversation_message_id") val cmId: Long?,
@Json(name = "message") val message: String?
)
@@ -102,7 +102,7 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
action = VkMessage.Action.parse(action?.type),
actionMemberId = action?.memberId,
actionText = action?.text,
actionConversationMessageId = action?.conversationMessageId,
actionCmId = action?.cmId,
actionMessage = action?.message,
geoType = geo?.type,
isImportant = important == true,
@@ -12,7 +12,7 @@ data class VkPinnedMessageData(
@Json(name = "from_id") val fromId: Long,
@Json(name = "out") val out: Boolean?,
@Json(name = "text") val text: String,
@Json(name = "conversation_message_id") val conversationMessageId: Long,
@Json(name = "conversation_message_id") val cmId: Long,
@Json(name = "fwd_messages") val forwards: List<VkMessageData>?,
@Json(name = "important") val important: Boolean = false,
@Json(name = "random_id") val randomId: Long = 0,
@@ -28,7 +28,7 @@ data class VkPinnedMessageData(
fun mapToDomain(): VkMessage = VkMessage(
id = id ?: -1,
cmId = conversationMessageId,
cmId = cmId,
text = text.ifBlank { null },
isOut = out == true,
peerId = peerId ?: -1,
@@ -38,7 +38,7 @@ data class VkPinnedMessageData(
action = VkMessage.Action.parse(action?.type),
actionMemberId = action?.memberId,
actionText = action?.text,
actionConversationMessageId = action?.conversationMessageId,
actionCmId = action?.cmId,
actionMessage = action?.message,
geoType = geo?.type,
isImportant = important,
@@ -2,7 +2,7 @@ package dev.meloda.fast.model.api.domain
data class VkAttachmentHistoryMessage(
val messageId: Long,
val conversationMessageId: Long,
val cmId: Long,
val date: Int,
val fromId: Long,
val position: Int,
@@ -1,9 +1,9 @@
package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.database.VkConversationEntity
import dev.meloda.fast.model.database.VkConvoEntity
data class VkConversation(
data class VkConvo(
val id: Long,
val localId: Long,
val ownerId: Long?,
@@ -54,7 +54,7 @@ data class VkConversation(
}
companion object {
val EMPTY: VkConversation = VkConversation(
val EMPTY: VkConvo = VkConvo(
id = -1,
localId = -1,
ownerId = null,
@@ -90,7 +90,7 @@ data class VkConversation(
}
}
fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity(
fun VkConvo.asEntity(): VkConvoEntity = VkConvoEntity(
id = id,
localId = localId,
ownerId = ownerId,
@@ -99,7 +99,7 @@ fun VkConversation.asEntity(): VkConversationEntity = VkConversationEntity(
photo100 = photo100,
photo200 = photo200,
isPhantom = isPhantom,
lastConversationMessageId = lastCmId,
lastCmId = lastCmId,
inReadCmId = inReadCmId,
outReadCmId = outReadCmId,
inRead = inRead,
@@ -16,7 +16,7 @@ data class VkMessage(
val action: Action?,
val actionMemberId: Long?,
val actionText: String?,
val actionConversationMessageId: Long?,
val actionCmId: Long?,
val actionMessage: String?,
val updateTime: Int?,
@@ -44,9 +44,9 @@ data class VkMessage(
fun isGroup() = fromId < 0
fun isRead(conversation: VkConversation): Boolean = when {
fun isRead(convo: VkConvo): Boolean = when {
id <= 0 -> false
else -> conversation.isRead(this)
else -> convo.isRead(this)
}
fun hasAttachments(): Boolean = attachments.orEmpty().isNotEmpty()
@@ -98,7 +98,7 @@ data class VkMessage(
fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
id = id,
conversationMessageId = cmId,
cmId = cmId,
text = text,
isOut = isOut,
peerId = peerId,
@@ -108,7 +108,7 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
action = action?.value,
actionMemberId = actionMemberId,
actionText = actionText,
actionConversationMessageId = actionConversationMessageId,
actionCmId = actionCmId,
actionMessage = actionMessage,
updateTime = updateTime,
important = isImportant,
@@ -1,12 +1,12 @@
package dev.meloda.fast.model.api.requests
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.ConvosFilter
data class ConversationsGetRequest(
data class ConvosGetRequest(
val count: Int? = null,
val offset: Int? = null,
val fields: String = "",
val filter: ConversationsFilter = ConversationsFilter.ALL,
val filter: ConvosFilter = ConvosFilter.ALL,
val extended: Boolean? = true,
val startMessageId: Long? = null
) {
@@ -115,7 +115,7 @@ data class MessagesGetLongPollServerRequest(
data class MessagesPinMessageRequest(
val peerId: Long,
val messageId: Long? = null,
val conversationMessageId: Long? = null
val cmId: Long? = null
) {
val map: Map<String, String>
@@ -123,7 +123,7 @@ data class MessagesPinMessageRequest(
"peer_id" to peerId.toString()
).apply {
messageId?.let { this["message_id"] = it.toString() }
conversationMessageId?.let { this["conversation_message_id"] = it.toString() }
cmId?.let { this["conversation_message_id"] = it.toString() }
}
}
@@ -136,7 +136,7 @@ data class MessagesUnpinMessageRequest(val peerId: Long) {
data class MessagesDeleteRequest(
val peerId: Long,
val messagesIds: List<Long>? = null,
val conversationsMessagesIds: List<Long>? = null,
val cmIds: List<Long>? = null,
val isSpam: Boolean? = null,
val deleteForAll: Boolean? = null
) {
@@ -149,7 +149,7 @@ data class MessagesDeleteRequest(
deleteForAll?.let { this["delete_for_all"] = it.asInt().toString() }
messagesIds?.let { this["message_ids"] = it.joinToString() }
conversationsMessagesIds?.let {
cmIds?.let {
this["conversation_message_ids"] = it.joinToString()
}
}
@@ -228,7 +228,7 @@ data class MessagesGetChatRequest(
}
data class MessagesGetConversationMembersRequest(
data class MessagesGetConvoMembersRequest(
val peerId: Long,
val offset: Int? = null,
val count: Int? = null,
@@ -267,14 +267,14 @@ data class MessagesGetHistoryAttachmentsRequest(
val offset: Int?,
val preserveOrder: Boolean?,
val attachmentTypes: List<String>,
val conversationMessageId: Long,
val cmId: Long,
val fields: String?
) {
val map = mutableMapOf(
"peer_id" to peerId.toString(),
"attachment_types" to attachmentTypes.joinToString(","),
"cmid" to conversationMessageId.toString()
"cmid" to cmId.toString()
).apply {
extended?.let { this["extended"] = it.toString() }
count?.let { this["count"] = it.toString() }
@@ -3,15 +3,15 @@ package dev.meloda.fast.model.api.responses
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkConversationData
import dev.meloda.fast.model.api.data.VkConvoData
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.data.VkUserData
@JsonClass(generateAdapter = true)
data class ConversationsGetResponse(
data class ConvosGetResponse(
@Json(name = "count") val count: Int,
@Json(name = "items") val items: List<ConversationsResponseItem>,
@Json(name = "items") val items: List<ConvosResponseItem>,
@Json(name = "unread_count") val unreadCount: Int?,
@Json(name = "profiles") val profiles: List<VkUserData>?,
@Json(name = "groups") val groups: List<VkGroupData>?,
@@ -19,21 +19,21 @@ data class ConversationsGetResponse(
)
@JsonClass(generateAdapter = true)
data class ConversationsGetByIdResponse(
data class ConvosGetByIdResponse(
@Json(name = "count") val count: Int,
@Json(name = "items") val items: List<VkConversationData>,
@Json(name = "items") val items: List<VkConvoData>,
@Json(name = "profiles") val profiles: List<VkUserData>?,
@Json(name = "groups") val groups: List<VkGroupData>?,
@Json(name = "contacts") val contacts: List<VkContactData>?
)
@JsonClass(generateAdapter = true)
data class ConversationsResponseItem(
@Json(name = "conversation") val conversation: VkConversationData,
data class ConvosResponseItem(
@Json(name = "conversation") val convo: VkConvoData,
@Json(name = "last_message") val lastMessage: VkMessageData?
)
@JsonClass(generateAdapter = true)
data class ConversationsDeleteResponse(
data class ConvosDeleteResponse(
@Json(name = "last_deleted_id") val lastDeletedId: Long
)
@@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData
import dev.meloda.fast.model.api.data.VkChatMemberData
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkConversationData
import dev.meloda.fast.model.api.data.VkConvoData
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.data.VkUserData
@@ -14,7 +14,7 @@ import dev.meloda.fast.model.api.data.VkUserData
data class MessagesGetHistoryResponse(
val count: Int,
val items: List<VkMessageData>,
val conversations: List<VkConversationData>?,
val convos: List<VkConvoData>?,
val profiles: List<VkUserData>?,
val groups: List<VkGroupData>?,
val contacts: List<VkContactData>?
@@ -30,7 +30,7 @@ data class MessagesGetByIdResponse(
)
@JsonClass(generateAdapter = true)
data class MessagesGetConversationMembersResponse(
data class MessagesGetConvoMembersResponse(
val count: Int,
val items: List<VkChatMemberData>?,
val profiles: List<VkUserData>?,
@@ -3,8 +3,8 @@ package dev.meloda.fast.model.database
import androidx.room.Embedded
import androidx.room.Relation
data class ConversationWithMessage(
@Embedded val conversation: VkConversationEntity,
data class ConvoWithMessage(
@Embedded val convo: VkConvoEntity,
@Relation(
parentColumn = "lastMessageId",
entityColumn = "id"
@@ -3,10 +3,10 @@ package dev.meloda.fast.model.database
import androidx.room.Entity
import androidx.room.PrimaryKey
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkConvo
@Entity(tableName = "conversations")
data class VkConversationEntity(
@Entity(tableName = "convos")
data class VkConvoEntity(
@PrimaryKey val id: Long,
val localId: Long,
val ownerId: Long?,
@@ -15,7 +15,7 @@ data class VkConversationEntity(
val photo100: String?,
val photo200: String?,
val isPhantom: Boolean,
val lastConversationMessageId: Long,
val lastCmId: Long,
val inReadCmId: Long,
val outReadCmId: Long,
val inRead: Long,
@@ -32,7 +32,7 @@ data class VkConversationEntity(
val isArchived: Boolean
)
fun VkConversationEntity.asExternalModel(): VkConversation = VkConversation(
fun VkConvoEntity.asExternalModel(): VkConvo = VkConvo(
id = id,
localId = localId,
ownerId = ownerId,
@@ -42,7 +42,7 @@ fun VkConversationEntity.asExternalModel(): VkConversation = VkConversation(
photo200 = photo200,
isCallInProgress = false,
isPhantom = isPhantom,
lastCmId = lastConversationMessageId,
lastCmId = lastCmId,
inReadCmId = inReadCmId,
outReadCmId = outReadCmId,
inRead = inRead,
@@ -8,7 +8,7 @@ import dev.meloda.fast.model.api.domain.VkUnknownAttachment
@Entity(tableName = "messages")
data class VkMessageEntity(
@PrimaryKey val id: Long,
val conversationMessageId: Long,
val cmId: Long,
val text: String?,
val isOut: Boolean,
val peerId: Long,
@@ -18,7 +18,7 @@ data class VkMessageEntity(
val action: String?,
val actionMemberId: Long?,
val actionText: String?,
val actionConversationMessageId: Long?,
val actionCmId: Long?,
val actionMessage: String?,
val updateTime: Int?,
val important: Boolean,
@@ -32,7 +32,7 @@ data class VkMessageEntity(
fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
id = id,
cmId = conversationMessageId,
cmId = cmId,
text = text,
isOut = isOut,
peerId = peerId,
@@ -42,7 +42,7 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
action = VkMessage.Action.parse(action),
actionMemberId = actionMemberId,
actionText = actionText,
actionConversationMessageId = actionConversationMessageId,
actionCmId = actionCmId,
actionMessage = actionMessage,
updateTime = updateTime,
isImportant = important,
@@ -16,7 +16,7 @@ import dev.meloda.fast.network.interceptor.VersionInterceptor
import dev.meloda.fast.network.service.account.AccountService
import dev.meloda.fast.network.service.audios.AudiosService
import dev.meloda.fast.network.service.auth.AuthService
import dev.meloda.fast.network.service.conversations.ConversationsService
import dev.meloda.fast.network.service.convos.ConvosService
import dev.meloda.fast.network.service.files.FilesService
import dev.meloda.fast.network.service.friends.FriendsService
import dev.meloda.fast.network.service.longpoll.LongPollService
@@ -80,7 +80,7 @@ val networkModule = module {
single { service(AccountService::class.java) }
single { service(AudiosService::class.java) }
single { service(ConversationsService::class.java) }
single { service(ConvosService::class.java) }
single { service(FilesService::class.java) }
single { service(LongPollService::class.java) }
single { service(MessagesService::class.java) }
@@ -1,61 +1,61 @@
package dev.meloda.fast.network.service.conversations
package dev.meloda.fast.network.service.convos
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.responses.ConversationsDeleteResponse
import dev.meloda.fast.model.api.responses.ConversationsGetByIdResponse
import dev.meloda.fast.model.api.responses.ConversationsGetResponse
import dev.meloda.fast.model.api.responses.ConvosDeleteResponse
import dev.meloda.fast.model.api.responses.ConvosGetByIdResponse
import dev.meloda.fast.model.api.responses.ConvosGetResponse
import dev.meloda.fast.network.ApiResponse
import dev.meloda.fast.network.RestApiError
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
interface ConversationsService {
interface ConvosService {
@FormUrlEncoded
@POST(ConversationsUrls.GET)
suspend fun getConversations(
@POST(ConvosUrls.GET)
suspend fun getConvos(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<ConversationsGetResponse>, RestApiError>
): ApiResult<ApiResponse<ConvosGetResponse>, RestApiError>
@FormUrlEncoded
@POST(ConversationsUrls.GET_BY_ID)
suspend fun getConversationsById(
@POST(ConvosUrls.GET_BY_ID)
suspend fun getConvosById(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<ConversationsGetByIdResponse>, RestApiError>
): ApiResult<ApiResponse<ConvosGetByIdResponse>, RestApiError>
@FormUrlEncoded
@POST(ConversationsUrls.DELETE)
@POST(ConvosUrls.DELETE)
suspend fun delete(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<ConversationsDeleteResponse>, RestApiError>
): ApiResult<ApiResponse<ConvosDeleteResponse>, RestApiError>
@FormUrlEncoded
@POST(ConversationsUrls.PIN)
@POST(ConvosUrls.PIN)
suspend fun pin(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError>
@FormUrlEncoded
@POST(ConversationsUrls.UNPIN)
@POST(ConvosUrls.UNPIN)
suspend fun unpin(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError>
@FormUrlEncoded
@POST(ConversationsUrls.REORDER_PINNED)
@POST(ConvosUrls.REORDER_PINNED)
suspend fun reorderPinned(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError>
@FormUrlEncoded
@POST(ConversationsUrls.ARCHIVE)
@POST(ConvosUrls.ARCHIVE)
suspend fun archive(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError>
@FormUrlEncoded
@POST(ConversationsUrls.UNARCHIVE)
@POST(ConvosUrls.UNARCHIVE)
suspend fun unarchive(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError>
@@ -1,8 +1,8 @@
package dev.meloda.fast.network.service.conversations
package dev.meloda.fast.network.service.convos
import dev.meloda.fast.common.AppConstants
object ConversationsUrls {
object ConvosUrls {
private const val URL = AppConstants.URL_API
@@ -6,7 +6,7 @@ import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
@@ -99,10 +99,10 @@ interface MessagesService {
): ApiResult<ApiResponse<VkChatData>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.GET_CONVERSATIONS_MEMBERS)
suspend fun getConversationMembers(
@POST(MessagesUrls.GET_CONVOS_MEMBERS)
suspend fun getConvoMembers(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesGetConversationMembersResponse>, RestApiError>
): ApiResult<ApiResponse<MessagesGetConvoMembersResponse>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.REMOVE_CHAT_USER)
@@ -18,7 +18,7 @@ object MessagesUrls {
const val GET_BY_ID = "$URL/messages.getById"
const val MARK_AS_READ = "$URL/messages.markAsRead"
const val GET_CHAT = "$URL/messages.getChat"
const val GET_CONVERSATIONS_MEMBERS = "$URL/messages.getConversationMembers"
const val GET_CONVOS_MEMBERS = "$URL/messages.getConversationMembers"
const val REMOVE_CHAT_USER = "$URL/messages.removeChatUser"
const val GET_HISTORY_ATTACHMENTS = "$URL/messages.getHistoryAttachments"
const val CREATE_CHAT = "$URL/messages.createChat"
@@ -1,4 +1,4 @@
package dev.meloda.fast.ui.model.api
package dev.meloda.fast.ui.model.vk
enum class ActionState {
PHANTOM, CALL_IN_PROGRESS, NONE;
@@ -1,41 +1,41 @@
package dev.meloda.fast.ui.model.api
package dev.meloda.fast.ui.model.vk
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.ui.R
sealed class ConversationOption(
sealed class ConvoOption(
val title: UiText,
val icon: UiImage
) {
data object MarkAsRead : ConversationOption(
data object MarkAsRead : ConvoOption(
title = UiText.Resource(R.string.action_mark_as_read),
icon = UiImage.Resource(R.drawable.round_done_all_24)
)
data object Pin : ConversationOption(
data object Pin : ConvoOption(
title = UiText.Resource(R.string.action_pin),
icon = UiImage.Resource(R.drawable.pin_outline_24)
)
data object Unpin : ConversationOption(
data object Unpin : ConvoOption(
title = UiText.Resource(R.string.action_unpin),
icon = UiImage.Resource(R.drawable.pin_off_outline_24)
)
data object Delete : ConversationOption(
data object Delete : ConvoOption(
title = UiText.Resource(R.string.action_delete),
icon = UiImage.Resource(R.drawable.round_delete_outline_24)
)
data object Archive : ConversationOption(
title = UiText.Resource(R.string.conversation_context_action_archive),
data object Archive : ConvoOption(
title = UiText.Resource(R.string.convo_context_action_archive),
icon = UiImage.Resource(R.drawable.outline_archive_24)
)
data object Unarchive : ConversationOption(
title = UiText.Resource(R.string.conversation_context_action_unarchive),
data object Unarchive : ConvoOption(
title = UiText.Resource(R.string.convo_context_action_unarchive),
icon = UiImage.Resource(R.drawable.outline_unarchive_24)
)
}
@@ -0,0 +1,7 @@
package dev.meloda.fast.ui.model.vk
data class MentionIndex(
val id: Long,
val idPrefix: String,
val indexRange: IntRange
)
@@ -1,4 +1,4 @@
package dev.meloda.fast.messageshistory.model
package dev.meloda.fast.ui.model.vk
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
@@ -6,7 +6,8 @@ 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(
@Stable
sealed class MessageUiItem(
open val id: Long,
open val cmId: Long
) {
@@ -35,8 +36,8 @@ sealed class UiItem(
val attachments: ImmutableList<VkAttachment>?,
val replyCmId: Long?,
val replyTitle: String?,
val replySummary: String?
) : UiItem(id, cmId)
val replySummary: AnnotatedString?
) : MessageUiItem(id, cmId)
@Stable
data class ActionMessage(
@@ -44,5 +45,5 @@ sealed class UiItem(
override val cmId: Long,
val text: AnnotatedString,
val actionCmId: Long?
) : UiItem(id, cmId)
) : MessageUiItem(id, cmId)
}
@@ -0,0 +1,5 @@
package dev.meloda.fast.ui.model.vk
enum class SendingStatus {
SENDING, SENT, FAILED
}
@@ -1,4 +1,4 @@
package dev.meloda.fast.ui.model.api
package dev.meloda.fast.ui.model.vk
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
@@ -8,7 +8,7 @@ import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.util.ImmutableList
@Immutable
data class UiConversation(
data class UiConvo(
val id: Long,
val lastMessageId: Long?,
val avatar: UiImage?,
@@ -28,5 +28,5 @@ data class UiConversation(
val interactionText: String?,
val isExpanded: Boolean,
val isArchived: Boolean,
val options: ImmutableList<ConversationOption>,
val options: ImmutableList<ConvoOption>,
)
@@ -1,4 +1,4 @@
package dev.meloda.fast.ui.model.api
package dev.meloda.fast.ui.model.vk
import androidx.compose.runtime.Immutable
import dev.meloda.fast.common.model.UiImage
@@ -11,6 +11,10 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.onKeyEvent
@@ -121,3 +125,14 @@ fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean {
return appForceDarkMode || (appBatterySaver && isSystemBatterySaver) || (!appBatterySaver && isSystemUsingDarkTheme && darkMode == DarkMode.FOLLOW_SYSTEM)
}
fun Color.lighten(amount: Float) = lerp(this, Color.White, amount.coerceIn(0f, 1f))
fun Color.darken(amount: Float) = lerp(this, Color.Black, amount.coerceIn(0f, 1f))
fun Color.isDark(
background: Color = Color.White,
threshold: Float = 0.5f
): Boolean {
val opaque = if (alpha < 1f) this.compositeOver(background) else this
return opaque.luminance() < threshold
}
+11 -11
View File
@@ -24,16 +24,16 @@
<string name="message_mark_as_spam">Пометить как спам</string>
<string name="action_mark_as_read">Прочитать</string>
<string name="action_delete">Удалить</string>
<string name="conversation_context_action_unarchive">Из архива</string>
<string name="conversation_context_action_delete">Удалить</string>
<string name="confirm_delete_conversation">Удалить чат?</string>
<string name="convo_context_action_unarchive">Из архива</string>
<string name="convo_context_action_delete">Удалить</string>
<string name="confirm_delete_convo">Удалить чат?</string>
<string name="action_sign_out">Выйти</string>
<string name="sign_out_confirm_title">Выйти?</string>
<string name="conversation_context_action_unpin">Открепить</string>
<string name="conversation_context_action_pin">Закрепить</string>
<string name="confirm_unpin_conversation">Открепить чат?</string>
<string name="confirm_pin_conversation">Закрепить чат?</string>
<string name="confirm_unarchive_conversation">Разархивировать чат?</string>
<string name="convo_context_action_unpin">Открепить</string>
<string name="convo_context_action_pin">Закрепить</string>
<string name="confirm_unpin_convo">Открепить чат?</string>
<string name="confirm_pin_convo">Закрепить чат?</string>
<string name="confirm_unarchive_convo">Разархивировать чат?</string>
<string name="action_pin">Закрепить</string>
<string name="action_unpin">Открепить</string>
<string name="action_mark">Пометить</string>
@@ -181,7 +181,7 @@
<string name="settings_amoled_dark_theme_description">Использовать AMOLED тему с чистым чёрным фоновым цветом, когда используется тёмная тема</string>
<string name="members_count">Участники: %1$d</string>
<string name="title_loading">Загрузка&#8230;</string>
<string name="title_conversations">Чаты</string>
<string name="title_convos">Чаты</string>
<string name="title_archive">Архив</string>
<string name="title_friends">Друзья</string>
<string name="title_profile">Профиль</string>
@@ -262,8 +262,8 @@
<string name="unspam_message_text">Вы уверены, что хотите убрать пометку спама у этого сообщения?</string>
<string name="pin_message_title">Закрепить сообщение</string>
<string name="copied_to_clipboard">Скопировано в буфер обмена</string>
<string name="conversation_context_action_archive">В архив</string>
<string name="confirm_archive_conversation">Архивировать чат?</string>
<string name="convo_context_action_archive">В архив</string>
<string name="confirm_archive_convo">Архивировать чат?</string>
<string name="action_archive">В архив</string>
<string name="autofill">Автозаполнение</string>
<string name="bold">Жирный</string>
+11 -11
View File
@@ -153,18 +153,18 @@
<string name="action_mark_as_read">Read</string>
<string name="action_delete">Delete</string>
<string name="conversation_context_action_archive">Archive</string>
<string name="conversation_context_action_unarchive">Unarchive</string>
<string name="conversation_context_action_delete">Delete</string>
<string name="confirm_delete_conversation">Delete the conversation?</string>
<string name="convo_context_action_archive">Archive</string>
<string name="convo_context_action_unarchive">Unarchive</string>
<string name="convo_context_action_delete">Delete</string>
<string name="confirm_delete_convo">Delete the conversation?</string>
<string name="action_sign_out">Sign out</string>
<string name="sign_out_confirm_title">Sign out?</string>
<string name="conversation_context_action_unpin">Unpin</string>
<string name="conversation_context_action_pin">Pin</string>
<string name="confirm_unpin_conversation">Unpin the conversation?</string>
<string name="confirm_pin_conversation">Pin the conversation?</string>
<string name="confirm_archive_conversation">Archive the conversation?</string>
<string name="confirm_unarchive_conversation">Unarchive the conversation?</string>
<string name="convo_context_action_unpin">Unpin</string>
<string name="convo_context_action_pin">Pin</string>
<string name="confirm_unpin_convo">Unpin the conversation?</string>
<string name="confirm_pin_convo">Pin the conversation?</string>
<string name="confirm_archive_convo">Archive the conversation?</string>
<string name="confirm_unarchive_convo">Unarchive the conversation?</string>
<string name="action_pin">Pin</string>
<string name="action_unpin">Unpin</string>
<string name="action_mark">Mark</string>
@@ -245,7 +245,7 @@
<string name="action_refresh">Refresh</string>
<string name="members_count">Members: %1$d</string>
<string name="title_loading">Loading&#8230;</string>
<string name="title_conversations">Conversations</string>
<string name="title_convos">Conversations</string>
<string name="title_archive">Archive</string>
<string name="title_friends">Friends</string>
<string name="title_profile">Profile</string>
@@ -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()
}
@@ -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

Some files were not shown because too many files have changed in this diff Show More