diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt index 6a76be0fe4..418174a8e9 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt @@ -115,6 +115,9 @@ fun AppearanceScope.AppearanceLayout( SectionDividerSpaced() ThemesSection(systemDarkTheme) + SectionDividerSpaced() + MessageShapeSection() + SectionDividerSpaced() ProfileImageSection() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 0297536577..2e98d0bc89 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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? = 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(), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index c403fe512b..bdbfdb89c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -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 } ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 2ba4316b0f..d89782148a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -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) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 6e59e38d02..3db9b55c5b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -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) ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 1542012136..f346402957 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -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, 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) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index bef837ba94..f2cd26803b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -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()) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 429ca4af09..6ab9a268bd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1196,6 +1196,9 @@ APP ICON THEMES Profile images + Message shape + Corner + Tail Chat theme Profile theme Chat colors diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt index 36c7d180b5..244504f4c7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt @@ -62,6 +62,9 @@ fun AppearanceScope.AppearanceLayout( SectionDividerSpaced() ThemesSection(systemDarkTheme) + SectionDividerSpaced() + MessageShapeSection() + SectionDividerSpaced() ProfileImageSection()