Detect RTL post direction from text
This commit is contained in:
+2
-1
@@ -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__",
|
||||
|
||||
Generated
+10
@@ -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: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+8
@@ -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,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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user