From f977ad22a3af3beaf9d6c93bf8a925e2041d8ec8 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 laid out in its LTR position. 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 are on the outer (end) edge and received-bubble tails point 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) 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, and the menus hang from their own anchor boxes. --- .../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 e4f93a0b8a..894683f3f2 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 @@ -393,6 +393,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 5f7aec3dd6..4b56c0c090 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.* 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 e318f406de..837a23e2b6 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 @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.appPreferences import chat.simplex.common.ui.theme.isInDarkTheme +import chat.simplex.common.views.helpers.mirrorIfRtl import chat.simplex.res.* import kotlinx.datetime.Clock @@ -92,7 +93,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 822aef9aa2..a70e121654 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 @@ -1247,7 +1247,7 @@ fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = fal } } -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 @@ -1299,7 +1299,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 fd422b649a..663d422605 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 @@ -645,9 +645,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 1c2ef93a09..7a10ab0037 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 7f8b902b94..243d7b4dcc 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 29819f21cb..d4cfeab2c7 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 @@ -570,6 +570,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 cf708c72d8..b690400e7f 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 @@ -280,7 +280,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 81ea810acb..ae0ca52d3c 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 @@ -141,7 +141,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) } } @@ -158,7 +158,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 c1d4a40dae..6d7ed79184 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.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -35,7 +36,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)