diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt index 2a7f1034df..74e585e751 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt @@ -92,7 +92,7 @@ fun SupportSimpleXBanner( val heroPlaceable = measurables[1].measure(constraints.copy(minWidth = 0, minHeight = 0)) layout(cardPlaceable.width, cardPlaceable.height) { cardPlaceable.place(0, 0) - heroPlaceable.place(cardPlaceable.width - heroPlaceable.width, cardPlaceable.height - heroPlaceable.height) + heroPlaceable.placeRelative(cardPlaceable.width - heroPlaceable.width, cardPlaceable.height - heroPlaceable.height) } } } 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 894683f3f2..418af39702 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 @@ -413,6 +413,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools Icon( painterResource(icon), contentDescription = null, + modifier = if (icon == MR.images.ic_chevron_right_2) Modifier.mirrorIfRtl() else Modifier, tint = statusColor ) } 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 27fb0687cc..63f393a0b1 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 @@ -548,7 +548,7 @@ fun PriorityLayout( ) { var y = 0 placeables.forEach { - it.place(0, y) + it.placeRelative(0, y) y += it.measuredHeight } } @@ -582,7 +582,7 @@ fun DependentLayout( ) { var y = 0 placeables.forEach { - it.place(0, y) + it.placeRelative(0, y) y += it.measuredHeight } } 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 541644c558..435efea744 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 @@ -4,8 +4,12 @@ import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.offset import androidx.compose.material.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntOffset @@ -16,6 +20,20 @@ import kotlin.math.roundToInt fun Modifier.mirrorIfRtl(): Modifier = if (LocalLayoutDirection.current == LayoutDirection.Rtl) this.scale(scaleX = -1f, scaleY = 1f) else this +@Composable +fun Painter.mirrorIfRtl(): Painter = + if (LocalLayoutDirection.current == LayoutDirection.Rtl) remember(this) { MirroredPainter(this) } else this + +private class MirroredPainter(private val painter: Painter): Painter() { + override val intrinsicSize get() = painter.intrinsicSize + + override fun DrawScope.onDraw() { + scale(scaleX = -1f, scaleY = 1f) { + with(painter) { draw(size) } + } + } +} + fun Modifier.badgeLayout() = layout { measurable, constraints -> val placeable = measurable.measure(constraints) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index 003300a033..3ccee311ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -104,7 +104,7 @@ private fun EnableReceiptsButton(onClick: () -> Unit) { @Composable private fun SkipButton(onClick: () -> Unit) { - SimpleButtonIconEnded(stringResource(MR.strings.dont_enable_receipts), painterResource(MR.images.ic_chevron_right), click = onClick) + SimpleButtonIconEnded(stringResource(MR.strings.dont_enable_receipts), painterResource(MR.images.ic_chevron_right).mirrorIfRtl(), click = onClick) TextBelowButton(stringResource(MR.strings.you_can_enable_delivery_receipts_later)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 3a6fc0fb5c..25ce49a0d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -233,7 +233,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - SectionView(generalGetString(MR.strings.settings_section_title_private_message_routing)) { SMPProxyModePicker(smpProxyMode, showModal, updateSMPProxyMode) SMPProxyFallbackPicker(smpProxyFallback, showModal, updateSMPProxyFallback, enabled = remember { derivedStateOf { smpProxyMode.value != SMPProxyMode.Never } }) - SettingsPreferenceItem(painterResource(MR.images.ic_arrow_forward), stringResource(MR.strings.private_routing_show_message_status), chatModel.controller.appPrefs.showSentViaProxy) + SettingsPreferenceItem(painterResource(MR.images.ic_arrow_forward).mirrorIfRtl(), stringResource(MR.strings.private_routing_show_message_status), chatModel.controller.appPrefs.showSentViaProxy) } SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) SectionDividerSpaced() diff --git a/plans/2026-05-13-rtl-layout-issues.md b/plans/2026-05-13-rtl-layout-issues.md index 9410351353..9a80d888d7 100644 --- a/plans/2026-05-13-rtl-layout-issues.md +++ b/plans/2026-05-13-rtl-layout-issues.md @@ -7,34 +7,41 @@ Design doc for the fix shipped in PR #6908 (Fixes #5448). Under an RTL locale (Arabic, Persian, Hebrew) on Android and desktop: - Chat bubble tail points away from the message author's profile, with - the tail overflowing onto the avatar. + the bubble's avatar-side edge overlapping the avatar. - Chat header places the call + 3-dot menu on the visual right and the back chevron on the visual left, opposite of platform convention; the - 3-dot dropdown opens off-screen. -- Chat list header has the profile avatar on the visual left and the - signal/wifi icon + "Chats" title on the right. + 3-dot dropdown opens at the visual left, away from the 3-dot button. +- Chat list header has the profile avatar on the visual left, and the + signal icon's waves keep their LTR orientation. - Asymmetric directional drawables — back/forward arrows, chevrons, signal-strength waves, sent-via-proxy arrow — keep their LTR orientation everywhere they appear. -iOS is unaffected — UIKit's RTL support and SwiftUI's semantic edges -already handle these cases. +iOS is not changed. Its bubble shape has the same defect +(`ChatItemClipShape.swift`), but the iOS app ships no Arabic, Hebrew or +Persian localisation, so it never runs right-to-left. -## Background — three independent mechanisms +## Background — four independent mechanisms -The user-visible regressions all come from one of three roots, each -fixed at the root rather than per call site: +The user-visible regressions all come from one of four roots. The +layouts and the bubble shape are fixed once where they are defined; the +directional drawables are fixed at each call site: 1. **Bubble shape mirroring** — `chatItemShape` in `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt`. 2. **App-bar slot placement** — `CenteredRowLayout` in `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt`, - reached by every `DefaultAppBar` caller via `AppBarCenterAligned` - (≈ 11 sites: `ChatView`, `ChatListView`, `NewChatSheet` ×2, + reached by every centred `DefaultAppBar` (not in search mode, and + with a title or at the bottom in one-hand mode) via `AppBarCenterAligned` + (10 sites: `ChatView`, `ChatListView`, `NewChatSheet` ×2, `MemberSupportChatView` ×2, `GroupReportsView`, `ModalView`, `ShareListView`, `SelectableChatItemToolbars`). 3. **Asymmetric directional drawables** — applied at each Icon call site whose vector resource is intrinsically directional. +4. **Other custom layouts placing children at absolute x** — a short + text under an image, file or voice message (`PriorityLayout`), the + group member name row above a message (`DependentLayout`) and the + supporter banner (`SupportSimpleXBanner`). ## Fix @@ -68,7 +75,7 @@ automatically. (leading / center / trailing). It placed slots with absolute `place(x, y)`, which doesn't react to layout direction. Switch all three calls to `placeRelative(x, y)` — the standard Compose primitive that mirrors the -x coordinate under RTL. +child's position under RTL (it is placed at `parentWidth - width - x`). ```kotlin first.placeRelative(0, ...) @@ -76,13 +83,15 @@ second.placeRelative((maxWidth - second.width) / 2, ...) third.placeRelative(maxWidth - third.width, ...) ``` -One source of truth, ≈ 11 call sites covered. `placeRelative` is +One source of truth, 10 call sites covered. `placeRelative` is identical to `place` under LTR — bit-exact LTR preservation. `DropdownMenu` `DpOffset(-width.value, ...)` calls in `ChatView.kt` -are intentionally untouched: `DropdownMenuPositionProvider` already -negates the content offset under RTL, so once `CenteredRowLayout` is -fixed the chat-header menus anchor correctly without a call-site change. +are intentionally untouched: the menu hangs from its own anchor box, +which already follows the layout direction, and +`DropdownMenuPositionProvider` negates the offset under RTL. On master +the menu already opened at the visual left; the fix moves the 3-dot +button there. ### 3. Asymmetric directional drawables @@ -98,9 +107,8 @@ fun Modifier.mirrorIfRtl(): Modifier = ``` `Modifier.scale` is a draw-time transform: it does not change measured -size or hit-test bounds. For the icons in question (square or -near-square) it is visually identical to a mirrored asset and click -areas remain correct. +size or hit-test bounds. It flips about the centre, so it is visually +identical to a mirrored asset and click areas remain correct. Apply at each call site whose drawable is intrinsically directional: @@ -110,7 +118,7 @@ Apply at each call site whose drawable is intrinsically directional: - forward chevron / arrow (`NewChatView`'s share-profile chevron, `WhatsNewView`'s "next" pager, `CommandsMenuView`'s submenu chevron, `GoToItemInnerButton`'s forward variant) -- subscription-status waves (5 variants in `SubscriptionStatusIcon`) +- subscription-status waves (4 drawables at 5 call sites in `SubscriptionStatusIcon`) - sent-via-proxy arrow (`CIMetaView`, `ChatItemInfoView`) Two structural details for site-level callers: @@ -120,7 +128,7 @@ Two structural details for site-level callers: (`val iconModifier = modifier.mirrorIfRtl()`) and reuse — calling `mirrorIfRtl()` once or five times yields the same `Modifier` value inside one composition. -- **`GoToItemInnerButton`** is a private helper that renders both a +- **`GoToItemInnerButton`** is a local helper inside `ChatItemView` that renders both a directional icon (`ic_arrow_forward`, "go to forwarded source") and a non-directional icon (`ic_search`, search-result indicator) through the same `Icon(painterResource(icon), ...)` call. Add a @@ -128,6 +136,39 @@ Two structural details for site-level callers: the directional callsite. The `ic_search` callsite keeps its default and is unchanged. +### 4. Icons drawn by shared helpers + +Two icons are drawn inside a shared helper that takes a +`Painter` and no icon modifier: the "Don't enable" chevron on the +delivery receipts sheet (`SimpleButtonIconEnded`) and the "Show message +status" row in Advanced network settings (`SettingsPreferenceItem`; it +uses the same arrow that is mirrored in the message meta, so without +this the setting points the opposite way from the indicator it +controls). `Painter.mirrorIfRtl()` (next to `Modifier.mirrorIfRtl()`) +returns a painter that draws the original flipped under RTL; the +caller's alpha and tint are applied by `Painter`'s own layer around the +flipped drawing, so neither helper's signature changes. + +The member delivery status "forwarded" chevron (message menu → Info → +Delivery) is the only directional glyph `GroupSndStatus.statusIcon()` +returns; the info view mirrors it when the returned icon is that +chevron, so the status is not dispatched on a second time. + +### 5. Other absolute placements + +- `PriorityLayout` (the rows of every framed message: header, quote, + media, file, voice, link preview and text) placed its rows at x = 0, + so a caption narrower than the image sat at the end edge under RTL. `placeRelative(0, y)`. +- `DependentLayout` (group member name and role above a received + message) placed its rows at x = 0. Under RTL the row started at the + left edge of the bubble column instead of the avatar side and could + stick out past the pane edge on short messages. `placeRelative(0, y)`. +- `SupportSimpleXBanner` placed the image at the absolute right of the + card, where the right-aligned text runs under it (the banner text is + not translated, so it hides the end of the English text). The image uses + `placeRelative`; the card fills the layout, so its position is the + same in both directions. + ## Why this is correct under both layout directions | direction | sent | mirror? | tail position | profile side | OK | @@ -143,51 +184,57 @@ exact LTR preservation. `placeRelative` is identical to `place` under LTR. `mirrorIfRtl()` returns the receiver unchanged under LTR. No LTR location regresses. -## Why a wider structural change is not in scope here +## Not in this PR -These are deliberately deferred: - -1. **`GroupSndStatus.Forwarded`'s `ic_chevron_right_2`** in member - delivery status (long-press → Info → Delivery). Cleanly mirroring it - requires extending `GroupSndStatus.statusIcon()`'s return type so a - single `when (this)` produces `(icon, color, mirror)` — a - consumer-side `is Forwarded` check would dispatch on `status` a - second time and couple the consumer to status sub-types. The proper - fix is a model API change, deserving its own commit. - -2. **Two settings rows** route their icon through - `SimpleButtonIconEnded` and `SettingsPreferenceItem`, which don't - accept a per-icon `Modifier`. Fixing them requires extending those - helper signatures. - -3. **iOS-side**: not addressed because UIKit's RTL support and SwiftUI's - semantic edges already handle these cases. - -The PR aims at the user-visible regressions called out in the issue; -deferring the above keeps the diff focused (+35/−16 across 12 files) -and the regression risk low. +- **Reply, forward and "open" arrows** (22 references, mostly drawn + through shared helpers), and the backspace, login and logout icons. + Material mirrors them under RTL, but they + are not called out by the issue and do not point at a side of the + layout; a maintainer decision, listed in the PR. +- **Selection mode** shifts received messages the wrong way under RTL + (`ChatView.kt`), and the **voice message progress bar** fills from the + left while its thumb runs from the right — different bugs, separate PR. +- **Desktop applies the layout direction at start**: choosing an RTL + language in Appearance changes some strings at once and the layout + after a restart. Separate change. +- `ic_search` in `GoToItemInnerButton` stays unmirrored, as everywhere + else in the app. ## Verification -Manual (Android + desktop) — switch in-app language to Arabic / Persian / -Hebrew (or system locale to RTL) and walk through the screens called -out in the issue: +Manual, Android and desktop. Switch the in-app language to Arabic, +Persian or Hebrew (Settings → Appearance → Language). Android applies +the layout direction at once; on desktop restart the app after each +language change (to RTL and back). Then: -- Open any chat — sent-message tails point to your profile (visual side - closest to your avatar in 2-pane mode); received-message tails point - to the contact's avatar. -- Chat header — call + 3-dot menu on the visual left, back chevron on - the visual right and pointing right (`>`). Tapping the 3-dot menu - opens a dropdown that anchors on the left and stays on-screen. -- Settings → Your settings — back chevron points `>` and is on the right - edge of the navigation header. -- Chat list header — profile avatar on the right; "Chats" title and - signal/wifi icon on the left, with the signal icon's "waves" mirrored. -- Settings → About → What's new — pagination chevrons mirror. -- Sent-via-proxy badge in chat-item meta and message info — arrow - mirrors. -- Switch back to English — every above location matches pre-PR behavior; - no LTR regression. +- Open any chat — sent-message tails are on the outer (end) edge, + bottom-left under RTL; received-message tails point to the contact's + avatar, bottom-right under RTL. +- Chat header — call + 3-dot menu on the visual left; on Android the + back chevron is on the visual right and points right (`>`). Tapping + the 3-dot menu opens a dropdown next to the button, on the left. +- Any settings page (e.g. Settings → Appearance) — the back button + points `>` and is on the right edge of its bar (at the bottom with + the default one-hand UI). +- Chat list header — profile avatar on the right; "Chats" title stays + centred, with the signal icon to its left and its "waves" mirrored. +- Settings → Help → What's new — pagination chevrons mirror. +- With Advanced network settings → "Show message status" on and private + routing used: the sent-via-proxy arrow in the message meta, and in a + group message's Info → Delivery, points left; the "Show message + status" row's own arrow is mirrored too. +- Delivery receipts sheet (shown at start only on installations + upgraded from before delivery receipts) — the "Don't enable" chevron points + left. +- Group chat: the member name/role row above a short received message + starts at the avatar side and stays inside the pane (also the channel + name row); an image, file or voice message with a short text has the + text at the start side. +- Supporter banner on the chat list (more than 3 chats, no badge, not + dismissed): its image is at the end side and does not cover the + text. +- Switch back to English — every above location matches pre-PR + behaviour; no LTR regression. ## Alternatives considered and rejected @@ -195,9 +242,8 @@ out in the issue: the mirror policy once per asset instead of once per call site. The project's vector drawables are generated by moko-resources from SVGs, and the SVG → vector XML pipeline doesn't currently propagate that - flag. Per-call-site `mirrorIfRtl()` is the available alternative; if - moko-resources adds the flag, the per-asset approach becomes cleaner - and the modifier calls can be removed. + flag, and desktop draws the SVG files directly, with no auto-mirror + at all. Per-call-site `mirrorIfRtl()` works on both platforms. - **A `MirroredIcon` wrapper composable.** Each call site has different surrounding modifiers (`size`, `height`, `padding`), tints, and @@ -206,15 +252,5 @@ out in the issue: modifier chain. - **Mirror `ic_search` inside `GoToItemInnerButton`.** Material Design - recommends mirroring magnifying-glass icons under RTL (handle to - bottom-left), but the original PR doesn't mirror search icons - elsewhere and the issue doesn't call them out. Out of scope; revisit - in a focused pass over search-icon directionality if reported. - -- **Fix Forwarded delivery status icon by gating on - `status is GroupSndStatus.Forwarded` at the consumer.** Considered - during review and rejected: it dispatches on `status` a second time - (after `statusIcon()` already did), couples the consumer to specific - sub-types, and risks drift if a future status sub-type adopts a - directional icon. The proper fix is to fuse the dispatch by extending - `statusIcon()`'s return — deferred to its own commit. + lists the search icon as a handheld object that is not mirrored under + RTL, and the app does not mirror `ic_search` anywhere else.