diff --git a/package.json b/package.json index 1d87a848b7..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", @@ -340,7 +341,7 @@ "^cborg$": "/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__", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a959c7d706..f06361e593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index eded5f025d..5e195ac2f9 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -326,11 +326,6 @@ export function QuoteEmbed({ {richText ? ( & { 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) 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__/bidi.test.ts b/src/lib/strings/__tests__/bidi.test.ts index 0e744e4cf2..1958aa76c7 100644 --- a/src/lib/strings/__tests__/bidi.test.ts +++ b/src/lib/strings/__tests__/bidi.test.ts @@ -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) - }) -}) 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/bidi.ts b/src/lib/strings/bidi.ts index 7a3d0061ad..fe312aa4fc 100644 --- a/src/lib/strings/bidi.ts +++ b/src/lib/strings/bidi.ts @@ -2,7 +2,6 @@ import {IS_WEB} from '#/env' const LEFT_TO_RIGHT_EMBEDDING = '\u202A' const POP_DIRECTIONAL_FORMATTING = '\u202C' -const languageDirectionCache = new Map() /* * 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 - } -} 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 +} diff --git a/src/screens/Messages/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx index 2d6642dfbd..d5bcd4043c 100644 --- a/src/screens/Messages/components/MessageInputEmbed.tsx +++ b/src/screens/Messages/components/MessageInputEmbed.tsx @@ -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} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 3ad7024f3a..6a1ee4bb55 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -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} diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 34ebbac187..12fff63012 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -327,7 +327,6 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ )} @@ -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({ ]}>