7 Commits

Author SHA1 Message Date
melod1n c666bd46f3 Potential fix for code scanning alert no. 1: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-03 06:25:22 +03:00
melod1n 421ca27758 Potential fix for code scanning alert no. 2: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-03 06:24:52 +03:00
melod1n 8231062ca5 Refactor: Move RippledClickContainer to core UI module
Moves the `RippledClickContainer` composable from the `messageshistory` feature module to the `core/ui` module to allow for reuse across different features.

Additionally, this change introduces text truncation with an ellipsis for the title and text within the `ReplyContainer` to prevent long content from breaking the layout.
2025-12-03 06:22:53 +03:00
melod1n 723555f634 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.
2025-12-03 06:12:44 +03:00
melod1n 3e05744a18 Update README.md 2025-12-03 06:07:45 +03:00
melod1n 821ee46cef 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.
2025-12-03 06:07:03 +03:00
melod1n dcddddea9b Refactor: Exclude outgoing messages from being marked as read
The "Mark as read" option will no longer be shown for outgoing messages in the message options dialog, as they are implicitly read.
2025-12-03 06:06:37 +03:00
20 changed files with 1823 additions and 1516 deletions
+3
View File
@@ -3,6 +3,9 @@ name: Android CI Build
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
env: env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }} RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}
+2
View File
@@ -1,4 +1,6 @@
name: Android CI Release name: Android CI Release
permissions:
contents: read
on: on:
workflow_dispatch: workflow_dispatch:
+1 -1
View File
@@ -48,7 +48,7 @@ Unofficial messenger for russian social network VKontakte
- [x] Send messages - [x] Send messages
- [x] Pinned message - [x] Pinned message
- [x] Pin & unpin messages - [x] Pin & unpin messages
- [ ] Reply to message - [x] Reply to message
- [x] Delete message - [x] Delete message
- [x] Select multiple messages - [x] Select multiple messages
- [x] Delete - [x] Delete
@@ -0,0 +1,7 @@
package dev.meloda.fast.common.util
import java.net.URLEncoder
fun String.urlEncode(encoding: String = "utf-8"): String {
return URLEncoder.encode(this, encoding)
}
@@ -32,7 +32,7 @@ interface MessagesRepository {
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> ): ApiResult<MessagesSendResponse, RestApiErrorDomain>
@@ -195,7 +195,7 @@ class MessagesRepositoryImpl(
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
@@ -203,7 +203,7 @@ class MessagesRepositoryImpl(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
replyTo = replyTo, forward = forward,
attachments = attachments, attachments = attachments,
formatData = formatData formatData = formatData
) )
@@ -32,7 +32,7 @@ interface MessagesUseCase : BaseUseCase {
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> ): Flow<State<MessagesSendResponse>>
@@ -56,7 +56,7 @@ class MessagesUseCaseImpl(
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
replyTo: Long?, forward: String?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData? formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> = flowNewState { ): Flow<State<MessagesSendResponse>> = flowNewState {
@@ -64,7 +64,7 @@ class MessagesUseCaseImpl(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
replyTo = replyTo, forward = forward,
attachments = attachments, attachments = attachments,
formatData = formatData formatData = formatData
).mapToState() ).mapToState()
@@ -34,7 +34,7 @@ data class MessagesSendRequest(
val message: String?, val message: String?,
val lat: Int? = null, val lat: Int? = null,
val lon: Int? = null, val lon: Int? = null,
val replyTo: Long? = null, val forward: String? = null,
val stickerId: Long? = null, val stickerId: Long? = null,
val disableMentions: Boolean? = null, val disableMentions: Boolean? = null,
val doNotParseLinks: Boolean? = null, val doNotParseLinks: Boolean? = null,
@@ -51,7 +51,7 @@ data class MessagesSendRequest(
message?.let { this["message"] = it } message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() } lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = 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() } stickerId?.let { this["sticker_id"] = it.toString() }
disableMentions?.let { this["disable_mentions"] = it.asInt().toString() } disableMentions?.let { this["disable_mentions"] = it.asInt().toString() }
doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() } doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() }
@@ -0,0 +1,33 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun RippledClickContainer(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(4.dp),
onClick: () -> Unit,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
.clip(shape)
.clickable(
interactionSource = null,
indication = ripple(),
onClick = onClick
),
contentAlignment = Alignment.Center
) {
content()
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L273,480L417,624L360,680L120,440L360,200L417,256L273,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z" />
</vector>
@@ -24,7 +24,9 @@ data class MessagesHistoryScreenState(
val conversation: VkConversation, val conversation: VkConversation,
val pinnedMessage: VkMessage?, val pinnedMessage: VkMessage?,
val pinnedTitle: String?, val pinnedTitle: String?,
val pinnedSummary: AnnotatedString? val pinnedSummary: AnnotatedString?,
val replyTitle: String?,
val replyText: String?
) { ) {
companion object { companion object {
@@ -43,7 +45,9 @@ data class MessagesHistoryScreenState(
conversation = VkConversation.EMPTY, conversation = VkConversation.EMPTY,
pinnedMessage = null, pinnedMessage = null,
pinnedTitle = null, pinnedTitle = null,
pinnedSummary = null pinnedSummary = null,
replyTitle = null,
replyText = null,
) )
} }
} }
@@ -117,7 +117,7 @@ fun MessageOptionsDialog(
options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin
} }
if (!message.isRead(screenState.conversation)) { if (!message.isOut && !message.isRead(screenState.conversation)) {
options += MessageOption.Read options += MessageOption.Read
} }
@@ -180,7 +180,13 @@ fun MessageOptionsDialog(
onClick = { onClick = {
onDismissed() onDismissed()
val pickedOption = options[index] 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 package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
@@ -32,10 +34,14 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -54,9 +60,9 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.ActionMode 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.components.IconButton
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.R
@OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
@@ -68,6 +74,9 @@ fun MessagesHistoryInputBar(
showEmojiButton: Boolean, showEmojiButton: Boolean,
showAttachmentButton: Boolean, showAttachmentButton: Boolean,
actionMode: ActionMode, actionMode: ActionMode,
replyTitle: String?,
replyText: String?,
inputFieldFocusRequester: Boolean,
onMessageInputChanged: (TextFieldValue) -> Unit = {}, onMessageInputChanged: (TextFieldValue) -> Unit = {},
onBoldRequested: () -> Unit = {}, onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {}, onItalicRequested: () -> Unit = {},
@@ -77,16 +86,28 @@ fun MessagesHistoryInputBar(
onSetMessageBarHeight: (Dp) -> Unit = {}, onSetMessageBarHeight: (Dp) -> Unit = {},
onEmojiButtonLongClicked: () -> Unit = {}, onEmojiButtonLongClicked: () -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {}, onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {} onActionButtonClicked: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {}
) { ) {
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current val density = LocalDensity.current
val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() }
LaunchedEffect(inputFieldFocusRequester) {
if (inputFieldFocusRequester) {
focusRequester.requestFocus()
}
}
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val inputBarTopCornerRadius by animateDpAsState(
targetValue = if (replyTitle == null) 24.dp else 0.dp,
label = "inputBarTopCornerRadius"
)
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -95,6 +116,14 @@ fun MessagesHistoryInputBar(
.navigationBarsPadding() .navigationBarsPadding()
.imePadding() .imePadding()
) { ) {
AnimatedVisibility(replyTitle != null) {
ReplyContainer(
title = replyTitle.orEmpty(),
text = replyText.orEmpty(),
onCloseClicked = onReplyCloseClicked,
)
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -102,10 +131,17 @@ fun MessagesHistoryInputBar(
.imeNestedScroll(), .imeNestedScroll(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(8.dp))
Row( Row(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(36.dp)) .clip(
RoundedCornerShape(
topStart = inputBarTopCornerRadius,
topEnd = inputBarTopCornerRadius,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
)
.then( .then(
if (theme.enableBlur) { if (theme.enableBlur) {
Modifier Modifier
@@ -166,6 +202,7 @@ fun MessagesHistoryInputBar(
TextField( TextField(
modifier = Modifier modifier = Modifier
.focusRequester(focusRequester)
.weight(1f) .weight(1f)
.appendTextContextMenuComponents { .appendTextContextMenuComponents {
separator() separator()
@@ -287,7 +324,7 @@ fun MessagesHistoryInputBar(
Spacer(modifier = Modifier.width(6.dp)) 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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
@@ -30,6 +29,7 @@ fun MessagesHistoryRoute(
val baseError by viewModel.baseError.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle() val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val inputFieldFocusRequester by viewModel.inputFieldFocusRequester.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) { LaunchedEffect(navigationEvent) {
val needToConsume = when (val navigation = navigationEvent) { val needToConsume = when (val navigation = navigationEvent) {
@@ -56,6 +56,7 @@ fun MessagesHistoryRoute(
showEmojiButton = AppSettings.General.showEmojiButton, showEmojiButton = AppSettings.General.showEmojiButton,
showAttachmentButton = AppSettings.General.showAttachmentButton, showAttachmentButton = AppSettings.General.showAttachmentButton,
enableHaptic = AppSettings.General.enableHaptic, enableHaptic = AppSettings.General.enableHaptic,
inputFieldFocusRequester = inputFieldFocusRequester,
onBack = onBack, onBack = onBack,
onClose = viewModel::onCloseButtonClicked, onClose = viewModel::onCloseButtonClicked,
onScrolledToIndex = viewModel::onScrolledToIndex, onScrolledToIndex = viewModel::onScrolledToIndex,
@@ -77,7 +78,8 @@ fun MessagesHistoryRoute(
onItalicRequested = viewModel::onItalicClicked, onItalicRequested = viewModel::onItalicClicked,
onUnderlineRequested = viewModel::onUnderlineClicked, onUnderlineRequested = viewModel::onUnderlineClicked,
onLinkRequested = viewModel::onLinkClicked, onLinkRequested = viewModel::onLinkClicked,
onRegularRequested = viewModel::onRegularClicked onRegularRequested = viewModel::onRegularClicked,
onReplyCloseClicked = viewModel::onReplyCloseClicked
) )
HandleDialogs( HandleDialogs(
@@ -44,13 +44,13 @@ import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage 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.Loader
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList import dev.meloda.fast.ui.util.emptyImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
@@ -70,6 +70,7 @@ fun MessagesHistoryScreen(
showEmojiButton: Boolean = false, showEmojiButton: Boolean = false,
showAttachmentButton: Boolean = false, showAttachmentButton: Boolean = false,
enableHaptic: Boolean = false, enableHaptic: Boolean = false,
inputFieldFocusRequester: Boolean,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onClose: () -> Unit = {}, onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {}, onScrolledToIndex: () -> Unit = {},
@@ -91,7 +92,8 @@ fun MessagesHistoryScreen(
onItalicRequested: () -> Unit = {}, onItalicRequested: () -> Unit = {},
onLinkRequested: () -> Unit = {}, onLinkRequested: () -> Unit = {},
onUnderlineRequested: () -> Unit = {}, onUnderlineRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {} onRegularRequested: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
@@ -215,6 +217,7 @@ fun MessagesHistoryScreen(
uiMessages = uiMessages, uiMessages = uiMessages,
isSelectedAtLeastOne = isSelectedAtLeastOne, isSelectedAtLeastOne = isSelectedAtLeastOne,
isPaginating = screenState.isPaginating, isPaginating = screenState.isPaginating,
isReplying = screenState.replyTitle != null,
messageBarHeight = messageBarHeight, messageBarHeight = messageBarHeight,
onRequestScrollToCmId = { cmId -> onRequestScrollToCmId = { cmId ->
val index = uiMessages.values.indexOfMessageByCmId(cmId) val index = uiMessages.values.indexOfMessageByCmId(cmId)
@@ -252,10 +255,14 @@ fun MessagesHistoryScreen(
showEmojiButton = showEmojiButton, showEmojiButton = showEmojiButton,
showAttachmentButton = showAttachmentButton, showAttachmentButton = showAttachmentButton,
actionMode = screenState.actionMode, actionMode = screenState.actionMode,
replyTitle = screenState.replyTitle,
replyText = screenState.replyText,
inputFieldFocusRequester = inputFieldFocusRequester,
onSetMessageBarHeight = { messageBarHeight = it }, onSetMessageBarHeight = { messageBarHeight = it },
onEmojiButtonLongClicked = onEmojiButtonLongClicked, onEmojiButtonLongClicked = onEmojiButtonLongClicked,
onAttachmentButtonClicked = onAttachmentButtonClicked, onAttachmentButtonClicked = onAttachmentButtonClicked,
onActionButtonClicked = onActionButtonClicked onActionButtonClicked = onActionButtonClicked,
onReplyCloseClicked = onReplyCloseClicked
) )
when { when {
@@ -53,6 +53,7 @@ fun MessagesList(
uiMessages: ImmutableList<UiItem>, uiMessages: ImmutableList<UiItem>,
isSelectedAtLeastOne: Boolean, isSelectedAtLeastOne: Boolean,
isPaginating: Boolean, isPaginating: Boolean,
isReplying: Boolean,
messageBarHeight: Dp, messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Long) -> Unit = {}, onRequestScrollToCmId: (cmId: Long) -> Unit = {},
onMessageClicked: (Long) -> Unit = {}, onMessageClicked: (Long) -> Unit = {},
@@ -132,6 +133,10 @@ fun MessagesList(
reverseLayout = true reverseLayout = true
) { ) {
item { item {
AnimatedVisibility(isReplying) {
Spacer(modifier = Modifier.height(48.dp))
}
Spacer(modifier = Modifier.height(messageBarHeight.plus(18.dp))) Spacer(modifier = Modifier.height(messageBarHeight.plus(18.dp)))
Spacer( Spacer(
modifier = Modifier modifier = Modifier
@@ -0,0 +1,114 @@
package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.RippledClickContainer
@Composable
fun ReplyContainer(
onCloseClicked: () -> Unit = {},
title: String,
text: String?,
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
) {
Row(
modifier = modifier
.padding(horizontal = 8.dp)
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(
RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
)
.background(backgroundColor)
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.round_reply_24px),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AnimatedVisibility(text != null) {
Text(
text = text.orEmpty(),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
RippledClickContainer(
modifier = Modifier.size(36.dp),
shape = CircleShape,
onClick = onCloseClicked
) {
Icon(
painter = painterResource(R.drawable.round_close_24px),
contentDescription = null,
)
}
}
}
@Preview
@Composable
private fun ReplyContainerPreview() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
ReplyContainer(
onCloseClicked = {},
title = "В ответ Ишак",
text = "Приветствую тебя, Ишак!",
)
}
}