feat(messages): add message selection and actions

This commit is contained in:
2025-03-26 22:06:55 +03:00
parent 296c3ce7a0
commit 4d18c86f04
12 changed files with 380 additions and 180 deletions
@@ -170,7 +170,6 @@ fun MainScreen(
conversationsScreen(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
onPhotoClicked = onPhotoClicked,
onCreateChatClicked = onCreateChatClicked,
navController = navController,
)
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10,9V7.41c0,-0.89 -1.08,-1.34 -1.71,-0.71L3.7,11.29c-0.39,0.39 -0.39,1.02 0,1.41l4.59,4.59c0.63,0.63 1.71,0.19 1.71,-0.7V14.9c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7,7.56c0,-0.94 -1.14,-1.42 -1.81,-0.75L0.71,11.29c-0.39,0.39 -0.39,1.02 0,1.41l4.48,4.48c0.67,0.68 1.81,0.2 1.81,-0.74 0,-0.28 -0.11,-0.55 -0.31,-0.75L3,12l3.69,-3.69c0.2,-0.2 0.31,-0.47 0.31,-0.75zM13,9V7.41c0,-0.89 -1.08,-1.34 -1.71,-0.71L6.7,11.29c-0.39,0.39 -0.39,1.02 0,1.41l4.59,4.59c0.63,0.63 1.71,0.18 1.71,-0.71V14.9c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
</vector>
@@ -16,7 +16,6 @@ object Conversations
fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onCreateChatClicked: () -> Unit,
navController: NavController,
) {
@@ -27,7 +26,6 @@ fun NavGraphBuilder.conversationsScreen(
ConversationsRoute(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
onConversationPhotoClicked = onPhotoClicked,
onCreateChatButtonClicked = onCreateChatClicked,
viewModel = viewModel
)
@@ -6,7 +6,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -40,7 +39,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
@@ -69,10 +67,8 @@ fun ConversationItem(
maxLines: Int,
isUserAccount: Boolean,
conversation: UiConversation,
modifier: Modifier = Modifier,
onPhotoClicked: (url: String) -> Unit
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val hapticFeedback = LocalHapticFeedback.current
val bottomStartCornerRadius by animateDpAsState(
@@ -154,12 +150,7 @@ fun ConversationItem(
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.clickable {
if (avatarImage is String) {
onPhotoClicked(avatarImage)
}
},
.clip(CircleShape),
placeholder = painterResource(id = UiR.drawable.ic_account_circle_cut)
)
}
@@ -39,13 +39,10 @@ fun ConversationsList(
maxLines: Int,
modifier: Modifier,
onOptionClicked: (UiConversation, ConversationOption) -> Unit,
padding: PaddingValues,
onPhotoClicked: (url: String) -> Unit
padding: PaddingValues
) {
val coroutineScope = rememberCoroutineScope()
val bottomPadding = LocalBottomPadding.current
LazyColumn(
modifier = modifier,
state = state
@@ -71,8 +68,7 @@ fun ConversationsList(
maxLines = maxLines,
isUserAccount = isUserAccount,
conversation = conversation,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
onPhotoClicked = onPhotoClicked
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
Spacer(modifier = Modifier.height(8.dp))
@@ -85,7 +85,6 @@ import dev.meloda.fast.ui.R as UiR
fun ConversationsRoute(
onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit,
onConversationPhotoClicked: (url: String) -> Unit,
onCreateChatButtonClicked: () -> Unit,
viewModel: ConversationsViewModel
) {
@@ -107,7 +106,6 @@ fun ConversationsRoute(
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh,
onConversationPhotoClicked = onConversationPhotoClicked,
onCreateChatButtonClicked = onCreateChatButtonClicked,
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset
@@ -135,7 +133,6 @@ fun ConversationsScreen(
onPaginationConditionsMet: () -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onConversationPhotoClicked: (url: String) -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {}
@@ -356,8 +353,7 @@ fun ConversationsScreen(
Modifier
}.fillMaxSize(),
onOptionClicked = onOptionClicked,
padding = padding,
onPhotoClicked = onConversationPhotoClicked
padding = padding
)
if (screenState.conversations.isEmpty()) {
@@ -1,6 +1,8 @@
package dev.meloda.fast.messageshistory
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.SavedStateHandle
@@ -45,6 +47,7 @@ import kotlin.random.Random
interface MessagesHistoryViewModel {
val screenState: StateFlow<MessagesHistoryScreenState>
val selectedMessages: StateFlow<List<Int>>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
@@ -53,6 +56,7 @@ interface MessagesHistoryViewModel {
val canPaginate: StateFlow<Boolean>
fun onCloseButtonClicked()
fun onRefresh()
fun onAttachmentButtonClicked()
fun onMessageInputChanged(newText: TextFieldValue)
@@ -60,19 +64,23 @@ interface MessagesHistoryViewModel {
fun onActionButtonClicked()
fun onPaginationConditionsMet()
fun onMessageClicked(messageId: Int)
fun onMessageLongClicked(messageId: Int)
}
class MessagesHistoryViewModelImpl(
private val applicationContext: Context,
private val messagesUseCase: MessagesUseCase,
private val conversationsUseCase: ConversationsUseCase,
private val resourceProvider: ResourceProvider,
private val userSettings: UserSettings,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase,
updatesParser: LongPollUpdatesParser,
savedStateHandle: SavedStateHandle
) : MessagesHistoryViewModel, ViewModel() {
override val screenState = MutableStateFlow(MessagesHistoryScreenState.EMPTY)
override val selectedMessages = MutableStateFlow<List<Int>>(emptyList())
override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
@@ -104,6 +112,18 @@ class MessagesHistoryViewModelImpl(
)
}
override fun onCloseButtonClicked() {
screenState.setValue { old ->
old.copy(
messages = old.messages.map {
if (it is UiItem.Message) it.copy(isSelected = false)
else it
}
)
}
selectedMessages.setValue { emptyList() }
}
override fun onRefresh() {
loadMessagesHistory(offset = 0)
}
@@ -158,6 +178,60 @@ class MessagesHistoryViewModelImpl(
loadMessagesHistory()
}
override fun onMessageClicked(messageId: Int) {
val messageIndex = screenState.value.messages.indexOfFirstOrNull {
it is UiItem.Message && it.id == messageId
} ?: return
val newMessages = screenState.value.messages.toMutableList()
val currentMessage: UiItem.Message = newMessages[messageIndex] as UiItem.Message
if (selectedMessages.value.isNotEmpty()) {
val isSelected = selectedMessages.value.contains(currentMessage.id)
newMessages[messageIndex] = currentMessage.copy(
isSelected = !isSelected
)
screenState.setValue { old -> old.copy(messages = newMessages) }
selectedMessages.setValue { old ->
old.toMutableList().also {
if (isSelected) {
it.remove(currentMessage.id)
} else {
it.add(currentMessage.id)
}
}
}
} else {
Toast.makeText(applicationContext, "Click", Toast.LENGTH_SHORT).show()
}
}
override fun onMessageLongClicked(messageId: Int) {
val messageIndex = screenState.value.messages.indexOfFirstOrNull {
it is UiItem.Message && it.id == messageId
} ?: return
val newMessages = screenState.value.messages.toMutableList()
val currentMessage: UiItem.Message = newMessages[messageIndex] as UiItem.Message
val isSelected = selectedMessages.value.contains(currentMessage.id)
newMessages[messageIndex] = currentMessage.copy(
isSelected = !isSelected
)
screenState.setValue { old -> old.copy(messages = newMessages) }
selectedMessages.setValue { old ->
old.toMutableList().also {
if (isSelected) {
it.remove(currentMessage.id)
} else {
it.add(currentMessage.id)
}
}
}
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
@@ -24,7 +24,8 @@ sealed class UiItem(
val avatar: UiImage,
val isEdited: Boolean,
val isRead: Boolean,
val sendingStatus: SendingStatus = SendingStatus.SENT
val sendingStatus: SendingStatus = SendingStatus.SENT,
val isSelected: Boolean = false
) : UiItem(id, conversationMessageId)
data class ActionMessage(
@@ -31,12 +31,11 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun IncomingMessageBubble(
modifier: Modifier = Modifier,
message: UiItem.Message,
animate: Boolean
animate: Boolean,
) {
val context = LocalContext.current
Row(modifier = modifier.fillMaxWidth()) {
Row(
modifier = modifier
modifier = Modifier
.fillMaxWidth(0.75f)
.padding(start = 16.dp),
verticalAlignment = Alignment.Bottom,
@@ -48,7 +47,7 @@ fun IncomingMessageBubble(
message.avatar.extractUrl()?.let { url ->
rememberAsyncImagePainter(
model = url,
imageLoader = context.imageLoader
imageLoader = LocalContext.current.imageLoader
)
} ?: painterResource(id = message.avatar.extractResId()),
contentDescription = null,
@@ -87,4 +86,6 @@ fun IncomingMessageBubble(
)
}
}
Spacer(modifier=Modifier.fillMaxWidth(0.25f))
}
}
@@ -1,5 +1,6 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
@@ -33,6 +34,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
@@ -107,6 +109,7 @@ fun MessagesHistoryRoute(
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
@@ -115,10 +118,12 @@ fun MessagesHistoryRoute(
MessagesHistoryScreen(
screenState = screenState,
selectedMessages = ImmutableList.copyOf(selectedMessages),
baseError = baseError,
canPaginate = canPaginate,
showEmojiButton = showEmojiButton,
onBack = onBack,
onClose = viewModel::onCloseButtonClicked,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onChatMaterialsDropdownItemClicked = onChatMaterialsDropdownItemClicked,
onRefresh = viewModel::onRefresh,
@@ -126,7 +131,9 @@ fun MessagesHistoryRoute(
onMessageInputChanged = viewModel::onMessageInputChanged,
onAttachmentButtonClicked = viewModel::onAttachmentButtonClicked,
onActionButtonClicked = viewModel::onActionButtonClicked,
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
onMessageClicked = viewModel::onMessageClicked,
onMessageLongClicked = viewModel::onMessageLongClicked
)
}
@@ -138,10 +145,12 @@ fun MessagesHistoryRoute(
@Composable
fun MessagesHistoryScreen(
screenState: MessagesHistoryScreenState = MessagesHistoryScreenState.EMPTY,
selectedMessages: ImmutableList<Int> = ImmutableList.empty(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
showEmojiButton: Boolean = false,
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onChatMaterialsDropdownItemClicked: (peerId: Int, conversationMessageId: Int) -> Unit = { _, _ -> },
onRefresh: () -> Unit = {},
@@ -149,7 +158,9 @@ fun MessagesHistoryScreen(
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {},
onEmojiButtonLongClicked: () -> Unit = {}
onEmojiButtonLongClicked: () -> Unit = {},
onMessageClicked: (Int) -> Unit = {},
onMessageLongClicked: (Int) -> Unit = {}
) {
val view = LocalView.current
@@ -157,6 +168,11 @@ fun MessagesHistoryScreen(
val currentTheme = LocalThemeConfig.current
BackHandler(
enabled = selectedMessages.isNotEmpty(),
onBack = onClose
)
val listState = rememberLazyListState()
val paginationConditionMet by remember(canPaginate, listState) {
@@ -191,6 +207,10 @@ fun MessagesHistoryScreen(
val density = LocalDensity.current
val showReplyAction by remember(screenState) {
mutableStateOf(selectedMessages.size == 1)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
@@ -213,6 +233,7 @@ fun MessagesHistoryScreen(
.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
if (selectedMessages.isEmpty()) {
val avatar = screenState.avatar.getImage()
if (avatar is Painter) {
Image(
@@ -234,11 +255,14 @@ fun MessagesHistoryScreen(
}
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,9 +270,18 @@ 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"
)
}
@@ -259,6 +292,46 @@ fun MessagesHistoryScreen(
)
),
actions = {
if (selectedMessages.isNotEmpty()) {
AnimatedVisibility(showReplyAction) {
IconButton(
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
}
) {
Icon(
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_delete_outline_24),
contentDescription = null
)
}
} else {
IconButton(
onClick = { dropDownMenuExpanded = true }
) {
@@ -309,6 +382,7 @@ fun MessagesHistoryScreen(
)
}
}
}
)
val showHorizontalProgressBar by remember(screenState) {
@@ -323,7 +397,6 @@ fun MessagesHistoryScreen(
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
@@ -343,7 +416,16 @@ fun MessagesHistoryScreen(
index = screenState.messages.indexOfMessageByCmId(cmId)
)
}
},
onMessageClicked = { id ->
if (selectedMessages.isNotEmpty()) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.CONTEXT_CLICK)
}
}
onMessageClicked(id)
},
onMessageLongClicked = onMessageLongClicked
)
Column(
@@ -1,30 +1,41 @@
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,
@@ -33,7 +44,9 @@ fun MessagesList(
immutableMessages: ImmutableList<UiItem>,
isPaginating: Boolean,
messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Int) -> Unit = {}
onRequestScrollToCmId: (cmId: Int) -> Unit = {},
onMessageClicked: (Int) -> Unit = {},
onMessageLongClicked: (Int) -> Unit = {}
) {
val enableAnimations = remember {
AppSettings.Experimental.moreAnimations
@@ -42,13 +55,14 @@ fun MessagesList(
immutableMessages.toList()
}
val currentTheme = LocalThemeConfig.current
val view = LocalView.current
LazyColumn(
modifier = modifier
.fillMaxWidth()
.then(
if (currentTheme.enableBlur) {
Modifier.haze(state = hazeState)
Modifier.hazeSource(state = hazeState)
} else Modifier
),
state = listState,
@@ -87,10 +101,33 @@ fun MessagesList(
}
is UiItem.Message -> {
val backgroundColor by animateColorAsState(
targetValue = if (item.isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
} else {
Color.Transparent
}
)
Surface(
modifier = 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.then(
Modifier
.padding(vertical = 4.dp)
.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
@@ -103,7 +140,9 @@ fun MessagesList(
} else {
IncomingMessageBubble(
modifier =
Modifier.then(
Modifier
.padding(vertical = 4.dp)
.then(
if (enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
@@ -116,8 +155,7 @@ fun MessagesList(
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
item {