Release 0.1.8 (#139)

* pagination in chat fixed
* other fixes and improvements

* fixed visual bug in progress bar in chat history

* Refactor: Enhance conversations and friends features

-   In `ConversationsScreen`, removed `isNeedToScrollToTop` and `onScrolledToTop`, and refactored toolbar container color logic. Added `NoItemsView` for empty conversation lists.
-   In `MainGraph`, added `onMessageClicked` for navigation to message history.
-   In `ApiEvent`, introduced `parseOrNull` for handling unknown event types.
-   In `ConversationsViewModel`, removed `scrollToTop` logic and refactored error handling.
-   In `FriendsViewModel`, refactored error handling and introduced `onErrorConsumed` and `handleError`.
-   In `FriendItem`, added an icon button to initiate sending a message to a friend.
-   In `strings.xml`, added or updated strings for session expiration, log out, refreshing, and empty friend lists.
-   In `RootScreen`, added `onMessageClicked` for navigating to messages.
-   In `FriendsList`, added `onMessageClicked` for handling message clicks.
-   In `MainScreen`, removed unused `MutableSharedFlow`.
-   In `FriendsScreen`, added support for showing errors, added `onMessageClicked`, and replaced `hazeChild` with `hazeEffect` and `hazeSource`.
-   In `FriendsNavigation`, added `onMessageClicked` for handling message clicks.
-   In `ConversationsNavigation`, removed the unused `scrollToTopFlow` parameter.
-   In `ErrorView`, added text alignment.
-   In `NoItemsView`, added support for a button and custom text.
-   In `LongPollUpdatesParser`, replaced try-catch with `parseOrNull`.

* Chat creation feature (#138)

* - read indicator, edit status and time for message in messages history

* message sending status
This commit is contained in:
2025-03-23 09:22:41 +03:00
committed by GitHub
parent 30e132d418
commit b2879d8756
81 changed files with 1687 additions and 458 deletions
@@ -24,11 +24,13 @@ import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.navigation.MessagesHistory
import dev.meloda.fast.messageshistory.util.asPresentation
import dev.meloda.fast.messageshistory.util.extractAvatar
import dev.meloda.fast.messageshistory.util.extractTitle
import dev.meloda.fast.messageshistory.util.findMessageById
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.api.domain.VkAttachment
@@ -160,7 +162,9 @@ class MessagesHistoryViewModelImpl(
val message = event.message
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.conversationId) return
if (screenState.value.messages.findMessageById(message.id) != null) return
val randomIds = messages.value.map(VkMessage::randomId)
if (message.randomId != 0 && message.randomId in randomIds) return
@@ -174,22 +178,22 @@ class MessagesHistoryViewModelImpl(
val newMessage = message.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = prevMessage,
nextMessage = null,
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation,
)
newMessages.add(0, newMessage)
prevMessage?.let { prev ->
newMessages[1] = prev.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = prevMessage,
nextMessage = messages.value.first(),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
)
}
@@ -205,11 +209,11 @@ class MessagesHistoryViewModelImpl(
?.let { index ->
val newMessage = message.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.value.getOrNull(index + 1),
nextMessage = messages.value.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
)
val newMessages = screenState.value.messages.toMutableList()
@@ -224,7 +228,37 @@ class MessagesHistoryViewModelImpl(
}
private fun handleReadOutgoingEvent(event: LongPollEvent.VkMessageReadOutgoingEvent) {
if (event.peerId != screenState.value.conversationId) return
val messages = messages.value
val messageIndex =
messages.indexOfFirstOrNull { it.id == event.messageId }
if (messageIndex == null) { // диалога нет в списке
// pizdets
} else {
val newConversation = screenState.value.conversation.copy(
outRead = event.messageId
)
val uiMessages = messages.mapIndexed { index, item ->
item.asPresentation(
resourceProvider = resourceProvider,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = newConversation
)
}
screenState.setValue { old ->
old.copy(
conversation = newConversation,
messages = uiMessages,
)
}
}
}
private fun loadMessagesHistory(offset: Int = currentOffset.value) {
@@ -236,9 +270,7 @@ class MessagesHistoryViewModelImpl(
offset = offset,
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
},
error = { error -> },
success = { response ->
val messages = response.messages
val fullMessages = if (offset == 0) {
@@ -256,16 +288,6 @@ class MessagesHistoryViewModelImpl(
messagesUseCase.storeMessages(messages)
conversationsUseCase.storeConversations(conversations)
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
)
}
val itemsCountSufficient = messages.size == MESSAGES_LOAD_COUNT
@@ -278,15 +300,28 @@ class MessagesHistoryViewModelImpl(
conversations
.firstOrNull { it.id == screenState.value.conversationId }
?.let { conversation ->
screenState.setValue { old -> old.copy(conversation = conversation) }
newState = newState.copy(
title = conversation.extractTitle(
useContactName = AppSettings.General.useContactNames,
resources = resourceProvider.resources
),
avatar = conversation.extractAvatar()
avatar = conversation.extractAvatar(),
conversation = conversation
)
}
val loadedMessages = fullMessages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
)
}
this.messages.emit(fullMessages)
screenState.setValue { newState.copy(messages = loadedMessages) }
canPaginate.setValue { itemsCountSufficient }
@@ -347,18 +382,14 @@ class MessagesHistoryViewModelImpl(
val newMessages = screenState.value.messages.toMutableList()
val newUiMessage = newMessage.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.value.firstOrNull(),
nextMessage = null,
showTimeInActionMessages = userSettings.showTimeInActionMessages.value
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
conversation = screenState.value.conversation
)
newMessages.add(0, newUiMessage)
messages.setValue { old ->
listOf(newMessage).plus(old)
}
screenState.setValue { old ->
old.copy(
message = TextFieldValue(),
@@ -377,19 +408,37 @@ class MessagesHistoryViewModelImpl(
state.processState(
error = { error ->
sendingMessages -= newMessage
},
success = { messageId ->
sendingMessages += newMessage
val messages = screenState.value.messages.toMutableList()
val uiMessages = screenState.value.messages.toMutableList()
messages.indexOfOrNull(newUiMessage)?.let { index ->
(messages[index] as? UiItem.Message)?.let { message ->
messages[index] = message.copy(id = messageId)
uiMessages.indexOfOrNull(newUiMessage)?.let { index ->
(uiMessages[index] as? UiItem.Message)?.let { message ->
uiMessages[index] = message.copy(sendingStatus = SendingStatus.FAILED)
}
}
screenState.setValue { old -> old.copy(messages = messages) }
screenState.setValue { old -> old.copy(messages = uiMessages) }
},
success = { messageId ->
sendingMessages -= newMessage
val uiMessages = screenState.value.messages.toMutableList()
messages.setValue { old ->
listOf(newMessage.copy(id = messageId)).plus(old)
}
uiMessages.indexOfOrNull(newUiMessage)?.let { index ->
(uiMessages[index] as? UiItem.Message)?.let { message ->
uiMessages[index] = message
.copy(
id = messageId,
sendingStatus = SendingStatus.SENT
)
.copy(isRead = newMessage.isRead(screenState.value.conversation))
}
}
screenState.setValue { old -> old.copy(messages = uiMessages) }
}
)
}
@@ -508,11 +557,11 @@ class MessagesHistoryViewModelImpl(
val uiMessages = messages.mapIndexed { index, item ->
item.asPresentation(
resourceProvider = resourceProvider,
showDate = false,
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = show
showTimeInActionMessages = show,
conversation = screenState.value.conversation
)
}
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation
@Immutable
data class MessagesHistoryScreenState(
@@ -18,7 +19,8 @@ data class MessagesHistoryScreenState(
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val actionMode: ActionMode,
val chatImageUrl: String?
val chatImageUrl: String?,
val conversation: VkConversation
) {
companion object {
@@ -34,7 +36,8 @@ data class MessagesHistoryScreenState(
isPaginating = false,
isPaginationExhausted = false,
actionMode = ActionMode.Record,
chatImageUrl = null
chatImageUrl = null,
conversation = VkConversation.EMPTY
)
}
}
@@ -0,0 +1,5 @@
package dev.meloda.fast.messageshistory.model
enum class SendingStatus {
SENDING, SENT, FAILED
}
@@ -22,7 +22,9 @@ sealed class UiItem(
val showAvatar: Boolean,
val showName: Boolean,
val avatar: UiImage,
val isEdited: Boolean
val isEdited: Boolean,
val isRead: Boolean,
val sendingStatus: SendingStatus = SendingStatus.SENT
) : UiItem(id, conversationMessageId)
data class ActionMessage(
@@ -32,4 +34,3 @@ sealed class UiItem(
val actionCmId: Int?
) : UiItem(id, conversationMessageId)
}
@@ -31,6 +31,7 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun IncomingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
animate: Boolean
) {
val context = LocalContext.current
@@ -44,12 +45,12 @@ fun IncomingMessageBubble(
if (message.isInChat) {
Image(
painter =
message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter(
model = url,
imageLoader = context.imageLoader
)
} ?: painterResource(id = message.avatar.extractResId()),
message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter(
model = url,
imageLoader = context.imageLoader
)
} ?: painterResource(id = message.avatar.extractResId()),
contentDescription = null,
modifier = Modifier
.padding(bottom = 6.dp)
@@ -80,6 +81,9 @@ fun IncomingMessageBubble(
isOut = false,
date = message.date,
edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
)
}
}
@@ -1,19 +1,35 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Create
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.ui.R as UiR
@Composable
fun MessageBubble(
@@ -22,6 +38,9 @@ fun MessageBubble(
isOut: Boolean,
date: String?,
edited: Boolean,
animate: Boolean,
isRead: Boolean,
sendingStatus: SendingStatus
) {
val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
@@ -45,44 +64,70 @@ fun MessageBubble(
vertical = 6.dp
)
) {
val minDateContainerWidth = remember(edited, isOut) {
val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp
mainPart + readIndicatorPart
}
val dateContainerWidth by animateDpAsState(
targetValue = minDateContainerWidth,
label = "dateContainerWidth"
)
if (text != null) {
Text(
text = text,
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center)
.animateContentSize(),
.padding(end = 4.dp)
.padding(end = dateContainerWidth)
.padding(end = 4.dp)
.then(if (animate) Modifier.animateContentSize() else Modifier),
color = textColor
)
}
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
.defaultMinSize(minWidth = dateContainerWidth)
) {
if (edited) {
Icon(
imageVector = Icons.Rounded.Create,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = date.orEmpty(),
style = MaterialTheme.typography.labelSmall,
)
Spacer(modifier = Modifier.width(4.dp))
// val dateContainerWidth by animateDpAsState(
// targetValue = if (edited) 50.dp else 30.dp,
// label = "dateContainerWidth"
// )
if (isOut) {
Icon(
modifier = Modifier.size(14.dp),
painter = painterResource(
when (sendingStatus) {
SendingStatus.SENDING -> UiR.drawable.round_access_time_24
SendingStatus.SENT -> {
if (isRead) UiR.drawable.round_done_all_24
else UiR.drawable.ic_round_done_24
}
// AnimatedVisibility(
// date != null,
// modifier = Modifier
// .width(dateContainerWidth)
// .align(Alignment.BottomEnd)
// ) {
// Row(modifier = Modifier.fillMaxWidth()) {
// if (edited) {
// Icon(
// imageVector = Icons.Rounded.Create,
// contentDescription = null,
// modifier = Modifier.size(14.dp)
// )
// Spacer(modifier = Modifier.width(4.dp))
// }
// Text(
// text = date.orEmpty(),
// style = MaterialTheme.typography.labelSmall
// )
// Spacer(modifier = Modifier.width(2.dp))
// }
// }
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
}
),
tint = if (sendingStatus == SendingStatus.FAILED) Color.Red
else LocalContentColor.current,
contentDescription = null
)
}
}
}
}
@@ -159,7 +159,7 @@ fun MessagesHistoryScreen(
val listState = rememberLazyListState()
val paginationConditionMet by remember {
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
@@ -282,6 +282,7 @@ fun MessagesHistoryScreen(
// TODO: 11/07/2024, Danil Nikolaev: to VM
// TODO: 23-Mar-25, Danil Nikolaev: crash if not messages (ex. new chat)
onChatMaterialsDropdownItemClicked(
screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId
@@ -90,26 +90,28 @@ fun MessagesList(
if (item.isOut) {
OutgoingMessageBubble(
modifier =
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item,
animate = enableAnimations
)
} else {
IncomingMessageBubble(
modifier =
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
Modifier.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item,
animate = enableAnimations
)
}
}
@@ -128,16 +130,17 @@ fun MessagesList(
}
}
Spacer(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
)
Spacer(Modifier.height(8.dp))
Spacer(
modifier = Modifier
.height(64.dp)
.fillMaxWidth()
)
Spacer(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
)
}
}
}
@@ -3,12 +3,8 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -20,6 +16,7 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun OutgoingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
animate: Boolean
) {
Row(
modifier = modifier.fillMaxWidth(),
@@ -37,18 +34,12 @@ fun OutgoingMessageBubble(
modifier = Modifier,
text = message.text.orDots(),
isOut = true,
date = null,
date = message.date,
edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
)
if (message.showDate) {
Spacer(modifier = Modifier.height(2.dp))
Text(
modifier = Modifier.padding(end = 12.dp),
text = message.date,
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
@@ -4,11 +4,13 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first()
fun List<UiItem>.firstMessageOrNull(): UiItem.Message? = filterIsInstance<UiItem.Message>().firstOrNull()
fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
indexOfFirst { it.id == messageId }
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message =
first { it.id == messageId } as UiItem.Message
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
firstOrNull { it.id == messageId } as UiItem.Message?
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
indexOfFirst { it.cmId == cmId }
@@ -12,6 +12,7 @@ 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.VkConversation
@@ -90,8 +91,8 @@ fun VkConversation.extractTitle(
}.parseString(resources).orDots()
fun VkMessage.asPresentation(
conversation: VkConversation,
resourceProvider: ResourceProvider,
showDate: Boolean,
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
@@ -118,15 +119,19 @@ fun VkMessage.asPresentation(
randomId = randomId,
isInChat = isPeerChat(),
name = extractTitle(),
showDate = showDate,
showDate = true,
showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(),
isEdited = updateTime != null
isEdited = updateTime != null,
isRead = isRead(conversation),
sendingStatus = when {
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
}
)
}
fun VkMessage.extractShowAvatar(nextMessage: VkMessage?): Boolean {
if (isOut) return false
return nextMessage == null || nextMessage.fromId != fromId