Reply attachment (#195)

This commit is contained in:
2025-06-25 09:04:50 +03:00
committed by GitHub
parent 56683bea96
commit 76dd1e2ce7
15 changed files with 289 additions and 49 deletions
@@ -24,6 +24,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -88,7 +89,7 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions()
setContent {
val context = LocalContext.current
val resources = LocalResources.current
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
@@ -164,10 +165,10 @@ class MainActivity : AppCompatActivity() {
}
val deviceWidthDp = remember(true) {
context.resources.displayMetrics.widthPixels.pxToDp()
resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
context.resources.displayMetrics.heightPixels.pxToDp()
resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
@@ -36,7 +36,7 @@ class VkUsersMap(
if (message.fromId > 0) map[message.fromId]
else null
fun user(userid: Long): VkUser? = map[userId]
fun user(userId: Long): VkUser? = map[userId]
companion object {
@@ -75,7 +75,13 @@ class ConversationsRepositoryImpl(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
).also { VkMemoryCache[message.id] = it }
}
item.conversation.asDomain(lastMessage).let { conversation ->
@@ -90,7 +90,13 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
).also { VkMemoryCache[message.id] = it }
}
}
@@ -159,7 +165,13 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message)
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.asDomain()?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
)
}
@@ -105,7 +105,7 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
actionConversationMessageId = action?.conversationMessageId,
actionMessage = action?.message,
geoType = geo?.type,
isImportant = important ?: false,
isImportant = important == true,
updateTime = updateTime,
forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain),
attachments = attachments.map(VkAttachmentItemData::toDomain),
@@ -35,7 +35,7 @@ data class VkMessage(
val user: VkUser?,
val group: VkGroupDomain?,
val actionUser: VkUser?,
val actionGroup: VkGroupDomain?
val actionGroup: VkGroupDomain?,
) {
fun isPeerChat() = peerId > 2_000_000_000
@@ -923,7 +923,9 @@ class MessagesHistoryViewModelImpl(
isImportant = false,
forwards = null,
attachments = null,
replyMessage = null,
replyMessage = when {
else -> null
},
geoType = null,
user = VkMemoryCache.getUser(UserConfig.userId),
group = null,
@@ -932,8 +934,6 @@ class MessagesHistoryViewModelImpl(
isPinned = false,
isSpam = false,
pinnedAt = null,
// TODO: 04-Apr-25, Danil Nikolaev: implement
formatData = formatData,
)
formatData = formatData.copy(items = emptyList())
@@ -29,7 +29,10 @@ sealed class UiItem(
val isSelected: Boolean,
val isPinned: Boolean,
val isImportant: Boolean,
val attachments: List<VkAttachment>?
val attachments: List<VkAttachment>?,
val replyCmId: Long?,
val replyTitle: String?,
val replySummary: String?
) : UiItem(id, cmId)
data class ActionMessage(
@@ -30,24 +30,26 @@ import coil.compose.rememberAsyncImagePainter
import coil.imageLoader
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable
fun IncomingMessageBubble(
enableAnimations: Boolean,
modifier: Modifier = Modifier,
message: UiItem.Message,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {}
) {
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
val currentOnReplyClick by rememberUpdatedState(onReplyClick)
Row(
modifier = modifier
.fillMaxWidth()
.then(
if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize()
if (enableAnimations) Modifier.animateContentSize()
else Modifier
),
) {
@@ -103,8 +105,11 @@ fun IncomingMessageBubble(
isImportant = message.isImportant,
isSelected = message.isSelected,
attachments = message.attachments?.toImmutableList(),
replyTitle = message.replyTitle,
replySummary = message.replySummary,
onClick = currentOnClick,
onLongClick = currentOnLongClick
onLongClick = currentOnLongClick,
onReplyClick = currentOnReplyClick
)
}
}
@@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
@@ -25,11 +27,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.SendingStatus
import dev.meloda.fast.messageshistory.presentation.attachments.Attachments
import dev.meloda.fast.messageshistory.presentation.attachments.Reply
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
@@ -48,9 +52,14 @@ fun MessageBubble(
isImportant: Boolean,
isSelected: Boolean,
attachments: ImmutableList<VkAttachment>?,
replyTitle: String?,
replySummary: String? = null,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {}
) {
val density = LocalDensity.current
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
@@ -60,6 +69,11 @@ fun MessageBubble(
} else {
MaterialTheme.colorScheme.primaryContainer
}
val replyBackgroundColor = if (!isOut) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.inversePrimary
}
val contentColor = if (!isOut) {
MaterialTheme.colorScheme.onSurface
@@ -101,8 +115,38 @@ fun MessageBubble(
}
}
var containerWidth by remember {
mutableIntStateOf(0)
}
CompositionLocalProvider(LocalContentColor provides contentColor) {
Column {
Column(
modifier = modifier
.wrapContentWidth()
.onGloballyPositioned {
containerWidth = it.size.width
}
) {
if (replyTitle != null) {
Reply(
modifier = Modifier
.padding(if (attachments == null || text != null) 0.dp else 4.dp)
.width(with(density) { containerWidth.toDp() }),
bottomPadding = if (attachments == null || text != null) 0.dp else 4.dp,
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (attachments == null || text != null) 0.dp else 16.dp,
bottomEnd = if (attachments == null || text != null) 0.dp else 16.dp
),
onClick = onReplyClick,
title = replyTitle,
summary = replySummary,
backgroundColor = backgroundColor,
innerBackgroundColor = replyBackgroundColor
)
}
if (shouldShowBubble) {
Box(
modifier = modifier
@@ -111,18 +155,19 @@ fun MessageBubble(
}
.widthIn(min = if (shouldFill) attachmentsContainerWidth.dp else 56.dp)
.clip(
if (attachments == null) RoundedCornerShape(24.dp)
else RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
RoundedCornerShape(
topStart = if (replyTitle == null) 24.dp else 0.dp,
topEnd = if (replyTitle == null) 24.dp else 0.dp,
bottomStart = if (attachments != null) 0.dp else 24.dp,
bottomEnd = if (attachments != null) 0.dp else 24.dp
)
)
.background(backgroundColor)
.padding(
horizontal = 8.dp,
vertical = 6.dp
start = 8.dp,
end = 8.dp,
top = if (replyTitle != null) 0.dp else 6.dp,
bottom = if (replyTitle != null) 4.dp else 6.dp
)
.then(if (theme.enableAnimations) Modifier.animateContentSize() else Modifier),
) {
@@ -223,6 +268,10 @@ private fun Bubble() {
isPinned = true,
isImportant = true,
isSelected = false,
attachments = emptyImmutableList()
attachments = emptyImmutableList(),
replyTitle = "Danil Nikolaev",
replySummary = "2 photos",
onClick = {},
onLongClick = {},
)
}
@@ -17,7 +17,8 @@ fun MessageTextContainer(
isOut: Boolean,
isSelected: Boolean,
) {
if (text != null) {
if (text == null) return
if (isSelected) {
SelectionContainer {
MessageText(
@@ -33,7 +34,6 @@ fun MessageTextContainer(
isOut = isOut,
)
}
}
}
@Composable
@@ -209,13 +209,18 @@ fun MessagesList(
)
else Modifier
),
enableAnimations = theme.enableAnimations,
message = item,
onClick = { attachment ->
onAttachmentClick(item, attachment)
},
onLongClick = { attachment ->
onAttachmentLongClick(item, attachment)
},
onReplyClick = {
if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId)
}
}
)
} else {
@@ -230,12 +235,18 @@ fun MessagesList(
)
else Modifier
),
enableAnimations = theme.enableAnimations,
message = item,
onClick = { attachment ->
onAttachmentClick(item, attachment)
},
onLongClick = { attachment ->
onAttachmentLongClick(item, attachment)
},
onReplyClick = {
if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId)
}
}
)
}
@@ -2,42 +2,48 @@ 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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable
fun OutgoingMessageBubble(
modifier: Modifier = Modifier,
enableAnimations: Boolean,
message: UiItem.Message,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {}
) {
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
val currentOnReplyClick by rememberUpdatedState(onReplyClick)
Row(
modifier = modifier
.fillMaxWidth()
.then(
if (LocalThemeConfig.current.enableAnimations) Modifier.animateContentSize()
if (enableAnimations) Modifier.animateContentSize()
else Modifier
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
Column(
Row(
modifier = Modifier
.padding(end = 16.dp)
.fillMaxWidth(0.85f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.End,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
MessageBubble(
modifier = Modifier,
@@ -51,8 +57,11 @@ fun OutgoingMessageBubble(
isImportant = message.isImportant,
isSelected = message.isSelected,
attachments = message.attachments?.toImmutableList(),
onClick = onClick,
onLongClick = onLongClick
replyTitle = message.replyTitle,
replySummary = message.replySummary,
onClick = currentOnClick,
onLongClick = currentOnLongClick,
onReplyClick = currentOnReplyClick
)
}
}
@@ -0,0 +1,128 @@
package dev.meloda.fast.messageshistory.presentation.attachments
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun Reply(
modifier: Modifier = Modifier,
bottomPadding: Dp,
shape: Shape,
onClick: () -> Unit,
backgroundColor: Color,
innerBackgroundColor: Color,
title: String,
summary: String?
) {
Box(
modifier = modifier
.background(
color = backgroundColor,
shape = shape
)
.height(40.dp)
.padding(
top = 4.dp,
start = 4.dp,
end = 4.dp,
bottom = bottomPadding
)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.fillMaxSize()
.background(innerBackgroundColor)
) {
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.onBackground)
)
Spacer(modifier = Modifier.width(6.dp))
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1
)
summary?.let {
Text(
text = summary,
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
}
}
}
}
@Composable
private fun ReplyBasePreview(
backgroundColor: Color,
innerBackgroundColor: Color
) {
Reply(
modifier = Modifier.width(120.dp),
shape = RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp
),
onClick = {},
title = "Danil Nikolaev",
summary = "2 photos",
backgroundColor = backgroundColor,
innerBackgroundColor = innerBackgroundColor,
bottomPadding = 0.dp
)
}
@Preview
@Composable
private fun IncomingReplyPreview() {
ReplyBasePreview(
backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
innerBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(20.dp)
)
}
@Preview
@Composable
private fun OutgoingReplyPreview() {
ReplyBasePreview(
backgroundColor = MaterialTheme.colorScheme.primaryContainer,
innerBackgroundColor = MaterialTheme.colorScheme.inversePrimary
)
}
@@ -57,6 +57,19 @@ fun VkMessage.extractTitle(): String = when {
else -> throw IllegalStateException("Message is not from user nor group. fromId: $fromId")
}
fun VkMessage.extractReplyTitle(): String? = replyMessage?.extractTitle()
// TODO: 24-Jun-25, Danil Nikolaev: improve
fun VkMessage.extractReplySummary(): String? = when (val message = replyMessage) {
null -> null
else -> {
when {
message.text != null -> message.text
else -> null
}
}
}
fun VkConversation.extractAvatar(): UiImage = when (peerType) {
PeerType.USER -> {
if (isAccount(id)) null
@@ -144,7 +157,10 @@ fun VkMessage.asPresentation(
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant,
attachments = attachments?.ifEmpty { null }
attachments = attachments?.ifEmpty { null },
replyCmId = replyMessage?.cmId,
replyTitle = extractReplyTitle(),
replySummary = extractReplySummary()
)
}