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:
workflow_dispatch:
permissions:
contents: read
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}
+2
View File
@@ -1,4 +1,6 @@
name: Android CI Release
permissions:
contents: read
on:
workflow_dispatch:
+1 -1
View File
@@ -48,7 +48,7 @@ Unofficial messenger for russian social network VKontakte
- [x] Send messages
- [x] Pinned message
- [x] Pin & unpin messages
- [ ] Reply to message
- [x] Reply to message
- [x] Delete message
- [x] Select multiple messages
- [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,
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() }
@@ -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 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,
)
}
}
@@ -117,7 +117,7 @@ fun MessageOptionsDialog(
options += if (message.isPinned) MessageOption.Unpin else MessageOption.Pin
}
if (!message.isRead(screenState.conversation)) {
if (!message.isOut && !message.isRead(screenState.conversation)) {
options += MessageOption.Read
}
@@ -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
@@ -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 = "Приветствую тебя, Ишак!",
)
}
}