feat(messages): Implement reply functionality

This commit introduces the ability to reply to messages.

- **API & Data Layer:**
  - Replaced `replyTo` parameter with `forward` in `sendMessage` calls across the data, domain, and repository layers to support the new reply mechanism.

- **ViewModel:**
  - Added logic to handle the reply state, including storing the replied message's ID (`replyToCmId`).
  - When a message is sent, it now correctly constructs a `forward` JSON object if it is a reply.
  - The UI state (`MessagesHistoryScreenState`) is updated to show and hide the reply preview.
  - Added a `onReplyCloseClicked` handler to cancel a reply.
  - The ViewModel interface was removed, and the implementation class `MessagesHistoryViewModelImpl` is used directly.

- **UI (Compose):**
  - A new `ReplyContainer` is displayed above the message input bar when a reply is active.
  - The input bar's corner radius animates to integrate with the reply container.
  - Added a `FocusRequester` to automatically focus the input field when the reply action is selected.
  - Added spacing in the message list to prevent the reply preview from overlapping messages.
  - The message options dialog now passes the `messageId` and `cmId` when an option is picked.
This commit is contained in:
2025-12-03 06:07:03 +03:00
parent dcddddea9b
commit 821ee46cef
12 changed files with 150 additions and 79 deletions
@@ -32,7 +32,7 @@ interface MessagesRepository {
peerId: Long,
randomId: Long,
message: String?,
replyTo: Long?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain>
@@ -195,7 +195,7 @@ class MessagesRepositoryImpl(
peerId: Long,
randomId: Long,
message: String?,
replyTo: Long?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
@@ -203,7 +203,7 @@ class MessagesRepositoryImpl(
peerId = peerId,
randomId = randomId,
message = message,
replyTo = replyTo,
forward = forward,
attachments = attachments,
formatData = formatData
)
@@ -32,7 +32,7 @@ interface MessagesUseCase : BaseUseCase {
peerId: Long,
randomId: Long,
message: String?,
replyTo: Long?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>>
@@ -56,7 +56,7 @@ class MessagesUseCaseImpl(
peerId: Long,
randomId: Long,
message: String?,
replyTo: Long?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> = flowNewState {
@@ -64,7 +64,7 @@ class MessagesUseCaseImpl(
peerId = peerId,
randomId = randomId,
message = message,
replyTo = replyTo,
forward = forward,
attachments = attachments,
formatData = formatData
).mapToState()
@@ -34,7 +34,7 @@ data class MessagesSendRequest(
val message: String?,
val lat: Int? = null,
val lon: Int? = null,
val replyTo: Long? = null,
val forward: String? = null,
val stickerId: Long? = null,
val disableMentions: Boolean? = null,
val doNotParseLinks: Boolean? = null,
@@ -51,7 +51,7 @@ data class MessagesSendRequest(
message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() }
replyTo?.let { this["reply_to"] = it.toString() }
forward?.let { this["forward"] = it }
stickerId?.let { this["sticker_id"] = it.toString() }
disableMentions?.let { this["disable_mentions"] = it.asInt().toString() }
doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() }
@@ -65,62 +65,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs
import kotlin.random.Random
interface MessagesHistoryViewModel {
val screenState: StateFlow<MessagesHistoryScreenState>
val navigation: StateFlow<MessageNavigation?>
val messages: StateFlow<List<VkMessage>>
val uiMessages: StateFlow<List<UiItem>>
val dialog: StateFlow<MessageDialog?>
val selectedMessages: StateFlow<List<VkMessage>>
val isNeedToScrollToIndex: StateFlow<Int?>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
fun onNavigationConsumed()
fun onTopBarClicked()
fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle)
fun onDialogDismissed(dialog: MessageDialog)
fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle)
fun onScrolledToIndex()
fun onCloseButtonClicked()
fun onRefresh()
fun onAttachmentButtonClicked()
fun onMessageInputChanged(newText: TextFieldValue)
fun onEmojiButtonLongClicked()
fun onActionButtonClicked()
fun onPaginationConditionsMet()
fun onMessageClicked(messageId: Long)
fun onMessageLongClicked(messageId: Long)
fun onPinnedMessageClicked(messageId: Long)
fun onUnpinMessageClicked()
fun onDeleteSelectedMessagesClicked()
fun onBoldClicked()
fun onItalicClicked()
fun onUnderlineClicked()
fun onLinkClicked()
fun onRegularClicked()
}
class MessagesHistoryViewModelImpl(
private val applicationContext: Context,
private val messagesUseCase: MessagesUseCase,
@@ -137,6 +90,8 @@ class MessagesHistoryViewModelImpl(
override val dialog = MutableStateFlow<MessageDialog?>(null)
override val selectedMessages = MutableStateFlow<List<VkMessage>>(emptyList())
override val inputFieldFocusRequester = MutableStateFlow(false)
override val isNeedToScrollToIndex = MutableStateFlow<Int?>(null)
override val baseError = MutableStateFlow<BaseError?>(null)
@@ -154,6 +109,8 @@ class MessagesHistoryViewModelImpl(
private val sendingMessages: MutableList<VkMessage> = mutableListOf()
private val failedMessages: MutableList<VkMessage> = mutableListOf()
private var replyToCmId: Long? = null
init {
val arguments = MessagesHistory.from(savedStateHandle).arguments
@@ -268,6 +225,9 @@ class MessagesHistoryViewModelImpl(
override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) {
when (dialog) {
is MessageDialog.MessageOptions -> {
val messageId = bundle.getLong("messageId")
val cmId = bundle.getLong("cmId")
when (val option = bundle.getParcelableCompat("option", MessageOption::class)) {
null -> Unit
@@ -275,9 +235,30 @@ class MessagesHistoryViewModelImpl(
// TODO: 28-Mar-25, Danil Nikolaev: retry sending
}
MessageOption.Reply -> {}
MessageOption.ForwardHere -> {}
MessageOption.Forward -> {}
MessageOption.Reply -> {
inputFieldFocusRequester.setValue { true }
replyToCmId = cmId
screenState.setValue { old ->
val msg = messages.value.find { it.id == messageId }
if (msg == null) {
old
} else {
old.copy(
replyTitle = msg.extractTitle(),
replyText = msg.text
)
}
}
}
MessageOption.ForwardHere -> {
}
MessageOption.Forward -> {
}
MessageOption.Pin -> {
this.dialog.setValue {
@@ -584,6 +565,17 @@ class MessagesHistoryViewModelImpl(
updateStyles()
}
override fun onReplyCloseClicked() {
replyToCmId = null
screenState.setValue { old ->
old.copy(
replyTitle = null,
replyText = null
)
}
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
@@ -927,6 +919,7 @@ class MessagesHistoryViewModelImpl(
forwards = null,
attachments = null,
replyMessage = when {
replyToCmId != null -> messages.value.find { it.cmId == replyToCmId }
else -> null
},
geoType = null,
@@ -947,15 +940,32 @@ class MessagesHistoryViewModelImpl(
screenState.setValue { old ->
old.copy(
message = TextFieldValue(),
actionMode = ActionMode.RECORD_AUDIO
actionMode = ActionMode.RECORD_AUDIO,
replyTitle = null,
replyText = null
)
}
val replyCmId = replyToCmId
replyToCmId = null
val forward = when {
replyCmId != null -> {
buildJsonObject {
put("peer_id", screenState.value.conversationId)
put("conversation_message_ids", buildJsonArray { add(replyCmId) })
put("is_reply", true)
}.toString()
}
else -> null
}
messagesUseCase.sendMessage(
peerId = screenState.value.conversationId,
randomId = newMessage.randomId,
message = newMessage.text,
replyTo = null,
forward = forward,
attachments = null,
formatData = newMessage.formatData
).listenValue(viewModelScope) { state ->
@@ -24,7 +24,9 @@ data class MessagesHistoryScreenState(
val conversation: VkConversation,
val pinnedMessage: VkMessage?,
val pinnedTitle: String?,
val pinnedSummary: AnnotatedString?
val pinnedSummary: AnnotatedString?,
val replyTitle: String?,
val replyText: String?
) {
companion object {
@@ -43,7 +45,9 @@ data class MessagesHistoryScreenState(
conversation = VkConversation.EMPTY,
pinnedMessage = null,
pinnedTitle = null,
pinnedSummary = null
pinnedSummary = null,
replyTitle = null,
replyText = null,
)
}
}
@@ -180,7 +180,13 @@ fun MessageOptionsDialog(
onClick = {
onDismissed()
val pickedOption = options[index]
onItemPicked(bundleOf("option" to pickedOption))
onItemPicked(
bundleOf(
"option" to pickedOption,
"messageId" to message.id,
"cmId" to message.cmId
)
)
}
)
}
@@ -1,7 +1,9 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
@@ -32,10 +34,14 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.LaunchedEffect
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
@@ -54,9 +60,9 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.R
@OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class)
@Composable
@@ -68,6 +74,9 @@ fun MessagesHistoryInputBar(
showEmojiButton: Boolean,
showAttachmentButton: Boolean,
actionMode: ActionMode,
replyTitle: String?,
replyText: String?,
inputFieldFocusRequester: Boolean,
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {},
@@ -77,16 +86,28 @@ fun MessagesHistoryInputBar(
onSetMessageBarHeight: (Dp) -> Unit = {},
onEmojiButtonLongClicked: () -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {}
onActionButtonClicked: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {}
) {
val view = LocalView.current
val context = LocalContext.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
LaunchedEffect(inputFieldFocusRequester) {
if (inputFieldFocusRequester) {
focusRequester.requestFocus()
}
}
val theme = LocalThemeConfig.current
val inputBarTopCornerRadius by animateDpAsState(
targetValue = if (replyTitle == null) 24.dp else 0.dp,
label = "inputBarTopCornerRadius"
)
Column(
modifier = modifier
.fillMaxWidth()
@@ -95,6 +116,14 @@ fun MessagesHistoryInputBar(
.navigationBarsPadding()
.imePadding()
) {
AnimatedVisibility(replyTitle != null) {
ReplyContainer(
title = replyTitle.orEmpty(),
text = replyText.orEmpty(),
onCloseClicked = onReplyCloseClicked,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -102,10 +131,17 @@ fun MessagesHistoryInputBar(
.imeNestedScroll(),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(10.dp))
Spacer(modifier = Modifier.width(8.dp))
Row(
modifier = Modifier
.clip(RoundedCornerShape(36.dp))
.clip(
RoundedCornerShape(
topStart = inputBarTopCornerRadius,
topEnd = inputBarTopCornerRadius,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
)
.then(
if (theme.enableBlur) {
Modifier
@@ -166,6 +202,7 @@ fun MessagesHistoryInputBar(
TextField(
modifier = Modifier
.focusRequester(focusRequester)
.weight(1f)
.appendTextContextMenuComponents {
separator()
@@ -287,7 +324,7 @@ fun MessagesHistoryInputBar(
Spacer(modifier = Modifier.width(6.dp))
}
Spacer(modifier = Modifier.width(10.dp))
Spacer(modifier = Modifier.width(8.dp))
}
}
}
@@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
@@ -30,6 +29,7 @@ fun MessagesHistoryRoute(
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val inputFieldFocusRequester by viewModel.inputFieldFocusRequester.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val needToConsume = when (val navigation = navigationEvent) {
@@ -56,6 +56,7 @@ fun MessagesHistoryRoute(
showEmojiButton = AppSettings.General.showEmojiButton,
showAttachmentButton = AppSettings.General.showAttachmentButton,
enableHaptic = AppSettings.General.enableHaptic,
inputFieldFocusRequester = inputFieldFocusRequester,
onBack = onBack,
onClose = viewModel::onCloseButtonClicked,
onScrolledToIndex = viewModel::onScrolledToIndex,
@@ -77,7 +78,8 @@ fun MessagesHistoryRoute(
onItalicRequested = viewModel::onItalicClicked,
onUnderlineRequested = viewModel::onUnderlineClicked,
onLinkRequested = viewModel::onLinkClicked,
onRegularRequested = viewModel::onRegularClicked
onRegularRequested = viewModel::onRegularClicked,
onReplyCloseClicked = viewModel::onReplyCloseClicked
)
HandleDialogs(
@@ -44,13 +44,13 @@ 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.R
import dev.meloda.fast.ui.components.Loader
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 kotlinx.coroutines.launch
import dev.meloda.fast.ui.R
@OptIn(
ExperimentalMaterial3Api::class,
@@ -70,6 +70,7 @@ fun MessagesHistoryScreen(
showEmojiButton: Boolean = false,
showAttachmentButton: Boolean = false,
enableHaptic: Boolean = false,
inputFieldFocusRequester: Boolean,
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {},
@@ -91,7 +92,8 @@ fun MessagesHistoryScreen(
onItalicRequested: () -> Unit = {},
onLinkRequested: () -> Unit = {},
onUnderlineRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {}
onRegularRequested: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {},
) {
val context = LocalContext.current
val view = LocalView.current
@@ -215,6 +217,7 @@ fun MessagesHistoryScreen(
uiMessages = uiMessages,
isSelectedAtLeastOne = isSelectedAtLeastOne,
isPaginating = screenState.isPaginating,
isReplying = screenState.replyTitle != null,
messageBarHeight = messageBarHeight,
onRequestScrollToCmId = { cmId ->
val index = uiMessages.values.indexOfMessageByCmId(cmId)
@@ -252,10 +255,14 @@ fun MessagesHistoryScreen(
showEmojiButton = showEmojiButton,
showAttachmentButton = showAttachmentButton,
actionMode = screenState.actionMode,
replyTitle = screenState.replyTitle,
replyText = screenState.replyText,
inputFieldFocusRequester = inputFieldFocusRequester,
onSetMessageBarHeight = { messageBarHeight = it },
onEmojiButtonLongClicked = onEmojiButtonLongClicked,
onAttachmentButtonClicked = onAttachmentButtonClicked,
onActionButtonClicked = onActionButtonClicked
onActionButtonClicked = onActionButtonClicked,
onReplyCloseClicked = onReplyCloseClicked
)
when {
@@ -53,6 +53,7 @@ fun MessagesList(
uiMessages: ImmutableList<UiItem>,
isSelectedAtLeastOne: Boolean,
isPaginating: Boolean,
isReplying: Boolean,
messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Long) -> Unit = {},
onMessageClicked: (Long) -> Unit = {},
@@ -132,6 +133,10 @@ fun MessagesList(
reverseLayout = true
) {
item {
AnimatedVisibility(isReplying) {
Spacer(modifier = Modifier.height(48.dp))
}
Spacer(modifier = Modifier.height(messageBarHeight.plus(18.dp)))
Spacer(
modifier = Modifier