pinned message in messages history draft

This commit is contained in:
2025-03-27 12:16:26 +03:00
parent f66123ba94
commit 9aa85d40c6
24 changed files with 330 additions and 84 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Unofficial messenger for russian social network VKontakte
- [ ] Link - [ ] Link
- [ ] TODO - [ ] TODO
- [x] Send messages - [x] Send messages
- [ ] Pinned message - [x] Pinned message
- [ ] Pin & unpin messages - [ ] Pin & unpin messages
- [ ] Reply to message - [ ] Reply to message
- [ ] Delete message - [ ] Delete message
@@ -46,20 +46,22 @@ interface MessagesRepository {
title: String? title: String?
): ApiResult<Int, RestApiErrorDomain> ): ApiResult<Int, RestApiErrorDomain>
suspend fun pin(
peerId: Int,
messageId: Int?,
conversationMessageId: Int?
): ApiResult<VkMessage, RestApiErrorDomain>
suspend fun unpin(
peerId: Int
): ApiResult<Int, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
// suspend fun markAsImportant( // suspend fun markAsImportant(
// params: MessagesMarkAsImportantRequest // params: MessagesMarkAsImportantRequest
// ): ApiResult<List<Int>, RestApiErrorDomain> // ): ApiResult<List<Int>, RestApiErrorDomain>
// //
// suspend fun pin(
// params: MessagesPinMessageRequest
// ): ApiResult<VkMessageData, RestApiErrorDomain>
//
// suspend fun unpin(
// params: MessagesUnPinMessageRequest
// ): ApiResult<Unit, RestApiErrorDomain>
//
// suspend fun delete( // suspend fun delete(
// params: MessagesDeleteRequest // params: MessagesDeleteRequest
// ): ApiResult<Unit, RestApiErrorDomain> // ): ApiResult<Unit, RestApiErrorDomain>
@@ -20,7 +20,9 @@ import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
import dev.meloda.fast.model.api.requests.MessagesMarkAsReadRequest import dev.meloda.fast.model.api.requests.MessagesMarkAsReadRequest
import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest import dev.meloda.fast.model.api.requests.MessagesSendRequest
import dev.meloda.fast.model.api.requests.MessagesUnPinMessageRequest
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
@@ -216,6 +218,32 @@ class MessagesRepositoryImpl(
) )
} }
override suspend fun pin(
peerId: Int,
messageId: Int?,
conversationMessageId: Int?
): ApiResult<VkMessage, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesPinMessageRequest(
peerId = peerId,
messageId = messageId,
conversationMessageId = conversationMessageId
)
messagesService.pin(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().asDomain()
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun unpin(
peerId: Int
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesUnPinMessageRequest(peerId = peerId)
messagesService.unpin(requestModel.map).mapApiDefault()
}
override suspend fun storeMessages(messages: List<VkMessage>) { override suspend fun storeMessages(messages: List<VkMessage>) {
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
} }
@@ -229,24 +257,6 @@ class MessagesRepositoryImpl(
// ) // )
// } // }
// //
// override suspend fun pin(
// params: MessagesPinMessageRequest
// ): ApiResult<VkMessageData, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.pin(params.map).mapResult(
// successMapper = { response -> response.requireResponse() },
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun unpin(
// params: MessagesUnPinMessageRequest
// ): ApiResult<Unit, RestApiErrorDomain> = withContext(Dispatchers.IO) {
// messagesService.unpin(params.map).mapResult(
// successMapper = {},
// errorMapper = { error -> error?.toDomain() }
// )
// }
//
// override suspend fun delete( // override suspend fun delete(
// params: MessagesDeleteRequest // params: MessagesDeleteRequest
// ): ApiResult<Unit, RestApiErrorDomain> = withContext(Dispatchers.IO) { // ): ApiResult<Unit, RestApiErrorDomain> = withContext(Dispatchers.IO) {
@@ -293,4 +303,3 @@ class MessagesRepositoryImpl(
// ) // )
// } // }
} }
@@ -21,7 +21,7 @@ import dev.meloda.fast.model.database.VkUserEntity
VkConversationEntity::class VkConversationEntity::class
], ],
version = 7 version = 8
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
@@ -2,6 +2,7 @@ package dev.meloda.fast.database.di
import androidx.room.Room import androidx.room.Room
import dev.meloda.fast.database.AccountsDatabase import dev.meloda.fast.database.AccountsDatabase
import dev.meloda.fast.database.CacheDatabase
import org.koin.core.scope.Scope import org.koin.core.scope.Scope
import org.koin.dsl.module import org.koin.dsl.module
@@ -12,7 +13,7 @@ val databaseModule = module {
single { get<AccountsDatabase>().accountDao() } single { get<AccountsDatabase>().accountDao() }
single { single {
Room.databaseBuilder(get(), dev.meloda.fast.database.CacheDatabase::class.java, "cache") Room.databaseBuilder(get(), CacheDatabase::class.java, "cache")
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
} }
@@ -22,4 +23,4 @@ val databaseModule = module {
single { cacheDB().conversationDao() } single { cacheDB().conversationDao() }
} }
private fun Scope.cacheDB(): dev.meloda.fast.database.CacheDatabase = get() private fun Scope.cacheDB(): CacheDatabase = get()
@@ -47,6 +47,16 @@ interface MessagesUseCase {
title: String? title: String?
): Flow<State<Int>> ): Flow<State<Int>>
fun pin(
peerId: Int,
messageId: Int?,
conversationMessageId: Int?
): Flow<State<VkMessage>>
fun unpin(
peerId: Int
): Flow<State<Int>>
suspend fun storeMessage(message: VkMessage) suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
} }
@@ -108,6 +108,30 @@ class MessagesUseCaseImpl(
emit(newState) emit(newState)
} }
override fun pin(
peerId: Int,
messageId: Int?,
conversationMessageId: Int?
): Flow<State<VkMessage>> = flow {
emit(State.Loading)
val newState = repository.pin(
peerId = peerId,
messageId = messageId,
conversationMessageId = conversationMessageId
).mapToState()
emit(newState)
}
override fun unpin(peerId: Int): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.unpin(peerId = peerId).mapToState()
emit(newState)
}
override suspend fun storeMessage(message: VkMessage) { override suspend fun storeMessage(message: VkMessage) {
repository.storeMessages(listOf(message)) repository.storeMessages(listOf(message))
} }
@@ -1,8 +1,8 @@
package dev.meloda.fast.model.api.data package dev.meloda.fast.model.api.data
import dev.meloda.fast.model.api.domain.VkMessage
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkMessage
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkMessageData( data class VkMessageData(
@@ -23,7 +23,9 @@ data class VkMessageData(
@Json(name = "action") val action: Action?, @Json(name = "action") val action: Action?,
@Json(name = "ttl") val ttl: Int?, @Json(name = "ttl") val ttl: Int?,
@Json(name = "reply_message") val replyMessage: VkMessageData?, @Json(name = "reply_message") val replyMessage: VkMessageData?,
@Json(name = "update_time") val updateTime: Int? @Json(name = "update_time") val updateTime: Int?,
@Json(name = "is_pinned") val isPinned: Boolean?,
@Json(name = "pinned_at") val pinnedAt: Int?
) { ) {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@@ -72,7 +74,7 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
actionConversationMessageId = action?.conversationMessageId, actionConversationMessageId = action?.conversationMessageId,
actionMessage = action?.message, actionMessage = action?.message,
geoType = geo?.type, geoType = geo?.type,
important = important, isImportant = important,
updateTime = updateTime, updateTime = updateTime,
forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain), forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain),
attachments = attachments.map(VkAttachmentItemData::toDomain), attachments = attachments.map(VkAttachmentItemData::toDomain),
@@ -81,4 +83,6 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
group = null, group = null,
actionUser = null, actionUser = null,
actionGroup = null, actionGroup = null,
pinnedAt = pinnedAt,
isPinned = isPinned == true
) )
@@ -41,7 +41,7 @@ data class VkPinnedMessageData(
actionConversationMessageId = action?.conversationMessageId, actionConversationMessageId = action?.conversationMessageId,
actionMessage = action?.message, actionMessage = action?.message,
geoType = geo?.type, geoType = geo?.type,
important = important, isImportant = important,
updateTime = updateTime, updateTime = updateTime,
forwards = forwards.orEmpty().map(VkMessageData::asDomain), forwards = forwards.orEmpty().map(VkMessageData::asDomain),
@@ -52,6 +52,7 @@ data class VkPinnedMessageData(
group = null, group = null,
actionUser = null, actionUser = null,
actionGroup = null, actionGroup = null,
pinnedAt = null,
isPinned = true,
) )
} }
@@ -18,8 +18,9 @@ data class VkMessage(
val actionMessage: String?, val actionMessage: String?,
val updateTime: Int?, val updateTime: Int?,
val pinnedAt: Int?,
val important: Boolean = false, val isPinned: Boolean,
val isImportant: Boolean = false,
val forwards: List<VkMessage>?, val forwards: List<VkMessage>?,
val attachments: List<VkAttachment>?, val attachments: List<VkAttachment>?,
@@ -91,10 +92,12 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
actionConversationMessageId = actionConversationMessageId, actionConversationMessageId = actionConversationMessageId,
actionMessage = actionMessage, actionMessage = actionMessage,
updateTime = updateTime, updateTime = updateTime,
important = important, important = isImportant,
forwardIds = forwards.orEmpty().map(VkMessage::id), forwardIds = forwards.orEmpty().map(VkMessage::id),
// TODO: 05/05/2024, Danil Nikolaev: save attachments // TODO: 05/05/2024, Danil Nikolaev: save attachments
attachments = emptyList(), attachments = emptyList(),
replyMessageId = replyMessage?.id, replyMessageId = replyMessage?.id,
geoType = geoType geoType = geoType,
pinnedAt = pinnedAt,
isPinned = isPinned,
) )
@@ -25,7 +25,9 @@ data class VkMessageEntity(
val forwardIds: List<Int>?, val forwardIds: List<Int>?,
val attachments: List<String>?, // TODO: 01/05/2024, Danil Nikolaev: how to store??? val attachments: List<String>?, // TODO: 01/05/2024, Danil Nikolaev: how to store???
val replyMessageId: Int?, val replyMessageId: Int?,
val geoType: String? val geoType: String?,
val pinnedAt: Int?,
val isPinned: Boolean
) )
fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage( fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
@@ -43,7 +45,7 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
actionConversationMessageId = actionConversationMessageId, actionConversationMessageId = actionConversationMessageId,
actionMessage = actionMessage, actionMessage = actionMessage,
updateTime = updateTime, updateTime = updateTime,
important = important, isImportant = important,
forwards = emptyList(),//forwards.orEmpty().map(VkMessageEntity::asExternalModel), forwards = emptyList(),//forwards.orEmpty().map(VkMessageEntity::asExternalModel),
// TODO: 05/05/2024, Danil Nikolaev: restore attachments // TODO: 05/05/2024, Danil Nikolaev: restore attachments
attachments = attachments.orEmpty().map { VkUnknownAttachment }, attachments = attachments.orEmpty().map { VkUnknownAttachment },
@@ -53,4 +55,6 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
group = null, group = null,
actionUser = null, actionUser = null,
actionGroup = null, actionGroup = null,
pinnedAt = pinnedAt,
isPinned = isPinned
) )
@@ -2,6 +2,7 @@ package dev.meloda.fast.network.service.messages
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.data.VkLongPollData 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.MessagesCreateChatResponse
import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
@@ -56,6 +57,18 @@ interface MessagesService {
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesCreateChatResponse>, RestApiError> ): ApiResult<ApiResponse<MessagesCreateChatResponse>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.PIN)
suspend fun pin(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<VkMessageData>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.UNPIN)
suspend fun unpin(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<Int>, RestApiError>
// @FormUrlEncoded // @FormUrlEncoded
// @POST(MessagesUrls.MarkAsImportant) // @POST(MessagesUrls.MarkAsImportant)
// suspend fun markAsImportant( // suspend fun markAsImportant(
@@ -63,18 +76,6 @@ interface MessagesService {
// ): ApiResult<ApiResponse<List<Int>>, RestApiError> // ): ApiResult<ApiResponse<List<Int>>, RestApiError>
// //
// @FormUrlEncoded // @FormUrlEncoded
// @POST(MessagesUrls.Pin)
// suspend fun pin(
// @FieldMap params: Map<String, String>
// ): ApiResult<ApiResponse<VkMessageData>, RestApiError>
//
// @FormUrlEncoded
// @POST(MessagesUrls.Unpin)
// suspend fun unpin(
// @FieldMap params: Map<String, String>
// ): ApiResult<ApiResponse<Unit>, RestApiError>
//
// @FormUrlEncoded
// @POST(MessagesUrls.Delete) // @POST(MessagesUrls.Delete)
// suspend fun delete( // suspend fun delete(
// @FieldMap params: Map<String, String> // @FieldMap params: Map<String, String>
+5 -1
View File
@@ -6,13 +6,16 @@
<string name="yes">Да</string> <string name="yes">Да</string>
<string name="no">Нет</string> <string name="no">Нет</string>
<string name="message_context_action_reply">Ответить</string> <string name="message_context_action_reply">Ответить</string>
<string name="message_context_action_forward">Переслать</string>
<string name="message_context_action_mark_as_important">Пометить как важное</string> <string name="message_context_action_mark_as_important">Пометить как важное</string>
<string name="message_context_action_unmark_as_important">Помететить как не важное</string> <string name="message_context_action_unmark_as_important">Пометить как не важное</string>
<string name="time_format">Время: %1$s</string> <string name="time_format">Время: %1$s</string>
<string name="message_context_action_unmark_as_spam">Помеьиьб как не спам</string>
<string name="message_context_action_pin">Закрепить</string> <string name="message_context_action_pin">Закрепить</string>
<string name="message_context_action_unpin">Открепить</string> <string name="message_context_action_unpin">Открепить</string>
<string name="message_context_action_edit">Изменить</string> <string name="message_context_action_edit">Изменить</string>
<string name="message_context_action_delete">Удалить</string> <string name="message_context_action_delete">Удалить</string>
<string name="message_context_action_copy">Скопировать</string>
<string name="confirm_delete_message">Удалить сообщение?</string> <string name="confirm_delete_message">Удалить сообщение?</string>
<string name="message_delete_for_all">Для всех</string> <string name="message_delete_for_all">Для всех</string>
<string name="message_mark_as_spam">Пометить как спам</string> <string name="message_mark_as_spam">Пометить как спам</string>
@@ -229,4 +232,5 @@
<string name="chat_attachment_music">Музыка</string> <string name="chat_attachment_music">Музыка</string>
<string name="chat_attachment_files">Файлы</string> <string name="chat_attachment_files">Файлы</string>
<string name="chat_attachment_links">Ссылки</string> <string name="chat_attachment_links">Ссылки</string>
<string name="message_context_action_mark_as_spam">Пометить как спам</string>
</resources> </resources>
+6 -1
View File
@@ -124,14 +124,19 @@
<string name="sign_out_confirm">Signing out will delete all data related to this account from this device. Continue?</string> <string name="sign_out_confirm">Signing out will delete all data related to this account from this device. Continue?</string>
<string name="yes">Yes</string> <string name="yes">Yes</string>
<string name="no">No</string> <string name="no">No</string>
<string name="time_format">Time: %1$s</string>
<string name="message_context_action_reply">Reply</string> <string name="message_context_action_reply">Reply</string>
<string name="message_context_action_forward">Forward</string>
<string name="message_context_action_mark_as_important">Mark as important</string> <string name="message_context_action_mark_as_important">Mark as important</string>
<string name="message_context_action_unmark_as_important">Unmark as important</string> <string name="message_context_action_unmark_as_important">Unmark as important</string>
<string name="time_format">Time: %1$s</string> <string name="message_context_action_mark_as_spam">Mark as spam</string>
<string name="message_context_action_unmark_as_spam">Unmark as spam</string>
<string name="message_context_action_pin">Pin</string> <string name="message_context_action_pin">Pin</string>
<string name="message_context_action_unpin">Unpin</string> <string name="message_context_action_unpin">Unpin</string>
<string name="message_context_action_edit">Edit</string> <string name="message_context_action_edit">Edit</string>
<string name="message_context_action_delete">Delete</string> <string name="message_context_action_delete">Delete</string>
<string name="message_context_action_copy">Copy</string>
<string name="confirm_delete_message">Delete the message?</string> <string name="confirm_delete_message">Delete the message?</string>
@@ -170,6 +170,6 @@ class ChatMaterialsViewModelImpl(
} }
companion object { companion object {
const val LOAD_COUNT = 200 const val LOAD_COUNT = 100
} }
} }
@@ -1,8 +1,6 @@
package dev.meloda.fast.messageshistory package dev.meloda.fast.messageshistory
import android.content.Context
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
@@ -54,13 +52,18 @@ interface MessagesHistoryViewModel {
val screenState: StateFlow<MessagesHistoryScreenState> val screenState: StateFlow<MessagesHistoryScreenState>
val selectedMessages: StateFlow<List<Int>> val selectedMessages: StateFlow<List<Int>>
val isNeedToScrollToIndex: StateFlow<Int?>
val baseError: StateFlow<BaseError?> val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>> val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int> val showMessageOptions: StateFlow<VkMessage?>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean> val canPaginate: StateFlow<Boolean>
fun onScrolledToIndex()
fun onCloseButtonClicked() fun onCloseButtonClicked()
fun onRefresh() fun onRefresh()
fun onAttachmentButtonClicked() fun onAttachmentButtonClicked()
@@ -72,10 +75,12 @@ interface MessagesHistoryViewModel {
fun onMessageClicked(messageId: Int) fun onMessageClicked(messageId: Int)
fun onMessageLongClicked(messageId: Int) fun onMessageLongClicked(messageId: Int)
fun onMessageOptionsDialogDismissed()
fun onPinnedMessageClicked(messageId: Int)
fun onUnpinMessageClicked()
} }
class MessagesHistoryViewModelImpl( class MessagesHistoryViewModelImpl(
private val applicationContext: Context,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val conversationsUseCase: ConversationsUseCase, private val conversationsUseCase: ConversationsUseCase,
private val resourceProvider: ResourceProvider, private val resourceProvider: ResourceProvider,
@@ -88,9 +93,13 @@ class MessagesHistoryViewModelImpl(
override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY) override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY)
override val selectedMessages = MutableStateFlow<List<Int>>(emptyList()) override val selectedMessages = MutableStateFlow<List<Int>>(emptyList())
override val isNeedToScrollToIndex = MutableStateFlow<Int?>(null)
override val baseError = MutableStateFlow<BaseError?>(null) override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList()) override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
override val showMessageOptions = MutableStateFlow<VkMessage?>(null)
override val currentOffset = MutableStateFlow(0) override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false) override val canPaginate = MutableStateFlow(false)
@@ -120,6 +129,10 @@ class MessagesHistoryViewModelImpl(
) )
} }
override fun onScrolledToIndex() {
isNeedToScrollToIndex.setValue { null }
}
override fun onCloseButtonClicked() { override fun onCloseButtonClicked() {
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
@@ -211,7 +224,9 @@ class MessagesHistoryViewModelImpl(
} }
} }
} else { } else {
Toast.makeText(applicationContext, "Click", Toast.LENGTH_SHORT).show() messages.value.firstOrNull { it.id == currentMessage.id }?.let { message ->
showMessageOptions.setValue { message }
}
} }
} }
@@ -240,6 +255,62 @@ class MessagesHistoryViewModelImpl(
} }
} }
override fun onMessageOptionsDialogDismissed() {
showMessageOptions.setValue { null }
}
override fun onPinnedMessageClicked(messageId: Int) {
val messageIndex = screenState.value.messages.indexOfFirstOrNull {
it is UiItem.Message && it.id == messageId
}
if (messageIndex == null) { // сообщения нет в списке
// pizdets
} else {
isNeedToScrollToIndex.setValue { messageIndex }
}
}
override fun onUnpinMessageClicked() {
// TODO: 27.03.2025, Danil Nikolaev: confirmation alert
val pinnedMessageId = screenState.value.pinnedMessage?.id ?: return
unpinMessage(pinnedMessageId)
}
private fun unpinMessage(messageId: Int) {
val messageIndex = screenState.value.messages.indexOfFirstOrNull {
it is UiItem.Message && it.id == messageId
}
messagesUseCase.unpin(screenState.value.conversationId)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = {
var newState = screenState.value.copy(
pinnedMessage = null,
conversation = screenState.value.conversation.copy(
pinnedMessage = null,
pinnedMessageId = null
),
pinnedSummary = null,
pinnedTitle = null
)
if (messageIndex != null) {
val newMessages = screenState.value.messages.toMutableList()
val currentMessage: UiItem.Message =
newMessages[messageIndex] as UiItem.Message
newMessages[messageIndex] = currentMessage.copy(isPinned = false)
newState = newState.copy(messages = newMessages)
}
screenState.setValue { old -> newState }
}
)
}
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
@@ -543,7 +614,7 @@ class MessagesHistoryViewModelImpl(
actionConversationMessageId = null, actionConversationMessageId = null,
actionMessage = null, actionMessage = null,
updateTime = null, updateTime = null,
important = false, isImportant = false,
forwards = null, forwards = null,
attachments = null, attachments = null,
replyMessage = null, replyMessage = null,
@@ -551,7 +622,9 @@ class MessagesHistoryViewModelImpl(
user = VkMemoryCache.getUser(UserConfig.userId), user = VkMemoryCache.getUser(UserConfig.userId),
group = null, group = null,
actionUser = null, actionUser = null,
actionGroup = null actionGroup = null,
isPinned = false,
pinnedAt = null
) )
sendingMessages += newMessage sendingMessages += newMessage
@@ -24,8 +24,9 @@ sealed class UiItem(
val avatar: UiImage, val avatar: UiImage,
val isEdited: Boolean, val isEdited: Boolean,
val isRead: Boolean, val isRead: Boolean,
val sendingStatus: SendingStatus = SendingStatus.SENT, val sendingStatus: SendingStatus,
val isSelected: Boolean = false val isSelected: Boolean,
val isPinned: Boolean
) : UiItem(id, conversationMessageId) ) : UiItem(id, conversationMessageId)
data class ActionMessage( data class ActionMessage(
@@ -27,13 +27,15 @@ fun ActionMessageItem(
Text( Text(
text = item.text, text = item.text,
modifier = modifier modifier = modifier
.padding(horizontal = 32.dp) .padding(
horizontal = 32.dp,
vertical = 4.dp
)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.then( .then(
if (item.actionCmId != null) { if (item.actionCmId != null) {
Modifier.clickable(onClick = onClick) Modifier.clickable(onClick = onClick)
} } else Modifier
else Modifier
) )
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)) .background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
.fillMaxWidth() .fillMaxWidth()
@@ -1,6 +1,7 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -33,10 +34,10 @@ fun IncomingMessageBubble(
message: UiItem.Message, message: UiItem.Message,
animate: Boolean, animate: Boolean,
) { ) {
Row(modifier = modifier.fillMaxWidth()) { Row(modifier = modifier.fillMaxWidth().then(if (animate) Modifier.animateContentSize() else Modifier),) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.75f) .fillMaxWidth(0.85f)
.padding(start = 16.dp), .padding(start = 16.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Start horizontalArrangement = Arrangement.Start
@@ -82,7 +83,8 @@ fun IncomingMessageBubble(
edited = message.isEdited, edited = message.isEdited,
animate = animate, animate = animate,
isRead = message.isRead, isRead = message.isRead,
sendingStatus = message.sendingStatus sendingStatus = message.sendingStatus,
pinned = message.isPinned
) )
} }
} }
@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -40,7 +41,8 @@ fun MessageBubble(
edited: Boolean, edited: Boolean,
animate: Boolean, animate: Boolean,
isRead: Boolean, isRead: Boolean,
sendingStatus: SendingStatus sendingStatus: SendingStatus,
pinned: Boolean
) { ) {
val backgroundColor = if (!isOut) { val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
@@ -63,12 +65,14 @@ fun MessageBubble(
horizontal = 8.dp, horizontal = 8.dp,
vertical = 6.dp vertical = 6.dp
) )
.then(if (animate) Modifier.animateContentSize() else Modifier),
) { ) {
val minDateContainerWidth = remember(edited, isOut) { val minDateContainerWidth = remember(edited, isOut) {
val mainPart = if (edited) 50.dp else 30.dp val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp val readIndicatorPart = if (isOut) 14.dp else 0.dp
val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp
mainPart + readIndicatorPart mainPart + readIndicatorPart + pinnedIndicatorPart
} }
val dateContainerWidth by animateDpAsState( val dateContainerWidth by animateDpAsState(
@@ -94,7 +98,18 @@ fun MessageBubble(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.defaultMinSize(minWidth = dateContainerWidth) .defaultMinSize(minWidth = dateContainerWidth)
.then(if (animate) Modifier.animateContentSize() else Modifier),
) { ) {
if (pinned) {
Icon(
painter = painterResource(UiR.drawable.ic_round_push_pin_24),
contentDescription = null,
modifier = Modifier
.size(14.dp)
.rotate(45f)
)
Spacer(modifier = Modifier.width(4.dp))
}
if (edited) { if (edited) {
Icon( Icon(
imageVector = Icons.Rounded.Create, imageVector = Icons.Rounded.Create,
@@ -64,6 +64,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -100,6 +101,8 @@ import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.SelectionType
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.getImage import dev.meloda.fast.ui.util.getImage
@@ -119,18 +122,22 @@ fun MessagesHistoryRoute(
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle() val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val showMessageOptions by viewModel.showMessageOptions.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val userSettings: UserSettings = koinInject() val userSettings: UserSettings = koinInject()
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle() val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
MessagesHistoryScreen( MessagesHistoryScreen(
screenState = screenState, screenState = screenState,
scrollIndex = scrollIndex,
selectedMessages = ImmutableList.copyOf(selectedMessages), selectedMessages = ImmutableList.copyOf(selectedMessages),
baseError = baseError, baseError = baseError,
canPaginate = canPaginate, canPaginate = canPaginate,
showEmojiButton = showEmojiButton, showEmojiButton = showEmojiButton,
onBack = onBack, onBack = onBack,
onClose = viewModel::onCloseButtonClicked, onClose = viewModel::onCloseButtonClicked,
onScrolledToIndex = viewModel::onScrolledToIndex,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) }, onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked, onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
@@ -140,8 +147,45 @@ fun MessagesHistoryRoute(
onActionButtonClicked = viewModel::onActionButtonClicked, onActionButtonClicked = viewModel::onActionButtonClicked,
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked, onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
onMessageClicked = viewModel::onMessageClicked, onMessageClicked = viewModel::onMessageClicked,
onMessageLongClicked = viewModel::onMessageLongClicked onMessageLongClicked = viewModel::onMessageLongClicked,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked
) )
if (showMessageOptions != null) {
val message = showMessageOptions!!
val messageOptions = mutableListOf(
stringResource(UiR.string.message_context_action_reply),
stringResource(UiR.string.message_context_action_forward)
)
if (message.isPeerChat() && screenState.conversation.canChangePin) {
messageOptions += stringResource(
if (message.isPinned) UiR.string.message_context_action_unpin
else UiR.string.message_context_action_pin
)
}
messageOptions += stringResource(UiR.string.message_context_action_copy)
messageOptions += stringResource(
if (message.isImportant) UiR.string.message_context_action_unmark_as_important
else UiR.string.message_context_action_mark_as_important
)
// if (!message.isOut) {
// messageOptions += "Mark as spam"
// }
messageOptions += stringResource(UiR.string.message_context_action_delete)
MaterialDialog(
onDismissRequest = viewModel::onMessageOptionsDialogDismissed,
selectionType = SelectionType.None,
items = ImmutableList.copyOf(messageOptions),
confirmText = stringResource(UiR.string.ok)
)
}
} }
@OptIn( @OptIn(
@@ -152,12 +196,14 @@ fun MessagesHistoryRoute(
@Composable @Composable
fun MessagesHistoryScreen( fun MessagesHistoryScreen(
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY, screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
scrollIndex: Int? = null,
selectedMessages: ImmutableList<Int> = ImmutableList.empty(), selectedMessages: ImmutableList<Int> = ImmutableList.empty(),
baseError: BaseError? = null, baseError: BaseError? = null,
canPaginate: Boolean = false, canPaginate: Boolean = false,
showEmojiButton: Boolean = false, showEmojiButton: Boolean = false,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onClose: () -> Unit = {}, onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {},
onSessionExpiredLogOutButtonClicked: () -> Unit = {}, onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> }, onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
@@ -167,7 +213,9 @@ fun MessagesHistoryScreen(
onActionButtonClicked: () -> Unit = {}, onActionButtonClicked: () -> Unit = {},
onEmojiButtonLongClicked: () -> Unit = {}, onEmojiButtonLongClicked: () -> Unit = {},
onMessageClicked: (Int) -> Unit = {}, onMessageClicked: (Int) -> Unit = {},
onMessageLongClicked: (Int) -> Unit = {} onMessageLongClicked: (Int) -> Unit = {},
onPinnedMessageClicked: (Int) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}
) { ) {
val view = LocalView.current val view = LocalView.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -175,6 +223,15 @@ fun MessagesHistoryScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
LaunchedEffect(scrollIndex) {
if (scrollIndex != null) {
coroutineScope.launch {
listState.animateScrollToItem(scrollIndex)
onScrolledToIndex()
}
}
}
BackHandler( BackHandler(
enabled = selectedMessages.isNotEmpty(), enabled = selectedMessages.isNotEmpty(),
onBack = onClose onBack = onClose
@@ -440,14 +497,14 @@ fun MessagesHistoryScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp) .height(56.dp)
.clickable { .clickable { onPinnedMessageClicked(pinnedMessage!!.id) }
}
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
modifier = Modifier.rotate(45f), modifier = Modifier
.rotate(45f)
.alpha(0.5f),
painter = painterResource(UiR.drawable.ic_round_push_pin_24), painter = painterResource(UiR.drawable.ic_round_push_pin_24),
contentDescription = null contentDescription = null
) )
@@ -468,6 +525,18 @@ fun MessagesHistoryScreen(
} }
} }
} }
if (screenState.conversation.canChangePin) {
Spacer(modifier = Modifier.width(16.dp))
IconButton(onClick = onUnpinMessageButtonClicked) {
Icon(
modifier = Modifier.alpha(0.5f),
imageVector = Icons.Rounded.Close,
contentDescription = null
)
}
}
} }
HorizontalDivider() HorizontalDivider()
} }
@@ -92,6 +92,12 @@ fun MessagesList(
when (item) { when (item) {
is UiItem.ActionMessage -> { is UiItem.ActionMessage -> {
ActionMessageItem( ActionMessageItem(
modifier = Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
) else Modifier
),
item = item, item = item,
onClick = { onClick = {
if (item.actionCmId != null) { if (item.actionCmId != null) {
@@ -112,6 +118,12 @@ fun MessagesList(
Surface( Surface(
modifier = Modifier modifier = Modifier
.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
) else Modifier
)
.combinedClickable( .combinedClickable(
onLongClick = { onLongClick = {
if (AppSettings.General.enableHaptic) { if (AppSettings.General.enableHaptic) {
@@ -1,5 +1,6 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -19,14 +20,14 @@ fun OutgoingMessageBubble(
animate: Boolean animate: Boolean
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth().then(if (animate) Modifier.animateContentSize() else Modifier),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(end = 16.dp) .padding(end = 16.dp)
.fillMaxWidth(0.75f), .fillMaxWidth(0.85f),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
) { ) {
@@ -38,7 +39,8 @@ fun OutgoingMessageBubble(
edited = message.isEdited, edited = message.isEdited,
animate = animate, animate = animate,
isRead = message.isRead, isRead = message.isRead,
sendingStatus = message.sendingStatus sendingStatus = message.sendingStatus,
pinned = message.isPinned
) )
} }
} }
@@ -128,7 +128,9 @@ fun VkMessage.asPresentation(
sendingStatus = when { sendingStatus = when {
id <= 0 -> SendingStatus.SENDING id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT else -> SendingStatus.SENT
} },
isSelected = false,
isPinned = isPinned
) )
} }