From 326c42e3d67df7d453cde36480de30c60cf5e2cd Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:53:22 +0000 Subject: [PATCH] android, desktop: fix several RTL layout issues Fixes https://github.com/simplex-chat/simplex-chat/issues/5448 The original report documents four user-visible regressions under an RTL locale (Arabic, Persian, Hebrew): chat bubble tails on the wrong side, chat header call/menu buttons not mirrored, settings back arrow pointing the wrong way, and the chat list profile avatar / signal icon laid out in their LTR positions. This commit addresses all of them and a handful of related directional-icon mirroring sites discovered during the audit. - Chat bubble tail direction: chatItemShape now reads layoutDirection from the GenericShape lambda and conditions the matrix mirror on sent != isRtl, so sent-bubble tails point toward the user's profile and received-bubble tails toward the contact's profile under both LTR and RTL. The bubble-tail paddings in FramedItemView and ChatView already use direction-aware start/end and now align with the mirrored shape automatically. - App-bar slot mirroring: CenteredRowLayout in FramedItemView switches from place(...) to placeRelative(...) so the navigation icon and the action buttons swap visual sides under RTL. Both the chat header (call/menu buttons) and the chat list header (profile avatar, signal icon) flow through this layout, so one fix covers both. - Asymmetric directional drawables: add Modifier.mirrorIfRtl() (Modifier.scale(-1f, 1f) when LocalLayoutDirection.current is Rtl) and apply it to the back arrow in NavigationButtonBack, every variant of the SubscriptionStatusIcon waves, the WhatsNewView pagination arrows, the back arrow and submenu chevron in CommandsMenuView, the back arrow in OnboardingCards and ImageFullScreenView.desktop, the share-profile chevron in NewChatView, and the sent-via-proxy arrow in CIMetaView and ChatItemInfoView. DropdownMenu offsets in ChatView (DpOffset(-width, ...)) are left unchanged: DropdownMenuPositionProvider already negates the content offset under RTL, so menus anchor and open correctly once CenteredRowLayout is fixed. --- .../chat/simplex/common/views/chat/ChatItemInfoView.kt | 1 + .../chat/simplex/common/views/chat/CommandsMenuView.kt | 3 +++ .../chat/simplex/common/views/chat/item/CIMetaView.kt | 3 ++- .../simplex/common/views/chat/item/ChatItemView.kt | 7 +++++-- .../simplex/common/views/chat/item/FramedItemView.kt | 8 +++++--- .../simplex/common/views/helpers/DefaultTopAppBar.kt | 2 +- .../chat/simplex/common/views/helpers/Modifiers.kt | 7 +++++++ .../common/views/helpers/SubscriptionStatusIcon.kt | 10 +++++----- .../chat/simplex/common/views/newchat/NewChatView.kt | 1 + .../simplex/common/views/newchat/OnboardingCards.kt | 2 +- .../simplex/common/views/onboarding/WhatsNewView.kt | 4 ++-- .../views/chat/item/ImageFullScreenView.desktop.kt | 3 ++- 12 files changed, 35 insertions(+), 16 deletions(-) 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 9c36f4896b..59062e4866 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 @@ -359,6 +359,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools Icon( painterResource(MR.images.ic_arrow_forward), contentDescription = null, + modifier = Modifier.mirrorIfRtl(), tint = CurrentColors.value.colors.secondary ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt index 26b1fce741..3613b44d60 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt @@ -25,6 +25,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.chat.group.* import chat.simplex.common.views.chat.item.sendCommandMsg import chat.simplex.common.views.helpers.commandMenuAnimSpec +import chat.simplex.common.views.helpers.mirrorIfRtl import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch @@ -126,6 +127,7 @@ fun CommandsMenuView( Icon( painterResource(MR.images.ic_arrow_back_ios_new), contentDescription = null, + modifier = Modifier.mirrorIfRtl(), tint = MaterialTheme.colors.secondary ) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) @@ -205,6 +207,7 @@ fun CommandsMenuView( Icon( painterResource(MR.images.ic_chevron_right), contentDescription = null, + modifier = Modifier.mirrorIfRtl(), tint = MaterialTheme.colors.secondary ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index 4ec2a885e7..b7ecfc17c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.isInDarkTheme +import chat.simplex.common.views.helpers.mirrorIfRtl import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -87,7 +88,7 @@ private fun CIMetaText( } if (showViaProxy && meta.sentViaProxy == true) { Spacer(Modifier.width(4.dp)) - Icon(painterResource(MR.images.ic_arrow_forward), null, Modifier.height(17.dp), tint = MaterialTheme.colors.secondary) + Icon(painterResource(MR.images.ic_arrow_forward), null, Modifier.height(17.dp).mirrorIfRtl(), tint = MaterialTheme.colors.secondary) } if (showStatus) { Spacer(Modifier.width(4.dp)) 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 15b0a12822..33337821e6 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 @@ -1231,7 +1231,7 @@ fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = fal return this.clip(shape) } -private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ -> +private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, layoutDirection -> val (msgTailWidth, msgBubbleMaxRadius) = with(density) { Pair(msgTailWidthDp.toPx(), msgBubbleMaxRadius.toPx()) } val width = size.width val height = size.height @@ -1283,7 +1283,10 @@ private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boole quadraticBezierTo(bubbleInitialX, 0f, bubbleInitialX + rx, 0f) // Top-left corner } - if (sent) { + // The default path draws the tail at the bottom-left corner. We mirror the path so the tail + // ends up on the side closer to the message author's profile in chat: right for sent in LTR, + // left for received in LTR — and the opposite under RTL where chat sides are mirrored. + if (sent != (layoutDirection == LayoutDirection.Rtl)) { val matrix = Matrix() matrix.scale(-1f, 1f) this.transform(matrix) 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 f2788715fe..181acaba8a 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 @@ -592,9 +592,11 @@ fun CenteredRowLayout( val second = measureable[1].measure(constraints.copy(minWidth = 0, minHeight = 0, maxWidth = (constraints.maxWidth - first.measuredWidth - third.measuredWidth).coerceAtLeast(0))) // Limit width for every other element to width of important element and height for a sum of all elements. layout(constraints.maxWidth, constraints.maxHeight) { - first.place(0, ((constraints.maxHeight - first.measuredHeight) / 2).coerceAtLeast(0)) - second.place((constraints.maxWidth - second.measuredWidth) / 2, ((constraints.maxHeight - second.measuredHeight) / 2).coerceAtLeast(0)) - third.place(constraints.maxWidth - third.measuredWidth, ((constraints.maxHeight - third.measuredHeight) / 2).coerceAtLeast(0)) + // placeRelative mirrors x under RTL so the leading slot (first) and trailing slot (third) + // swap visual sides automatically when the locale flips. + first.placeRelative(0, ((constraints.maxHeight - first.measuredHeight) / 2).coerceAtLeast(0)) + second.placeRelative((constraints.maxWidth - second.measuredWidth) / 2, ((constraints.maxHeight - second.measuredHeight) / 2).coerceAtLeast(0)) + third.placeRelative(constraints.maxWidth - third.measuredWidth, ((constraints.maxHeight - third.measuredHeight) / 2).coerceAtLeast(0)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 81fac40a40..30f892d919 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -128,7 +128,7 @@ fun CallAppBar( fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, height: Dp = 24.dp) { IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) { Icon( - painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), Modifier.height(height), tint = tintColor + painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), Modifier.height(height).mirrorIfRtl(), tint = tintColor ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt index 8ad877d879..777f890b37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt @@ -5,12 +5,19 @@ import androidx.compose.foundation.layout.offset import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import kotlin.math.roundToInt +// Horizontally flips an asymmetric directional drawable (back/forward arrows, chevrons, signal +// waves) when the layout direction is RTL. Apply to the Icon's modifier at each call site. +@Composable +fun Modifier.mirrorIfRtl(): Modifier = + if (LocalLayoutDirection.current == LayoutDirection.Rtl) this.scale(scaleX = -1f, scaleY = 1f) else this + fun Modifier.badgeLayout() = layout { measurable, constraints -> val placeable = measurable.measure(constraints) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt index e0e61b598e..f14957ddf8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt @@ -16,26 +16,26 @@ fun SubscriptionStatusIcon( ) { @Composable fun ZeroIcon() { - Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color.copy(alpha = 0.33f), modifier = modifier) + Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color.copy(alpha = 0.33f), modifier = modifier.mirrorIfRtl()) } when { variableValue <= 0f -> ZeroIcon() variableValue > 0f && variableValue <= 0.25f -> Box { ZeroIcon() - Icon(painterResource(MR.images.ic_radiowaves_up_forward_1_bar), null, tint = color, modifier = modifier) + Icon(painterResource(MR.images.ic_radiowaves_up_forward_1_bar), null, tint = color, modifier = modifier.mirrorIfRtl()) } variableValue > 0.25f && variableValue <= 0.5f -> Box { ZeroIcon() - Icon(painterResource(MR.images.ic_radiowaves_up_forward_2_bar), null, tint = color, modifier = modifier) + Icon(painterResource(MR.images.ic_radiowaves_up_forward_2_bar), null, tint = color, modifier = modifier.mirrorIfRtl()) } variableValue > 0.5f && variableValue <= 0.75f -> Box { ZeroIcon() - Icon(painterResource(MR.images.ic_radiowaves_up_forward_3_bar), null, tint = color, modifier = modifier) + Icon(painterResource(MR.images.ic_radiowaves_up_forward_3_bar), null, tint = color, modifier = modifier.mirrorIfRtl()) } - else -> Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color, modifier = modifier) + else -> Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color, modifier = modifier.mirrorIfRtl()) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 72311cd7fe..b69268293b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -568,6 +568,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact painter = painterResource(MR.images.ic_arrow_forward_ios), contentDescription = stringResource(MR.strings.new_chat_share_profile), tint = MaterialTheme.colors.secondary, + modifier = Modifier.mirrorIfRtl(), ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index 26007c74af..7bf4715b4c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -263,7 +263,7 @@ private fun BackButton(modifier: Modifier = Modifier, color: Color = MaterialThe painterResource(MR.images.ic_arrow_back_ios_new), contentDescription = stringResource(MR.strings.back), tint = color, - modifier = Modifier.height(24.dp) + modifier = Modifier.height(24.dp).mirrorIfRtl() ) Text(stringResource(MR.strings.back), color = color) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index e1415d071d..4a5056338f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -112,7 +112,7 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool .clickable { currentVersion.value = prev } .padding(8.dp) ) { - Icon(painterResource(MR.images.ic_arrow_back_ios_new), "previous", tint = MaterialTheme.colors.primary) + Icon(painterResource(MR.images.ic_arrow_back_ios_new), "previous", Modifier.mirrorIfRtl(), tint = MaterialTheme.colors.primary) Text(versionDescriptions[prev].version, color = MaterialTheme.colors.primary) } } @@ -129,7 +129,7 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool .padding(8.dp) ) { Text(versionDescriptions[next].version, color = MaterialTheme.colors.primary) - Icon(painterResource(MR.images.ic_arrow_forward_ios), "next", tint = MaterialTheme.colors.primary) + Icon(painterResource(MR.images.ic_arrow_forward_ios), "next", Modifier.mirrorIfRtl(), tint = MaterialTheme.colors.primary) } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index bd395c2c97..0f4472bb4a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.getBitmapFromByteArray +import chat.simplex.common.views.helpers.mirrorIfRtl import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -33,7 +34,7 @@ actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: ( Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) { SurfaceFromPlayer(player, modifier) IconButton(onClick = close, Modifier.padding(top = 5.dp)) { - Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary) + Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp).mirrorIfRtl(), tint = MaterialTheme.colors.primary) } } Controls(player)