mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-25 18:32:17 +00:00
android, desktop: add chat message tail and roundness settings (#4958)
* android, desktop: add roundness setting to chat items * add tail setting * use shape for clip * wip tails * shape style * show tail only on last msg in group * roundings * padding for direct chats * groups padding * space between messages in settings preview * refactor group paddings * simplify * simplify * RcvDeleted handling * revert uncessary * import * always maintain tail position * rename * reactions should not move * short emoji shouldn't have tail * remove invisible tail for voice without text * better usage of gutters * simplify * rename * simplify reactions * linter happy * exclude moderated items from shape * uncessary diff * func position * fix chat view align on font resize (with image) * fix tails moving bubble on max width * fix big group names sometimes changing position * small refactor * fix top left corner end position * rename * sticky steps * revert whitespace changes --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
@@ -115,6 +115,9 @@ fun AppearanceScope.AppearanceLayout(
|
||||
SectionDividerSpaced()
|
||||
ThemesSection(systemDarkTheme)
|
||||
|
||||
SectionDividerSpaced()
|
||||
MessageShapeSection()
|
||||
|
||||
SectionDividerSpaced()
|
||||
ProfileImageSection()
|
||||
|
||||
|
||||
@@ -219,6 +219,8 @@ class AppPreferences {
|
||||
}, settingsThemes)
|
||||
val themeOverrides = mkThemeOverridesPreference()
|
||||
val profileImageCornerRadius = mkFloatPreference(SHARED_PREFS_PROFILE_IMAGE_CORNER_RADIUS, 22.5f)
|
||||
val chatItemRoundness = mkFloatPreference(SHARED_PREFS_CHAT_ITEM_ROUNDNESS, 0.75f)
|
||||
val chatItemTail = mkBoolPreference(SHARED_PREFS_CHAT_ITEM_TAIL, true)
|
||||
val fontScale = mkFloatPreference(SHARED_PREFS_FONT_SCALE, 1f)
|
||||
val densityScale = mkFloatPreference(SHARED_PREFS_DENSITY_SCALE, 1f)
|
||||
|
||||
@@ -422,6 +424,8 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_THEMES_OLD = "Themes"
|
||||
private const val SHARED_PREFS_THEME_OVERRIDES = "ThemeOverrides"
|
||||
private const val SHARED_PREFS_PROFILE_IMAGE_CORNER_RADIUS = "ProfileImageCornerRadius"
|
||||
private const val SHARED_PREFS_CHAT_ITEM_ROUNDNESS = "ChatItemRoundness"
|
||||
private const val SHARED_PREFS_CHAT_ITEM_TAIL = "ChatItemTail"
|
||||
private const val SHARED_PREFS_FONT_SCALE = "FontScale"
|
||||
private const val SHARED_PREFS_DENSITY_SCALE = "DensityScale"
|
||||
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
|
||||
@@ -6334,6 +6338,8 @@ data class AppSettings(
|
||||
var iosCallKitEnabled: Boolean? = null,
|
||||
var iosCallKitCallsInRecents: Boolean? = null,
|
||||
var uiProfileImageCornerRadius: Float? = null,
|
||||
var uiChatItemRoundness: Float? = null,
|
||||
var uiChatItemTail: Boolean? = null,
|
||||
var uiColorScheme: String? = null,
|
||||
var uiDarkColorScheme: String? = null,
|
||||
var uiCurrentThemeIds: Map<String, String>? = null,
|
||||
@@ -6366,6 +6372,8 @@ data class AppSettings(
|
||||
if (iosCallKitEnabled != def.iosCallKitEnabled) { empty.iosCallKitEnabled = iosCallKitEnabled }
|
||||
if (iosCallKitCallsInRecents != def.iosCallKitCallsInRecents) { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
|
||||
if (uiProfileImageCornerRadius != def.uiProfileImageCornerRadius) { empty.uiProfileImageCornerRadius = uiProfileImageCornerRadius }
|
||||
if (uiChatItemRoundness != def.uiChatItemRoundness) { empty.uiChatItemRoundness = uiChatItemRoundness }
|
||||
if (uiChatItemTail != def.uiChatItemTail) { empty.uiChatItemTail = uiChatItemTail }
|
||||
if (uiColorScheme != def.uiColorScheme) { empty.uiColorScheme = uiColorScheme }
|
||||
if (uiDarkColorScheme != def.uiDarkColorScheme) { empty.uiDarkColorScheme = uiDarkColorScheme }
|
||||
if (uiCurrentThemeIds != def.uiCurrentThemeIds) { empty.uiCurrentThemeIds = uiCurrentThemeIds }
|
||||
@@ -6409,6 +6417,8 @@ data class AppSettings(
|
||||
iosCallKitEnabled?.let { def.iosCallKitEnabled.set(it) }
|
||||
iosCallKitCallsInRecents?.let { def.iosCallKitCallsInRecents.set(it) }
|
||||
uiProfileImageCornerRadius?.let { def.profileImageCornerRadius.set(it) }
|
||||
uiChatItemRoundness?.let { def.chatItemRoundness.set(it) }
|
||||
uiChatItemTail?.let { def.chatItemTail.set(it) }
|
||||
uiColorScheme?.let { def.currentTheme.set(it) }
|
||||
uiDarkColorScheme?.let { def.systemDarkTheme.set(it) }
|
||||
uiCurrentThemeIds?.let { def.currentThemeIds.set(it) }
|
||||
@@ -6442,6 +6452,8 @@ data class AppSettings(
|
||||
iosCallKitEnabled = true,
|
||||
iosCallKitCallsInRecents = false,
|
||||
uiProfileImageCornerRadius = 22.5f,
|
||||
uiChatItemRoundness = 0.75f,
|
||||
uiChatItemTail = true,
|
||||
uiColorScheme = DefaultTheme.SYSTEM_THEME_NAME,
|
||||
uiDarkColorScheme = DefaultTheme.SIMPLEX.themeName,
|
||||
uiCurrentThemeIds = null,
|
||||
@@ -6476,6 +6488,8 @@ data class AppSettings(
|
||||
iosCallKitEnabled = def.iosCallKitEnabled.get(),
|
||||
iosCallKitCallsInRecents = def.iosCallKitCallsInRecents.get(),
|
||||
uiProfileImageCornerRadius = def.profileImageCornerRadius.get(),
|
||||
uiChatItemRoundness = def.chatItemRoundness.get(),
|
||||
uiChatItemTail = def.chatItemTail.get(),
|
||||
uiColorScheme = def.currentTheme.get() ?: DefaultTheme.SYSTEM_THEME_NAME,
|
||||
uiDarkColorScheme = def.systemDarkTheme.get() ?: DefaultTheme.SIMPLEX.themeName,
|
||||
uiCurrentThemeIds = def.currentThemeIds.get(),
|
||||
|
||||
@@ -24,11 +24,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.item.ItemAction
|
||||
import chat.simplex.common.views.chat.item.MarkdownText
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.group.MemberProfileImage
|
||||
import chat.simplex.common.views.chat.item.*
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
@@ -75,7 +74,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
|
||||
Column {
|
||||
Box(
|
||||
Modifier.clip(RoundedCornerShape(18.dp)).background(itemColor).padding(bottom = 3.dp)
|
||||
Modifier.clipChatItem().background(itemColor).padding(bottom = 3.dp)
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
|
||||
.onRightClick { showMenu.value = true }
|
||||
) {
|
||||
@@ -122,7 +121,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
|
||||
|
||||
Column {
|
||||
Box(
|
||||
Modifier.clip(RoundedCornerShape(18.dp)).background(quoteColor).padding(bottom = 3.dp)
|
||||
Modifier.clipChatItem().background(quoteColor).padding(bottom = 3.dp)
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
|
||||
.onRightClick { showMenu.value = true }
|
||||
) {
|
||||
|
||||
@@ -1036,7 +1036,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
tryOrShowError("${cItem.id}ChatItem", error = {
|
||||
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
|
||||
}) {
|
||||
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, showTimestamp = itemSeparation.timestamp)
|
||||
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,6 +1081,15 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp {
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
|
||||
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
|
||||
|
||||
return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp)
|
||||
}
|
||||
|
||||
Box {
|
||||
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null
|
||||
val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf
|
||||
@@ -1099,7 +1108,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
Column(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
|
||||
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.fillMaxWidth()
|
||||
.then(swipeableModifier),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
@@ -1111,7 +1120,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
Text(
|
||||
memberNames(member, prevMember, memCount),
|
||||
Modifier
|
||||
.padding(start = MEMBER_IMAGE_SIZE + DEFAULT_PADDING_HALF)
|
||||
.padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF)
|
||||
.weight(1f, false),
|
||||
fontSize = 13.5.sp,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
@@ -1119,9 +1128,13 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
maxLines = 1
|
||||
)
|
||||
if (memCount == 1 && member.memberRole > GroupMemberRole.Member) {
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true)
|
||||
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
|
||||
|
||||
Text(
|
||||
member.memberRole.text,
|
||||
Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF),
|
||||
Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp),
|
||||
fontSize = 13.5.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
@@ -1137,12 +1150,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier, cItem.id, selectedChatItems)
|
||||
}
|
||||
Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() },
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) {
|
||||
Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) {
|
||||
MemberImage(member)
|
||||
}
|
||||
Box(modifier = Modifier.padding(top = 2.dp)) {
|
||||
Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range, false)
|
||||
}
|
||||
}
|
||||
@@ -1164,7 +1176,8 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
|
||||
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(swipeableOrSelectionModifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
@@ -1178,7 +1191,8 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
|
||||
.padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp)
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(if (selectionVisible) Modifier else swipeableModifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
@@ -1190,11 +1204,14 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier.padding(
|
||||
start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp,
|
||||
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
|
||||
).then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier)
|
||||
start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp,
|
||||
end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false),
|
||||
)
|
||||
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
|
||||
.then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ package chat.simplex.common.views.chat.item
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.shape.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
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.geometry.*
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.*
|
||||
@@ -26,9 +27,16 @@ import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.*
|
||||
|
||||
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||
|
||||
private val msgRectMaxRadius = 18.dp
|
||||
private val msgBubbleMaxRadius = msgRectMaxRadius * 1.2f
|
||||
val msgTailWidthDp = 9.dp
|
||||
private val msgTailMinHeightDp = msgTailWidthDp * 1.254f // ~56deg
|
||||
private val msgTailMaxHeightDp = msgTailWidthDp * 1.732f // 60deg
|
||||
|
||||
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
|
||||
|
||||
fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||
@@ -74,6 +82,7 @@ fun ChatItemView(
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
itemSeparation: ItemSeparation,
|
||||
preview: Boolean = false,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -100,7 +109,7 @@ fun ChatItemView(
|
||||
|
||||
@Composable
|
||||
fun ChatItemReactions() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.chatItemOffset(cItem, itemSeparation.largeGap, inverted = true, revealed = true)) {
|
||||
cItem.reactions.forEach { r ->
|
||||
var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp))
|
||||
if (cInfo.featureEnabled(ChatFeature.Reactions) && (cItem.allowAddReaction || r.userReacted)) {
|
||||
@@ -127,13 +136,13 @@ fun ChatItemView(
|
||||
Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) {
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.clipChatItem(cItem, itemSeparation.largeGap, revealed.value)
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
|
||||
.onRightClick { showMenu.value = true },
|
||||
) {
|
||||
@Composable
|
||||
fun framedItemView() {
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, receiveFile, onLinkLongClick, scrollToItem)
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem)
|
||||
}
|
||||
|
||||
fun deleteMessageQuestionText(): String {
|
||||
@@ -795,6 +804,154 @@ fun ItemAction(text: String, color: Color = Color.Unspecified, onClick: () -> Un
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.chatItemOffset(cItem: ChatItem, tailVisible: Boolean, inverted: Boolean = false, revealed: Boolean): Modifier {
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
val style = shapeStyle(cItem, chatItemTail.value, tailVisible, revealed)
|
||||
|
||||
val offset = if (style is ShapeStyle.Bubble) {
|
||||
if (style.tailVisible) {
|
||||
if (cItem.chatDir.sent) msgTailWidthDp else -msgTailWidthDp
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
} else 0.dp
|
||||
|
||||
return this.offset(x = if (inverted) (-1f * offset) else offset)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = false, revealed: Boolean = false): Modifier {
|
||||
val chatItemRoundness = remember { appPreferences.chatItemRoundness.state }
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
val style = shapeStyle(chatItem, chatItemTail.value, tailVisible, revealed)
|
||||
val cornerRoundness = chatItemRoundness.value.coerceIn(0f, 1f)
|
||||
|
||||
val shape = when (style) {
|
||||
is ShapeStyle.Bubble -> chatItemShape(cornerRoundness, LocalDensity.current, style.tailVisible, chatItem?.chatDir?.sent == true)
|
||||
is ShapeStyle.RoundRect -> RoundedCornerShape(style.radius * cornerRoundness)
|
||||
}
|
||||
|
||||
return this.clip(shape)
|
||||
}
|
||||
|
||||
private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ ->
|
||||
val (msgTailWidth, msgBubbleMaxRadius) = with(density) { Pair(msgTailWidthDp.toPx(), msgBubbleMaxRadius.toPx()) }
|
||||
val width = if (sent && tailVisible) size.width - msgTailWidth else size.width
|
||||
val height = size.height
|
||||
val rxMax = min(msgBubbleMaxRadius, width / 2)
|
||||
val ryMax = min(msgBubbleMaxRadius, height / 2)
|
||||
val rx = roundness * rxMax
|
||||
val ry = roundness * ryMax
|
||||
val tailHeight = with(density) {
|
||||
min(
|
||||
msgTailMinHeightDp.toPx() + roundness * (msgTailMaxHeightDp.toPx() - msgTailMinHeightDp.toPx()),
|
||||
height / 2
|
||||
)
|
||||
}
|
||||
moveTo(rx, 0f)
|
||||
lineTo(width - rx, 0f) // Top Line
|
||||
if (roundness > 0) {
|
||||
quadraticBezierTo(width, 0f, width, ry) // Top-right corner
|
||||
}
|
||||
if (height > 2 * ry) {
|
||||
lineTo(width, height - ry) // Right side
|
||||
}
|
||||
if (roundness > 0) {
|
||||
quadraticBezierTo(width, height, width - rx, height) // Bottom-right corner
|
||||
}
|
||||
if (tailVisible) {
|
||||
lineTo(0f, height) // Bottom line
|
||||
if (roundness > 0) {
|
||||
val d = tailHeight - msgTailWidth * msgTailWidth / tailHeight
|
||||
val controlPoint = Offset(msgTailWidth, height - tailHeight + d * sqrt(roundness))
|
||||
quadraticBezierTo(controlPoint.x, controlPoint.y, msgTailWidth, height - tailHeight)
|
||||
} else {
|
||||
lineTo(msgTailWidth, height - tailHeight)
|
||||
}
|
||||
|
||||
if (height > ry + tailHeight) {
|
||||
lineTo(msgTailWidth, ry)
|
||||
}
|
||||
} else {
|
||||
lineTo(rx, height) // Bottom line
|
||||
if (roundness > 0) {
|
||||
quadraticBezierTo(0f, height, 0f, height - ry) // Bottom-left corner
|
||||
}
|
||||
if (height > 2 * ry) {
|
||||
lineTo(0f, ry) // Left side
|
||||
}
|
||||
}
|
||||
if (roundness > 0) {
|
||||
val bubbleInitialX = if (tailVisible) msgTailWidth else 0f
|
||||
quadraticBezierTo(bubbleInitialX, 0f, bubbleInitialX + rx, 0f) // Top-left corner
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
val matrix = Matrix()
|
||||
matrix.scale(-1f, 1f)
|
||||
this.transform(matrix)
|
||||
this.translate(Offset(size.width, 0f))
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ShapeStyle {
|
||||
data class Bubble(val tailVisible: Boolean, val startPadding: Boolean) : ShapeStyle()
|
||||
data class RoundRect(val radius: Dp) : ShapeStyle()
|
||||
}
|
||||
|
||||
fun shapeStyle(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVisible: Boolean, revealed: Boolean): ShapeStyle {
|
||||
if (chatItem == null) {
|
||||
return ShapeStyle.RoundRect(msgRectMaxRadius)
|
||||
}
|
||||
|
||||
when (chatItem.content) {
|
||||
is CIContent.SndMsgContent,
|
||||
is CIContent.RcvMsgContent,
|
||||
is CIContent.RcvDecryptionError,
|
||||
is CIContent.SndDeleted,
|
||||
is CIContent.RcvDeleted,
|
||||
is CIContent.RcvIntegrityError,
|
||||
is CIContent.SndModerated,
|
||||
is CIContent.RcvModerated,
|
||||
is CIContent.RcvBlocked,
|
||||
is CIContent.InvalidJSON -> {
|
||||
if (chatItem.meta.itemDeleted != null && (!revealed || chatItem.isDeletedContent)) {
|
||||
return ShapeStyle.RoundRect(msgRectMaxRadius)
|
||||
}
|
||||
|
||||
val tail = when (val content = chatItem.content.msgContent) {
|
||||
is MsgContent.MCImage,
|
||||
is MsgContent.MCVideo,
|
||||
is MsgContent.MCVoice -> {
|
||||
if (content.text.isEmpty()) {
|
||||
false
|
||||
} else {
|
||||
tailVisible
|
||||
}
|
||||
}
|
||||
is MsgContent.MCText -> {
|
||||
if (isShortEmoji(content.text)) {
|
||||
false
|
||||
} else {
|
||||
tailVisible
|
||||
}
|
||||
}
|
||||
else -> tailVisible
|
||||
}
|
||||
return if (tailEnabled) {
|
||||
ShapeStyle.Bubble(tail, !chatItem.chatDir.sent)
|
||||
} else {
|
||||
ShapeStyle.RoundRect(msgRectMaxRadius)
|
||||
}
|
||||
}
|
||||
|
||||
is CIContent.RcvGroupInvitation,
|
||||
is CIContent.SndGroupInvitation -> return ShapeStyle.RoundRect(msgRectMaxRadius)
|
||||
else -> return ShapeStyle.RoundRect(8.dp)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(cancelAction.alert.titleId),
|
||||
@@ -931,6 +1088,7 @@ fun PreviewChatItemView(
|
||||
showViaProxy = false,
|
||||
showTimestamp = true,
|
||||
preview = true,
|
||||
itemSeparation = ItemSeparation(timestamp = true, largeGap = true, null)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -970,7 +1128,8 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
developerTools = false,
|
||||
showViaProxy = false,
|
||||
preview = true,
|
||||
showTimestamp = true
|
||||
showTimestamp = true,
|
||||
itemSeparation = ItemSeparation(timestamp = true, largeGap = true, null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
@@ -36,6 +34,7 @@ fun FramedItemView(
|
||||
showViaProxy: Boolean,
|
||||
showMenu: MutableState<Boolean>,
|
||||
showTimestamp: Boolean,
|
||||
tailVisible: Boolean = false,
|
||||
receiveFile: (Long) -> Unit,
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
scrollToItem: (Long) -> Unit = {},
|
||||
@@ -190,7 +189,7 @@ fun FramedItemView(
|
||||
val sentColor = MaterialTheme.appColors.sentMessage
|
||||
val receivedColor = MaterialTheme.appColors.receivedMessage
|
||||
Box(Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.clipChatItem(ci, tailVisible, revealed = true)
|
||||
.background(
|
||||
when {
|
||||
transparentBackground -> Color.Transparent
|
||||
@@ -200,7 +199,14 @@ fun FramedItemView(
|
||||
)) {
|
||||
var metaColor = MaterialTheme.colors.secondary
|
||||
Box(contentAlignment = Alignment.BottomEnd) {
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
val style = shapeStyle(ci, chatItemTail.value, tailVisible, revealed = true)
|
||||
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
|
||||
Column(
|
||||
Modifier
|
||||
.width(IntrinsicSize.Max)
|
||||
.padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp)
|
||||
) {
|
||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||
if (ci.meta.itemDeleted != null) {
|
||||
when (ci.meta.itemDeleted) {
|
||||
@@ -279,7 +285,13 @@ fun FramedItemView(
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(
|
||||
bottom = 6.dp,
|
||||
end = 12.dp + if (tailRendered && sent) msgTailWidthDp else 0.dp,
|
||||
)
|
||||
) {
|
||||
CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex
|
||||
import chat.simplex.common.ui.theme.ThemeManager.toReadableHex
|
||||
import chat.simplex.common.views.chat.item.PreviewChatItemView
|
||||
import chat.simplex.common.views.chat.item.msgTailWidthDp
|
||||
import chat.simplex.res.MR
|
||||
import com.godaddy.android.colorpicker.ClassicColorPicker
|
||||
import com.godaddy.android.colorpicker.HsvColor
|
||||
@@ -84,6 +85,31 @@ object AppearanceScope {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageShapeSection() {
|
||||
SectionView(stringResource(MR.strings.settings_section_title_message_shape).uppercase(), contentPadding = PaddingValues()) {
|
||||
Row(modifier = Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING + 4.dp ) ,verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(MR.strings.settings_message_shape_corner), color = colors.onBackground)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Slider(
|
||||
remember { appPreferences.chatItemRoundness.state }.value,
|
||||
valueRange = 0f..1f,
|
||||
steps = 20,
|
||||
onValueChange = {
|
||||
val diff = it % 0.05f
|
||||
appPreferences.chatItemRoundness.set(it + (if (diff >= 0.025f) -diff + 0.05f else -diff))
|
||||
saveThemeToDatabase(null)
|
||||
},
|
||||
colors = SliderDefaults.colors(
|
||||
activeTickColor = Color.Transparent,
|
||||
inactiveTickColor = Color.Transparent,
|
||||
)
|
||||
)
|
||||
}
|
||||
SettingsPreferenceItem(icon = null, stringResource(MR.strings.settings_message_shape_tail), appPreferences.chatItemTail)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FontScaleSection() {
|
||||
val localFontScale = remember { mutableStateOf(appPrefs.fontScale.get()) }
|
||||
@@ -169,13 +195,17 @@ object AppearanceScope {
|
||||
.padding(DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
if (withMessages) {
|
||||
val alice = remember { ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), generalGetString(MR.strings.wallpaper_preview_hello_bob)) }
|
||||
PreviewChatItemView(alice)
|
||||
PreviewChatItemView(
|
||||
ChatItem.getSampleData(2, CIDirection.DirectSnd(), Clock.System.now(), stringResource(MR.strings.wallpaper_preview_hello_alice),
|
||||
quotedItem = CIQuote(alice.chatDir, alice.id, sentAt = alice.meta.itemTs, formattedText = alice.formattedText, content = MsgContent.MCText(alice.content.text))
|
||||
)
|
||||
)
|
||||
val chatItemTail = remember { appPreferences.chatItemTail.state }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = if (chatItemTail.value) Modifier else Modifier.padding(horizontal = msgTailWidthDp)) {
|
||||
val alice = remember { ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), generalGetString(MR.strings.wallpaper_preview_hello_bob)) }
|
||||
PreviewChatItemView(alice)
|
||||
PreviewChatItemView(
|
||||
ChatItem.getSampleData(2, CIDirection.DirectSnd(), Clock.System.now(), stringResource(MR.strings.wallpaper_preview_hello_alice),
|
||||
quotedItem = CIQuote(alice.chatDir, alice.id, sentAt = alice.meta.itemTs, formattedText = alice.formattedText, content = MsgContent.MCText(alice.content.text))
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
@@ -1196,6 +1196,9 @@
|
||||
<string name="settings_section_title_icon">APP ICON</string>
|
||||
<string name="settings_section_title_themes">THEMES</string>
|
||||
<string name="settings_section_title_profile_images">Profile images</string>
|
||||
<string name="settings_section_title_message_shape">Message shape</string>
|
||||
<string name="settings_message_shape_corner">Corner</string>
|
||||
<string name="settings_message_shape_tail">Tail</string>
|
||||
<string name="settings_section_title_chat_theme">Chat theme</string>
|
||||
<string name="settings_section_title_user_theme">Profile theme</string>
|
||||
<string name="settings_section_title_chat_colors">Chat colors</string>
|
||||
|
||||
@@ -62,6 +62,9 @@ fun AppearanceScope.AppearanceLayout(
|
||||
SectionDividerSpaced()
|
||||
ThemesSection(systemDarkTheme)
|
||||
|
||||
SectionDividerSpaced()
|
||||
MessageShapeSection()
|
||||
|
||||
SectionDividerSpaced()
|
||||
ProfileImageSection()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user