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.
This commit is contained in:
Narasimha-sc committed 2026-10-18 02:01:27 +00:00
1 parent 139ce5f8cc
commit f977ad22a3
12 files changed
+35 -16

No files matched your search

@@ -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
)
}
@@ -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
)
}
@@ -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))
@@ -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)
@@ -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))
}
}
}
@@ -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
)
}
}
@@ -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)
@@ -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())
}
}
@@ -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(),
)
}
}
@@ -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)
}
@@ -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)
}
}
}
@@ -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)