From 5be7d720112dd4fb1fe49687bacfcf0941297705 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 31 Aug 2026 21:59:24 -0500 Subject: [PATCH] APP-2983: Fix RTL post alignment on native (#11600) --- package.json | 1 + pnpm-lock.yaml | 10 ++++++++ src/components/RichText.tsx | 10 +++++--- src/global.d.ts | 8 +++++++ .../strings/__tests__/text-direction.test.ts | 24 +++++++++++++++++++ src/lib/strings/text-direction.native.ts | 17 +++++++++++++ src/lib/strings/text-direction.web.ts | 7 ++++++ 7 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/lib/strings/__tests__/text-direction.test.ts create mode 100644 src/lib/strings/text-direction.native.ts create mode 100644 src/lib/strings/text-direction.web.ts diff --git a/package.json b/package.json index 32b58d9840..12b78723de 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "babel-plugin-transform-remove-console": "^6.9.4", "bcp-47": "^2.1.0", "bcp-47-match": "^2.0.3", + "bidi-js": "^1.0.3", "date-fns": "^4.4.0", "email-validator": "^2.0.4", "emoji-mart": "^5.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 207118a9cd..b6439ec1c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -421,6 +421,9 @@ importers: bcp-47-match: specifier: ^2.0.3 version: 2.0.3 + bidi-js: + specifier: ^1.0.3 + version: 1.0.3 date-fns: specifier: ^4.4.0 version: 4.4.0 @@ -4523,6 +4526,9 @@ packages: bcp-47@2.1.0: resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -14010,6 +14016,10 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + big-integer@1.6.52: {} big.js@5.2.2: {} diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 1fa020f2a9..e3b7090fa7 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -2,6 +2,7 @@ import {useMemo} from 'react' import {type StyleProp, type TextStyle} from 'react-native' import {RichText as RichTextAPI} from '@bsky/sdk/richtext' +import {isRTLText} from '#/lib/strings/text-direction' import {toShortUrl} from '#/lib/strings/url-helpers' import {android, atoms as a, flatten, type TextStyleProp} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' @@ -9,6 +10,7 @@ import {InlineLinkText, type LinkProps} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichTextTag} from '#/components/RichTextTag' import {Text, type TextProps} from '#/components/Typography' +import {IS_NATIVE} from '#/env' import {app} from '#/lexicons' import * as bsky from '#/types/bsky' @@ -82,15 +84,17 @@ export function RichText({ } }, [value]) - const plainStyles = style + const {text, facets} = richText + const plainStyles: StyleProp = [ + style, + IS_NATIVE && isRTLText(text) ? {textAlign: 'right'} : null, + ] const suffixStyles = suffix && suffixOffset ? android({paddingBottom: suffixOffset, marginBottom: -suffixOffset}) : null const interactiveStyles = [plainStyles, interactiveStyle] - const {text, facets} = richText - if (!facets?.length) { if (isOnlyEmoji(text)) { const flattenedStyle = flatten(style) diff --git a/src/global.d.ts b/src/global.d.ts index 829b15c95f..8a45691bb1 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,2 +1,10 @@ // TS6.0 enables noUncheckedSideEffectImports declare module '*.css' + +declare module 'bidi-js' { + type Bidi = { + getBidiCharTypeName(character: string): string + } + + export default function bidiFactory(): Bidi +} diff --git a/src/lib/strings/__tests__/text-direction.test.ts b/src/lib/strings/__tests__/text-direction.test.ts new file mode 100644 index 0000000000..a7470c1380 --- /dev/null +++ b/src/lib/strings/__tests__/text-direction.test.ts @@ -0,0 +1,24 @@ +import {describe, expect, it} from '@jest/globals' + +import {isRTLText} from '../text-direction' + +describe('isRTLText', () => { + it('recognizes right-to-left text', () => { + expect(isRTLText('עברית')).toBe(true) + expect(isRTLText('العربية')).toBe(true) + }) + + it('recognizes left-to-right text', () => { + expect(isRTLText('English')).toBe(false) + }) + + it('uses the first strong directional character', () => { + expect(isRTLText(' 123 🦋 עברית English')).toBe(true) + expect(isRTLText(' 123 🦋 English עברית')).toBe(false) + }) + + it('defaults to left-to-right when there are no strong characters', () => { + expect(isRTLText('123 🦋 ...')).toBe(false) + expect(isRTLText('')).toBe(false) + }) +}) diff --git a/src/lib/strings/text-direction.native.ts b/src/lib/strings/text-direction.native.ts new file mode 100644 index 0000000000..2276bdd4fa --- /dev/null +++ b/src/lib/strings/text-direction.native.ts @@ -0,0 +1,17 @@ +import bidiFactory from 'bidi-js' + +const bidi = bidiFactory() + +/** + * Checks the first strong directional character, matching HTML `dir="auto"`. + */ +export function isRTLText(text: string) { + for (const character of text) { + const type = bidi.getBidiCharTypeName(character) + + if (type === 'R' || type === 'AL') return true + if (type === 'L') return false + } + + return false +} diff --git a/src/lib/strings/text-direction.web.ts b/src/lib/strings/text-direction.web.ts new file mode 100644 index 0000000000..1f11ce2d42 --- /dev/null +++ b/src/lib/strings/text-direction.web.ts @@ -0,0 +1,7 @@ +/** + * React Native Web sets `dir="auto"` on root Text elements, so the browser + * handles direction detection without JavaScript. + */ +export function isRTLText(_text: string) { + return false +}