Detect RTL post direction from text

This commit is contained in:
Eric Bailey
2026-08-28 15:56:27 -05:00
parent 29e16bdd42
commit 5999fd0c80
17 changed files with 72 additions and 69 deletions
+2 -1
View File
@@ -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",
@@ -340,7 +341,7 @@
"^cborg$": "<rootDir>/node_modules/cborg/cborg.js"
},
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@formatjs/.*|@formatjs_generated/.*|@atproto/.*|@bsky/sdk|tlds|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|@bsky/sdk|tlds|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)"
],
"modulePathIgnorePatterns": [
"__tests__/.*/__mocks__",
+10
View File
@@ -420,6 +420,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
@@ -4522,6 +4525,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'}
@@ -14009,6 +14015,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: {}
-5
View File
@@ -326,11 +326,6 @@ export function QuoteEmbed({
{richText ? (
<RichText
value={richText}
language={
bsky.isType(app.bsky.feed.post, quote.record)
? quote.record.langs?.[0]
: undefined
}
style={a.text_md}
numberOfLines={20}
disableLinks
+3 -6
View File
@@ -2,7 +2,7 @@ import {useMemo} from 'react'
import {type StyleProp, type TextStyle} from 'react-native'
import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
import {isRTL} from '#/lib/strings/bidi'
import {isRTLText} from '#/lib/strings/text-direction'
import {toShortUrl} from '#/lib/strings/url-helpers'
import {android, atoms as a, flatten, native, type TextStyleProp} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
@@ -21,7 +21,6 @@ const URL_REGEX =
export type RichTextProps = TextStyleProp &
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
value: RichTextAPI | string
language?: string
testID?: string
numberOfLines?: number
disableLinks?: boolean
@@ -58,7 +57,6 @@ export type RichTextProps = TextStyleProp &
export function RichText({
testID,
value,
language,
style,
numberOfLines,
disableLinks,
@@ -85,9 +83,10 @@ export function RichText({
}
}, [value])
const {text, facets} = richText
const plainStyles = [
style,
isRTL(language) ? native({textAlign: 'right'}) : null,
isRTLText(text) ? native({textAlign: 'right'}) : null,
]
const suffixStyles =
suffix && suffixOffset
@@ -95,8 +94,6 @@ export function RichText({
: null
const interactiveStyles = [plainStyles, interactiveStyle]
const {text, facets} = richText
if (!facets?.length) {
if (isOnlyEmoji(text)) {
const flattenedStyle = flatten(style)
+8
View File
@@ -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
}
+1 -26
View File
@@ -1,5 +1,3 @@
import '@formatjs/intl-locale/polyfill-force.js'
import {describe, expect, it, jest} from '@jest/globals'
/*
@@ -13,7 +11,7 @@ jest.mock('#/env', () => ({
},
}))
import {forceLTR, isRTL} from '../bidi'
import {forceLTR} from '../bidi'
const LEFT_TO_RIGHT_EMBEDDING = '\u202A'
const POP_DIRECTIONAL_FORMATTING = '\u202C'
@@ -33,26 +31,3 @@ describe('forceLTR', () => {
expect(forceLTR('@alice.bsky.social')).toBe('@alice.bsky.social')
})
})
describe('isRTL', () => {
it('uses the Intl.Locale implementation forced in production', () => {
expect(Intl.Locale).toHaveProperty('polyfilled', true)
expect(Intl.Locale.prototype).toHaveProperty('getTextInfo')
})
it('recognizes right-to-left languages and scripts', () => {
expect(isRTL('he')).toBe(true)
expect(isRTL('ar')).toBe(true)
expect(isRTL('az-Arab')).toBe(true)
})
it('recognizes left-to-right languages and scripts', () => {
expect(isRTL('en')).toBe(false)
expect(isRTL('az-Latn')).toBe(false)
})
it('handles missing and invalid language tags', () => {
expect(isRTL(undefined)).toBe(false)
expect(isRTL('not_a_language')).toBe(false)
})
})
@@ -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)
})
})
-21
View File
@@ -2,7 +2,6 @@ import {IS_WEB} from '#/env'
const LEFT_TO_RIGHT_EMBEDDING = '\u202A'
const POP_DIRECTIONAL_FORMATTING = '\u202C'
const languageDirectionCache = new Map<string, boolean>()
/*
* Force LTR directionality in a string.
@@ -18,23 +17,3 @@ export function forceLTR(str: string) {
if (IS_WEB) return str
return LEFT_TO_RIGHT_EMBEDDING + str + POP_DIRECTIONAL_FORMATTING
}
/**
* Determines whether a BCP 47 language tag uses a right-to-left script.
*/
export function isRTL(language: string | undefined) {
if (!language) return false
const cached = languageDirectionCache.get(language)
if (cached !== undefined) return cached
try {
const isRightToLeft =
new Intl.Locale(language).getTextInfo().direction === 'rtl'
languageDirectionCache.set(language, isRightToLeft)
return isRightToLeft
} catch {
languageDirectionCache.set(language, false)
return false
}
}
+17
View File
@@ -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
}
+7
View File
@@ -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
}
@@ -239,7 +239,6 @@ function MessageInputPostEmbed({
enableTags
testID="postText"
value={rt}
language={record.langs?.[0]}
style={[a.text_sm, t.atoms.text_contrast_high]}
authorHandle={post.author.handle}
numberOfLines={3}
@@ -405,7 +405,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
enableTags
selectable
value={richText}
language={record.langs?.[0]}
style={[a.flex_1, a.text_lg]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
@@ -327,7 +327,6 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
<RichText
enableTags
value={richText}
language={record.langs?.[0]}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
@@ -355,7 +355,6 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
<RichText
enableTags
value={richText}
language={record.langs?.[0]}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
-4
View File
@@ -963,7 +963,6 @@ function Overlay({
{record?.text?.trim() && (
<ExpandableRichTextView
value={richText}
language={record.langs?.[0]}
authorHandle={post.author.handle}
/>
)}
@@ -1021,11 +1020,9 @@ function Overlay({
function ExpandableRichTextView({
value,
language,
authorHandle,
}: {
value: RichTextAPI
language?: string
authorHandle?: string
}) {
const {height: screenHeight} = useSafeAreaFrame()
@@ -1060,7 +1057,6 @@ function ExpandableRichTextView({
]}>
<RichText
value={value}
language={language}
style={[a.text_sm, a.flex_1, a.leading_relaxed]}
authorHandle={authorHandle}
enableTags
-1
View File
@@ -226,7 +226,6 @@ function PostInner({
enableTags
testID="postText"
value={richText}
language={record?.langs?.[0]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
style={[a.flex_1, a.text_md]}
authorHandle={post.author.handle}
-1
View File
@@ -520,7 +520,6 @@ let PostContent = ({
enableTags
testID="postText"
value={richText}
language={record?.langs?.[0]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
style={[a.flex_1, a.text_md]}
authorHandle={postAuthor.handle}