Release 0.2.0 (#150)

Release Notes

* Bumped haze, agp, and guava dependencies
* Implemented ordering functionality for friends list
* Added scroll to top feature in friends and conversations screens
* Improved messages handling
* Fixed coloring issues
* Cache improvements
* Implemented logout functionality
* Implemented new authorization flow (no auto-token re-request)
* Added support for sticker pack preview attachments
* Bump LongPoll to version 19
* Markdown support for messages bubbles
* Adjust app name font size based on screen width

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
2025-04-04 21:47:05 +03:00
committed by GitHub
parent 0eb3146428
commit 82fb78e9ea
279 changed files with 9171 additions and 4517 deletions
@@ -0,0 +1,23 @@
package dev.meloda.fast.messageshistory.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.api.domain.VkMessage
@Immutable
sealed class MessageDialog {
data class MessageOptions(val message: VkMessage) : MessageDialog()
data class MessagePin(val messageId: Long) : MessageDialog()
data class MessageUnpin(val messageId: Long) : MessageDialog()
data class MessageDelete(val message: VkMessage) : MessageDialog()
data class MessagesDelete(val messages: List<VkMessage>) : MessageDialog()
data class MessageSpam(
val message: VkMessage,
val isSpam: Boolean
) : MessageDialog()
data class MessageMarkImportance(
val message: VkMessage,
val isImportant: Boolean
) : MessageDialog()
}
@@ -0,0 +1,12 @@
package dev.meloda.fast.messageshistory.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class MessageNavigation {
data class ChatMaterials(
val peerId: Long,
val cmId: Long
) : MessageNavigation()
}
@@ -0,0 +1,84 @@
package dev.meloda.fast.messageshistory.model
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import dev.meloda.fast.ui.R
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class MessageOption(
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int
) : Parcelable {
data object Retry : MessageOption(
titleResId = R.string.message_context_action_retry,
iconResId = R.drawable.round_restart_alt_24
)
data object Reply : MessageOption(
titleResId = R.string.message_context_action_reply,
iconResId = R.drawable.round_reply_24
)
data object ForwardHere : MessageOption(
titleResId = R.string.message_context_action_forward_here,
iconResId = R.drawable.round_reply_all_24
)
data object Forward : MessageOption(
titleResId = R.string.message_context_action_forward,
iconResId = R.drawable.round_forward_24
)
data object Pin : MessageOption(
titleResId = R.string.message_context_action_pin,
iconResId = R.drawable.pin_outline_24
)
data object Unpin : MessageOption(
titleResId = R.string.message_context_action_unpin,
iconResId = R.drawable.pin_off_outline_24
)
data object Read : MessageOption(
titleResId = R.string.message_context_action_read,
iconResId = R.drawable.round_mark_email_read_24
)
data object Copy : MessageOption(
titleResId = R.string.message_context_action_copy,
iconResId = R.drawable.round_content_copy_24
)
data object MarkAsImportant : MessageOption(
titleResId = R.string.message_context_action_mark_as_important,
iconResId = R.drawable.round_star_24
)
data object UnmarkAsImportant : MessageOption(
titleResId = R.string.message_context_action_unmark_as_important,
iconResId = R.drawable.round_star_outline_24
)
data object MarkAsSpam : MessageOption(
titleResId = R.string.message_context_action_mark_as_spam,
iconResId = R.drawable.round_report_gmailerrorred_24
)
data object UnmarkAsSpam : MessageOption(
titleResId = R.string.message_context_action_unmark_as_spam,
iconResId = R.drawable.round_report_off_24
)
data object Edit : MessageOption(
titleResId = R.string.message_context_action_edit,
iconResId = R.drawable.round_create_24
)
data object Delete : MessageOption(
titleResId = R.string.message_context_action_delete,
iconResId = R.drawable.round_delete_outline_24
)
}
@@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class MessagesHistoryArguments(val conversationId: Int) : Parcelable
data class MessagesHistoryArguments(val conversationId: Long) : Parcelable
@@ -1,18 +1,19 @@
package dev.meloda.fast.messageshistory.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
@Immutable
data class MessagesHistoryScreenState(
val conversationId: Int,
val conversationId: Long,
val title: String,
val status: String?,
val avatar: UiImage,
val messages: List<UiItem>,
val message: TextFieldValue,
val attachments: List<VkAttachment>,
val isLoading: Boolean,
@@ -20,7 +21,10 @@ data class MessagesHistoryScreenState(
val isPaginationExhausted: Boolean,
val actionMode: ActionMode,
val chatImageUrl: String?,
val conversation: VkConversation
val conversation: VkConversation,
val pinnedMessage: VkMessage?,
val pinnedTitle: String?,
val pinnedSummary: AnnotatedString?
) {
companion object {
@@ -29,7 +33,6 @@ data class MessagesHistoryScreenState(
title = "",
status = null,
avatar = UiImage.Color(0),
messages = emptyList(),
message = TextFieldValue(),
attachments = emptyList(),
isLoading = true,
@@ -37,7 +40,10 @@ data class MessagesHistoryScreenState(
isPaginationExhausted = false,
actionMode = ActionMode.Record,
chatImageUrl = null,
conversation = VkConversation.EMPTY
conversation = VkConversation.EMPTY,
pinnedMessage = null,
pinnedTitle = null,
pinnedSummary = null
)
}
}
@@ -4,18 +4,18 @@ import androidx.compose.ui.text.AnnotatedString
import dev.meloda.fast.common.model.UiImage
sealed class UiItem(
open val id: Int,
val cmId: Int
open val id: Long,
val cmId: Long
) {
data class Message(
override val id: Int,
val conversationMessageId: Int,
val text: String?,
override val id: Long,
val conversationMessageId: Long,
val text: AnnotatedString?,
val isOut: Boolean,
val fromId: Int,
val fromId: Long,
val date: String,
val randomId: Int,
val randomId: Long,
val isInChat: Boolean,
val name: String,
val showDate: Boolean,
@@ -24,13 +24,16 @@ sealed class UiItem(
val avatar: UiImage,
val isEdited: Boolean,
val isRead: Boolean,
val sendingStatus: SendingStatus = SendingStatus.SENT
val sendingStatus: SendingStatus,
val isSelected: Boolean,
val isPinned: Boolean,
val isImportant: Boolean
) : UiItem(id, conversationMessageId)
data class ActionMessage(
override val id: Int,
val conversationMessageId: Int,
override val id: Long,
val conversationMessageId: Long,
val text: AnnotatedString,
val actionCmId: Int?
val actionCmId: Long?
) : UiItem(id, conversationMessageId)
}
@@ -27,17 +27,17 @@ data class MessagesHistory(val arguments: MessagesHistoryArguments) {
fun NavGraphBuilder.messagesHistoryScreen(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit
onNavigateToChatMaterials: (peerId: Long, cmId: Long) -> Unit
) {
composable<MessagesHistory>(typeMap = MessagesHistory.typeMap) {
MessagesHistoryRoute(
onError = onError,
onBack = onBack,
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
onNavigateToChatMaterials = onNavigateToChatMaterials
)
}
}
fun NavController.navigateToMessagesHistory(conversationId: Int) {
fun NavController.navigateToMessagesHistory(conversationId: Long) {
this.navigate(MessagesHistory(MessagesHistoryArguments(conversationId)))
}
@@ -27,13 +27,15 @@ fun ActionMessageItem(
Text(
text = item.text,
modifier = modifier
.padding(horizontal = 32.dp)
.padding(
horizontal = 32.dp,
vertical = 4.dp
)
.clip(RoundedCornerShape(12.dp))
.then(
if (item.actionCmId != null) {
Modifier.clickable(onClick = onClick)
}
else Modifier
} else Modifier
)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
.fillMaxWidth()
@@ -1,6 +1,7 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -26,65 +27,75 @@ import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.ui.theme.LocalThemeConfig
@Composable
fun IncomingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
animate: Boolean
) {
val context = LocalContext.current
Row(
modifier = modifier
.fillMaxWidth(0.75f)
.padding(start = 16.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Start
.fillMaxWidth()
.then(
if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize()
else Modifier
),
) {
if (message.isInChat) {
Image(
painter =
message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter(
model = url,
imageLoader = context.imageLoader
)
} ?: painterResource(id = message.avatar.extractResId()),
contentDescription = null,
modifier = Modifier
.padding(bottom = 6.dp)
.size(28.dp)
.alpha(if (message.showAvatar) 1f else 0f)
.clip(CircleShape),
)
Spacer(modifier = Modifier.width(8.dp))
}
Column {
AnimatedVisibility(visible = message.showName) {
Text(
Row(
modifier = Modifier
.fillMaxWidth(0.85f)
.padding(start = 16.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Start
) {
if (message.isInChat) {
Image(
painter =
message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter(
model = url,
imageLoader = LocalContext.current.imageLoader
)
} ?: painterResource(id = message.avatar.extractResId()),
contentDescription = null,
modifier = Modifier
.padding(start = 12.dp)
.widthIn(max = 140.dp),
text = message.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
.padding(bottom = 6.dp)
.size(28.dp)
.alpha(if (message.showAvatar) 1f else 0f)
.clip(CircleShape),
)
Spacer(modifier = Modifier.width(8.dp))
}
MessageBubble(
modifier = Modifier,
text = message.text,
isOut = false,
date = message.date,
edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
)
Column {
AnimatedVisibility(visible = message.showName) {
Text(
modifier = Modifier
.padding(start = 12.dp)
.widthIn(max = 140.dp),
text = message.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
MessageBubble(
modifier = Modifier,
text = message.text,
isOut = false,
date = message.date,
edited = message.isEdited,
isRead = message.isRead,
sendingStatus = message.sendingStatus,
pinned = message.isPinned,
important = message.isImportant,
isSelected = message.isSelected
)
}
}
Spacer(modifier = Modifier.fillMaxWidth(0.25f))
}
}
@@ -12,6 +12,7 @@ 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.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Create
import androidx.compose.material3.Icon
@@ -20,113 +21,192 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
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.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.R as UiR
@Composable
fun MessageBubble(
modifier: Modifier = Modifier,
text: String?,
text: AnnotatedString?,
isOut: Boolean,
date: String?,
edited: Boolean,
animate: Boolean,
isRead: Boolean,
sendingStatus: SendingStatus
sendingStatus: SendingStatus,
pinned: Boolean,
important: Boolean,
isSelected: Boolean
) {
val theme = LocalThemeConfig.current
val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
} else {
MaterialTheme.colorScheme.primaryContainer
}
val textColor = if (!isOut) {
val contentColor = if (!isOut) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onPrimaryContainer
}
Box(
modifier = modifier
.widthIn(min = 56.dp)
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.padding(
horizontal = 8.dp,
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)
.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)
CompositionLocalProvider(LocalContentColor provides contentColor) {
Box(
modifier = modifier
.widthIn(min = 56.dp)
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.padding(
horizontal = 8.dp,
vertical = 6.dp
)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) {
if (edited) {
Icon(
imageVector = Icons.Rounded.Create,
contentDescription = null,
modifier = Modifier.size(14.dp)
val minDateContainerWidth by remember(edited, isOut, pinned, important) {
derivedStateOf {
val mainPart = if (edited) 50.dp else 30.dp
val readIndicatorPart = if (isOut) 14.dp else 0.dp
val pinnedIndicatorPart = if (pinned) 14.dp else 0.dp
val importantIndicatorPart = if (important) 14.dp else 0.dp
mainPart + readIndicatorPart + pinnedIndicatorPart + importantIndicatorPart
}
}
val dateContainerWidth by animateDpAsState(
targetValue = minDateContainerWidth,
label = "dateContainerWidth"
)
if (text != null) {
val textLambda: @Composable () -> Unit = remember(text, theme, dateContainerWidth) {
{
Text(
text = kotlin.run {
val builder = AnnotatedString.Builder(text)
text.spanStyles.map { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color =
if (isOut) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.primary
}
)
} else {
spanStyleRange.item
}
builder.addStyle(
style = updatedSpanStyle,
start = spanStyleRange.start,
end = spanStyleRange.end
)
}
text.paragraphStyles.forEach { style ->
builder.addStyle(
style = style.item,
start = style.start,
end = style.end
)
}
builder.toAnnotatedString()
},
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center)
.padding(end = 4.dp)
.padding(end = dateContainerWidth)
.padding(end = 4.dp)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier)
)
}
}
if (isSelected) {
SelectionContainer {
textLambda.invoke()
}
} else {
textLambda.invoke()
}
}
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
.defaultMinSize(minWidth = dateContainerWidth)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) {
if (important) {
Icon(
painter = painterResource(UiR.drawable.round_star_24),
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
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) {
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))
}
Text(
text = date.orEmpty(),
style = MaterialTheme.typography.labelSmall,
)
Spacer(modifier = Modifier.width(4.dp))
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
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
}
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
}
SendingStatus.FAILED -> UiR.drawable.round_error_outline_24
}
),
tint = if (sendingStatus == SendingStatus.FAILED) Color.Red
else LocalContentColor.current,
contentDescription = null
)
),
tint = if (sendingStatus == SendingStatus.FAILED) MaterialTheme.colorScheme.error
else LocalContentColor.current,
contentDescription = null
)
}
}
}
}
@@ -0,0 +1,323 @@
package dev.meloda.fast.messageshistory.presentation
import android.os.Bundle
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.messageshistory.model.MessageDialog
import dev.meloda.fast.messageshistory.model.MessageOption
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.MaterialDialog
import java.util.concurrent.TimeUnit
@Composable
fun HandleDialogs(
screenState: MessagesHistoryScreenState,
dialog: MessageDialog?,
onConfirmed: (MessageDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (MessageDialog) -> Unit = {},
onItemPicked: (MessageDialog, Bundle) -> Unit = { _, _ -> }
) {
when (dialog) {
null -> Unit
is MessageDialog.MessageOptions -> {
MessageOptionsDialog(
screenState = screenState,
message = dialog.message,
onDismissed = { onDismissed(dialog) },
onItemPicked = { bundle -> onItemPicked(dialog, bundle) }
)
}
is MessageDialog.MessageDelete -> {
MessageDeleteDialog(
messages = listOf(dialog.message),
onConfirmed = { onConfirmed(dialog, it) },
onDismissed = { onDismissed(dialog) }
)
}
is MessageDialog.MessagesDelete -> {
MessageDeleteDialog(
messages = dialog.messages,
onConfirmed = { onConfirmed(dialog, it) },
onDismissed = { onDismissed(dialog) }
)
}
is MessageDialog.MessagePin,
is MessageDialog.MessageUnpin -> {
MessagePinStateDialog(
pin = dialog is MessageDialog.MessagePin,
onConfirmed = { onConfirmed(dialog, bundleOf()) },
onDismissed = { onDismissed(dialog) }
)
}
is MessageDialog.MessageMarkImportance -> {
MessageImportanceDialog(
important = dialog.isImportant,
onConfirmed = { onConfirmed(dialog, bundleOf()) },
onDismissed = { onDismissed(dialog) }
)
}
is MessageDialog.MessageSpam -> {
MessageSpamDialog(
spam = dialog.isSpam,
onConfirmed = { onConfirmed(dialog, bundleOf()) },
onDismissed = { onDismissed(dialog) }
)
}
}
}
@Composable
fun MessageOptionsDialog(
screenState: MessagesHistoryScreenState,
message: VkMessage,
onDismissed: () -> Unit = {},
onItemPicked: (Bundle) -> Unit
) {
val options = mutableListOf<MessageOption>()
if (message.isFailed()) {
options += MessageOption.Retry
} else {
options += MessageOption.Reply
options += MessageOption.ForwardHere
options += MessageOption.Forward
if (message.isPeerChat() && screenState.conversation.canChangePin) {
options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin
}
if (!message.isRead(screenState.conversation)) {
options += MessageOption.Read
}
options += MessageOption.Copy
if (message.isOut) {
val diff = System.currentTimeMillis() - message.date * 1000L
if (diff - TimeUnit.DAYS.toMillis(1) <= 0) {
options += MessageOption.Edit
}
}
options += if (message.isImportant) MessageOption.UnmarkAsImportant
else MessageOption.MarkAsImportant
if (!message.isOut) {
options += if (message.isSpam) MessageOption.UnmarkAsSpam
else MessageOption.MarkAsSpam
}
}
options += MessageOption.Delete
val messageOptions = options.map { option ->
Triple(
stringResource(option.titleResId),
painterResource(option.iconResId),
when {
option in listOf(
MessageOption.Delete,
MessageOption.MarkAsSpam
) -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
)
}
MaterialDialog(onDismissRequest = onDismissed) {
messageOptions
.forEachIndexed { index, (title, painter, tintColor) ->
DropdownMenuItem(
text = {
Row {
Text(text = title)
Spacer(modifier = Modifier.width(8.dp))
}
},
leadingIcon = {
Row {
Spacer(modifier = Modifier.width(8.dp))
Icon(
painter = painter,
contentDescription = null,
tint = tintColor
)
}
},
onClick = {
onDismissed()
val pickedOption = options[index]
onItemPicked(bundleOf("option" to pickedOption))
}
)
}
}
}
@Composable
fun MessageDeleteDialog(
messages: List<VkMessage>,
onConfirmed: (Bundle) -> Unit = {},
onDismissed: () -> Unit = {},
) {
var forEveryone by remember {
mutableStateOf(
!messages.any { it.peerId == UserConfig.userId }
&& messages.all(VkMessage::isOut)
)
}
val shouldBeDisabled by remember(messages) {
mutableStateOf(
messages.any { it.peerId == UserConfig.userId }
|| messages.all(VkMessage::isFailed)
|| !messages.all(VkMessage::isOut)
)
}
MaterialDialog(
onDismissRequest = onDismissed,
title = stringResource(R.string.delete_message_title),
confirmText = stringResource(R.string.action_delete),
confirmAction = {
onConfirmed(
bundleOf("everyone" to if (messages.all(VkMessage::isOut)) forEveryone else false)
)
},
cancelText = stringResource(R.string.cancel),
) {
Row(
modifier = Modifier
.then(
if (!shouldBeDisabled) {
Modifier.clickable { forEveryone = !forEveryone }
} else Modifier)
.fillMaxWidth()
.minimumInteractiveComponentSize()
.padding(start = 24.dp, end = 16.dp)
) {
Checkbox(
checked = forEveryone,
onCheckedChange = null,
enabled = !shouldBeDisabled
)
Spacer(modifier = Modifier.width(8.dp))
LocalContentAlpha(
alpha = if (shouldBeDisabled) ContentAlpha.disabled
else ContentAlpha.high
) {
Text(text = stringResource(R.string.delete_message_for_everyone))
}
}
}
}
@Composable
fun MessagePinStateDialog(
pin: Boolean,
onConfirmed: () -> Unit = {},
onDismissed: () -> Unit = {},
) {
MaterialDialog(
onDismissRequest = onDismissed,
title = stringResource(
if (pin) R.string.pin_message_title
else R.string.unpin_message_title
),
text = stringResource(
if (pin) R.string.pin_message_text
else R.string.unpin_message_text
),
confirmText = stringResource(
if (pin) R.string.action_pin
else R.string.action_unpin
),
confirmAction = onConfirmed,
cancelText = stringResource(R.string.cancel)
)
}
@Composable
fun MessageImportanceDialog(
important: Boolean,
onConfirmed: () -> Unit = {},
onDismissed: () -> Unit = {},
) {
MaterialDialog(
onDismissRequest = onDismissed,
title = stringResource(
if (important) R.string.important_message_title
else R.string.unimportant_message_title
),
text = stringResource(
if (important) R.string.important_message_text
else R.string.unimportant_message_text
),
confirmText = stringResource(
if (important) R.string.action_mark
else R.string.action_unmark
),
confirmAction = onConfirmed,
cancelText = stringResource(R.string.cancel)
)
}
@Composable
fun MessageSpamDialog(
spam: Boolean,
onConfirmed: () -> Unit = {},
onDismissed: () -> Unit = {},
) {
MaterialDialog(
onDismissRequest = onDismissed,
title = stringResource(
if (spam) R.string.spam_message_title
else R.string.unspam_message_title
),
text = stringResource(
if (spam) R.string.spam_message_text
else R.string.unspam_message_text
),
confirmText = stringResource(
if (spam) R.string.action_mark
else R.string.action_unmark
),
confirmAction = onConfirmed,
cancelText = stringResource(R.string.cancel)
)
}
@@ -0,0 +1,83 @@
package dev.meloda.fast.messageshistory.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.datastore.UserSettings
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
import dev.meloda.fast.messageshistory.model.MessageNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@Composable
fun MessagesHistoryRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
val messages by viewModel.messages.collectAsStateWithLifecycle()
val uiMessages by viewModel.uiMessages.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val userSettings: UserSettings = koinInject()
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val needToConsume = when (val navigation = navigationEvent) {
null -> false
is MessageNavigation.ChatMaterials -> {
val (peerId, cmId) = navigation
onNavigateToChatMaterials(peerId, cmId)
true
}
}
if (needToConsume) viewModel.onNavigationConsumed()
}
MessagesHistoryScreen(
screenState = screenState,
messages = messages.toImmutableList(),
uiMessages = uiMessages.toImmutableList(),
scrollIndex = scrollIndex,
selectedMessages = selectedMessages.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
showEmojiButton = showEmojiButton,
onBack = onBack,
onClose = viewModel::onCloseButtonClicked,
onScrolledToIndex = viewModel::onScrolledToIndex,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onTopBarClicked = viewModel::onTopBarClicked,
onRefresh = viewModel::onRefresh,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onMessageInputChanged = viewModel::onMessageInputChanged,
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
onActionButtonClicked = viewModel::onActionButtonClicked,
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
onMessageClicked = viewModel::onMessageClicked,
onMessageLongClicked = viewModel::onMessageLongClicked,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked
)
HandleDialogs(
screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
}
@@ -1,13 +1,17 @@
package dev.meloda.fast.messageshistory.presentation
import android.content.SharedPreferences
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,6 +38,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
@@ -75,60 +80,29 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.messageshistory.util.firstMessage
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
@Composable
fun MessagesHistoryRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val userSettings: UserSettings = koinInject()
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
MessagesHistoryScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
showEmojiButton = showEmojiButton,
onBack = onBack,
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onMessageInputChanged = viewModel::onMessageInputChanged,
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
onActionButtonClicked = viewModel::onActionButtonClicked,
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
@@ -137,27 +111,51 @@ fun MessagesHistoryRoute(
@Composable
fun MessagesHistoryScreen(
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
messages: ImmutableList<VkMessage> = emptyImmutableList(),
uiMessages: ImmutableList<UiItem> = emptyImmutableList(),
scrollIndex: Int? = null,
selectedMessages: ImmutableList<VkMessage> = emptyImmutableList(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
showEmojiButton: Boolean = false,
onBack: () -> Unit = {},
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
onRefreshDropdownItemClicked: () -> Unit = {},
onToggleAnimationsDropdownItemClicked: (Boolean) -> Unit = {},
onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {},
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onTopBarClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {},
onEmojiButtonLongClicked: () -> Unit = {}
onEmojiButtonLongClicked: () -> Unit = {},
onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {},
onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {}
) {
val view = LocalView.current
val coroutineScope = rememberCoroutineScope()
val preferences: SharedPreferences = koinInject()
val currentTheme = LocalThemeConfig.current
val theme = LocalThemeConfig.current
val listState = rememberLazyListState()
val hazeState = remember { HazeState() }
LaunchedEffect(scrollIndex) {
if (scrollIndex != null) {
coroutineScope.launch {
listState.animateScrollToItem(scrollIndex)
onScrolledToIndex()
}
}
}
BackHandler(
enabled = selectedMessages.isNotEmpty(),
onBack = onClose
)
val pinnedMessage = screenState.pinnedMessage
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
@@ -177,12 +175,24 @@ fun MessagesHistoryScreen(
mutableStateOf(false)
}
val hazeState = remember { HazeState() }
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollForward) 1f else 0f,
val topBarContainerColorAlpha by animateFloatAsState(
targetValue = if (!theme.enableBlur || !listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
val topBarContainerColor by animateColorAsState(
targetValue =
if (theme.enableBlur || !listState.canScrollBackward) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(
durationMillis = 200,
easing = FastOutLinearInEasing
)
)
var messageBarHeight by remember {
@@ -191,54 +201,97 @@ fun MessagesHistoryScreen(
val density = LocalDensity.current
val showReplyAction by remember(selectedMessages) {
derivedStateOf { selectedMessages.size == 1 }
}
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(topBarContainerColor.copy(alpha = topBarContainerColorAlpha))
.then(
if (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
) {
TopAppBar(
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeChild(
if (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
.fillMaxWidth(),
.fillMaxWidth()
.then(
if (screenState.isLoading && messages.isEmpty()) Modifier
else Modifier.clickable {
onTopBarClicked()
}
),
title = {
Row(
modifier = Modifier
.weight(1f),
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
val avatar = screenState.avatar.getImage()
if (avatar is Painter) {
Image(
painter = avatar,
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
} else {
AsyncImage(
model = screenState.avatar.getImage(),
contentDescription = "Profile Image",
modifier = Modifier
.size(36.dp)
.clip(CircleShape),
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut),
)
if (selectedMessages.isEmpty()) {
val avatar = screenState.avatar.getImage()
if (screenState.conversationId == UserConfig.userId) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(24.dp),
painter = painterResource(id = UiR.drawable.ic_round_bookmark_border_24),
contentDescription = "Favorites icon",
tint = MaterialTheme.colorScheme.onPrimary
)
}
} else {
if (avatar is Painter) {
Image(
painter = avatar,
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
} else {
AsyncImage(
model = screenState.avatar.getImage(),
contentDescription = "Profile Image",
modifier = Modifier
.size(36.dp)
.clip(CircleShape),
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text =
if (screenState.isLoading) stringResource(id = UiR.string.title_loading)
else screenState.title,
text = when {
screenState.isLoading -> stringResource(id = UiR.string.title_loading)
selectedMessages.size > 0 -> "(${selectedMessages.size})"
else -> screenState.title
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
@@ -246,73 +299,109 @@ fun MessagesHistoryScreen(
}
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(
onClick = {
if (selectedMessages.isEmpty()) onBack()
else onClose()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
imageVector = if (selectedMessages.isEmpty()) {
Icons.AutoMirrored.Rounded.ArrowBack
} else {
Icons.Rounded.Close
},
contentDescription = "Back button"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
)
),
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
actions = {
IconButton(
onClick = { dropDownMenuExpanded = true }
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "Options"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
dropDownMenuExpanded = false
// 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
)
},
text = {
Text(text = "Materials")
}
)
DropdownMenuItem(
onClick = {
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
Text(text = "Refresh")
},
leadingIcon = {
if (selectedMessages.isNotEmpty()) {
AnimatedVisibility(showReplyAction) {
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
imageVector = Icons.Rounded.Refresh,
painter = painterResource(UiR.drawable.round_reply_24),
contentDescription = null
)
}
)
}
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
painter = painterResource(UiR.drawable.round_reply_all_24),
contentDescription = null
)
}
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
painter = painterResource(UiR.drawable.round_forward_24),
contentDescription = null
)
}
IconButton(onClick = onDeleteSelectedButtonClicked) {
Icon(
painter = painterResource(UiR.drawable.round_delete_outline_24),
contentDescription = null
)
}
} else {
IconButton(
onClick = { dropDownMenuExpanded = true }
) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = "Options"
)
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = {
dropDownMenuExpanded = false
},
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(UiR.string.action_refresh))
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = null
)
}
)
}
}
}
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && screenState.messages.isNotEmpty() }
derivedStateOf { screenState.isLoading && messages.isNotEmpty() }
}
if (showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -320,6 +409,19 @@ fun MessagesHistoryScreen(
AnimatedVisibility(!showHorizontalProgressBar) {
HorizontalDivider()
}
if (!screenState.isLoading && pinnedMessage != null) {
PinnedMessageContainer(
modifier = Modifier,
pinnedMessage = requireNotNull(pinnedMessage),
title = screenState.pinnedTitle.orDots(),
summary = screenState.pinnedSummary,
canChangePin = screenState.conversation.canChangePin,
onPinnedMessageClicked = onPinnedMessageClicked,
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
)
HorizontalDivider()
}
}
}
) { padding ->
@@ -331,18 +433,32 @@ fun MessagesHistoryScreen(
.padding(bottom = padding.calculateBottomPadding()),
) {
MessagesList(
modifier = Modifier.align(Alignment.BottomStart),
hazeState = hazeState,
listState = listState,
immutableMessages = ImmutableList.copyOf(screenState.messages),
hasPinnedMessage = pinnedMessage != null,
uiMessages = uiMessages,
isPaginating = screenState.isPaginating,
messageBarHeight = messageBarHeight,
onRequestScrollToCmId = { cmId ->
coroutineScope.launch {
listState.animateScrollToItem(
index = screenState.messages.indexOfMessageByCmId(cmId)
)
val index = uiMessages.values.indexOfMessageByCmId(cmId)
if (index == null) { // сообщения нет в списке
// pizdets
} else {
coroutineScope.launch {
listState.animateScrollToItem(index = index)
}
}
}
},
onMessageClicked = { id ->
if (selectedMessages.isNotEmpty()) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK)
}
}
onMessageClicked(id)
},
onMessageLongClicked = onMessageLongClicked
)
Column(
@@ -362,13 +478,28 @@ fun MessagesHistoryScreen(
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(10.dp))
Row(
modifier = Modifier
.clip(RoundedCornerShape(36.dp))
.then(
if (theme.enableBlur) {
Modifier
.hazeEffect(
state = hazeState,
style = HazeMaterials.ultraThin()
)
.border(
1.dp, MaterialTheme.colorScheme.outlineVariant,
RoundedCornerShape(36.dp)
)
} else Modifier
)
.animateContentSize()
.weight(1f)
.clip(RoundedCornerShape(36.dp))
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
.background(
if (theme.enableBlur) Color.Transparent
else MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
)
.onGloballyPositioned {
messageBarHeight = with(density) {
it.size.height.toDp()
@@ -386,7 +517,9 @@ fun MessagesHistoryScreen(
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
@@ -405,7 +538,9 @@ fun MessagesHistoryScreen(
},
onLongClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.LONG_PRESS)
view.performHapticFeedback(
HapticFeedbackConstantsCompat.LONG_PRESS
)
}
onEmojiButtonLongClicked()
},
@@ -447,8 +582,11 @@ fun MessagesHistoryScreen(
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
onAttachmentButtonClicked()
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
@@ -484,7 +622,9 @@ fun MessagesHistoryScreen(
onClick = {
if (screenState.actionMode == ActionMode.Record) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
@@ -535,8 +675,14 @@ fun MessagesHistoryScreen(
}
}
if (screenState.isLoading && screenState.messages.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
when {
screenState.isLoading && messages.values.isEmpty() -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
baseError != null -> {
VkErrorView(baseError = baseError)
}
}
}
}
@@ -1,54 +1,63 @@
package dev.meloda.fast.messageshistory.presentation
import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessagesList(
modifier: Modifier = Modifier,
hasPinnedMessage: Boolean,
hazeState: HazeState,
listState: LazyListState,
immutableMessages: ImmutableList<UiItem>,
uiMessages: ImmutableList<UiItem>,
isPaginating: Boolean,
messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Int) -> Unit = {}
onRequestScrollToCmId: (cmId: Long) -> Unit = {},
onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {}
) {
val enableAnimations = remember {
AppSettings.Experimental.moreAnimations
}
val messages = remember(immutableMessages) {
immutableMessages.toList()
}
val currentTheme = LocalThemeConfig.current
val theme = LocalThemeConfig.current
val view = LocalView.current
LazyColumn(
modifier = modifier
.fillMaxWidth()
.then(
if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState)
if (theme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else Modifier
),
state = listState,
@@ -65,7 +74,7 @@ fun MessagesList(
}
items(
items = messages,
items = uiMessages.values,
key = UiItem::id,
contentType = { item ->
when (item) {
@@ -77,6 +86,12 @@ fun MessagesList(
when (item) {
is UiItem.ActionMessage -> {
ActionMessageItem(
modifier = Modifier.then(
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
) else Modifier
),
item = item,
onClick = {
if (item.actionCmId != null) {
@@ -87,37 +102,65 @@ fun MessagesList(
}
is UiItem.Message -> {
if (item.isOut) {
OutgoingMessageBubble(
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
),
message = item,
animate = enableAnimations
)
val backgroundColor by animateColorAsState(
targetValue = if (item.isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
} else {
Color.Transparent
}
)
Surface(
modifier = Modifier
.then(
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
) else Modifier
)
.combinedClickable(
onLongClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
onMessageLongClicked(item.id)
},
onClick = { onMessageClicked(item.id) }
),
color = backgroundColor
) {
if (item.isOut) {
OutgoingMessageBubble(
modifier =
Modifier
.padding(vertical = 4.dp)
.then(
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item
)
} else {
IncomingMessageBubble(
modifier =
Modifier
.padding(vertical = 4.dp)
.then(
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
message = item
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
item {
@@ -130,6 +173,10 @@ fun MessagesList(
}
}
if (hasPinnedMessage) {
Spacer(modifier = Modifier.height(56.dp))
}
Spacer(Modifier.height(8.dp))
Spacer(
modifier = Modifier
@@ -1,5 +1,6 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -11,34 +12,41 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.ui.theme.LocalThemeConfig
@Composable
fun OutgoingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
animate: Boolean
) {
Row(
modifier = modifier.fillMaxWidth(),
modifier = modifier
.fillMaxWidth()
.then(
if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize()
else Modifier
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Column(
modifier = Modifier
.padding(end = 16.dp)
.fillMaxWidth(0.75f),
.fillMaxWidth(0.85f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.End,
) {
MessageBubble(
modifier = Modifier,
text = message.text.orDots(),
text = message.text,
isOut = true,
date = message.date,
edited = message.isEdited,
animate = animate,
isRead = message.isRead,
sendingStatus = message.sendingStatus
sendingStatus = message.sendingStatus,
pinned = message.isPinned,
important = message.isImportant,
isSelected = message.isSelected
)
}
}
@@ -0,0 +1,88 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.foundation.clickable
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.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.Icon
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
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.IconButton
@Composable
fun PinnedMessageContainer(
modifier: Modifier = Modifier,
pinnedMessage: VkMessage,
title: String,
summary: AnnotatedString?,
canChangePin: Boolean,
onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.clickable { onPinnedMessageClicked(pinnedMessage.id) }
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.rotate(45f)
.alpha(0.5f),
painter = painterResource(R.drawable.ic_round_push_pin_24),
contentDescription = null
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
summary?.let { summary ->
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(text = summary)
}
}
}
if (canChangePin) {
Spacer(modifier = Modifier.width(16.dp))
IconButton(onClick = onUnpinMessageButtonClicked) {
Icon(
modifier = Modifier.alpha(0.5f),
imageVector = Icons.Rounded.Close,
contentDescription = null
)
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
@@ -1,19 +1,20 @@
package dev.meloda.fast.messageshistory.util
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.messageshistory.model.UiItem
fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first()
fun List<UiItem>.firstMessageOrNull(): UiItem.Message? = filterIsInstance<UiItem.Message>().firstOrNull()
fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
fun List<UiItem>.indexOfMessageById(messageId: Long): Int =
indexOfFirst { it.id == messageId }
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
fun List<UiItem>.findMessageById(messageId: Long): UiItem.Message? =
firstOrNull { it.id == messageId } as UiItem.Message?
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
indexOfFirst { it.cmId == cmId }
fun List<UiItem>.indexOfMessageByCmId(cmId: Long): Int? =
indexOfFirstOrNull { it.cmId == cmId }
fun List<UiItem>.findMessageByCmId(cmId: Int): UiItem.Message =
fun List<UiItem>.findMessageByCmId(cmId: Long): UiItem.Message =
first { it.cmId == cmId } as UiItem.Message
@@ -1,10 +1,15 @@
package dev.meloda.fast.messageshistory.util
import android.content.res.Resources
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.AnnotatedString.Annotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.StringAnnotation
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
@@ -15,6 +20,7 @@ import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
@@ -22,7 +28,7 @@ import java.text.SimpleDateFormat
import java.util.Locale
import dev.meloda.fast.ui.R as UiR
private fun isAccount(fromId: Int) = fromId == UserConfig.userId
private fun isAccount(fromId: Long) = fromId == UserConfig.userId
fun VkMessage.extractAvatar() = when {
isUser() -> {
@@ -96,11 +102,12 @@ fun VkMessage.asPresentation(
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean
showTimeInActionMessages: Boolean,
isSelected: Boolean
): UiItem = when {
action != null -> UiItem.ActionMessage(
id = id,
conversationMessageId = conversationMessageId,
conversationMessageId = cmId,
text = extractActionText(
resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
@@ -111,8 +118,12 @@ fun VkMessage.asPresentation(
else -> UiItem.Message(
id = id,
conversationMessageId = conversationMessageId,
text = text,
conversationMessageId = cmId,
text = extractTextWithVisualizedMentions(
isOut = isOut,
originalText = text,
formatData = formatData
),
isOut = isOut,
fromId = fromId,
date = extractDate(),
@@ -126,9 +137,13 @@ fun VkMessage.asPresentation(
isEdited = updateTime != null,
isRead = isRead(conversation),
sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
}
},
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant
)
}
@@ -537,3 +552,144 @@ 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 += 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)
}