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",
|
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||||
"bcp-47": "^2.1.0",
|
"bcp-47": "^2.1.0",
|
||||||
"bcp-47-match": "^2.0.3",
|
"bcp-47-match": "^2.0.3",
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"email-validator": "^2.0.4",
|
"email-validator": "^2.0.4",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
@@ -340,7 +341,7 @@
|
|||||||
"^cborg$": "<rootDir>/node_modules/cborg/cborg.js"
|
"^cborg$": "<rootDir>/node_modules/cborg/cborg.js"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"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": [
|
"modulePathIgnorePatterns": [
|
||||||
"__tests__/.*/__mocks__",
|
"__tests__/.*/__mocks__",
|
||||||
|
|||||||
Generated
+10
@@ -420,6 +420,9 @@ importers:
|
|||||||
bcp-47-match:
|
bcp-47-match:
|
||||||
specifier: ^2.0.3
|
specifier: ^2.0.3
|
||||||
version: 2.0.3
|
version: 2.0.3
|
||||||
|
bidi-js:
|
||||||
|
specifier: ^1.0.3
|
||||||
|
version: 1.0.3
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
@@ -4522,6 +4525,9 @@ packages:
|
|||||||
bcp-47@2.1.0:
|
bcp-47@2.1.0:
|
||||||
resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==}
|
resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==}
|
||||||
|
|
||||||
|
bidi-js@1.0.3:
|
||||||
|
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||||
|
|
||||||
big-integer@1.6.52:
|
big-integer@1.6.52:
|
||||||
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -14009,6 +14015,10 @@ snapshots:
|
|||||||
is-alphanumerical: 2.0.1
|
is-alphanumerical: 2.0.1
|
||||||
is-decimal: 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-integer@1.6.52: {}
|
||||||
|
|
||||||
big.js@5.2.2: {}
|
big.js@5.2.2: {}
|
||||||
|
|||||||
@@ -326,11 +326,6 @@ export function QuoteEmbed({
|
|||||||
{richText ? (
|
{richText ? (
|
||||||
<RichText
|
<RichText
|
||||||
value={richText}
|
value={richText}
|
||||||
language={
|
|
||||||
bsky.isType(app.bsky.feed.post, quote.record)
|
|
||||||
? quote.record.langs?.[0]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
style={a.text_md}
|
style={a.text_md}
|
||||||
numberOfLines={20}
|
numberOfLines={20}
|
||||||
disableLinks
|
disableLinks
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {useMemo} from 'react'
|
|||||||
import {type StyleProp, type TextStyle} from 'react-native'
|
import {type StyleProp, type TextStyle} from 'react-native'
|
||||||
import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
|
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 {toShortUrl} from '#/lib/strings/url-helpers'
|
||||||
import {android, atoms as a, flatten, native, type TextStyleProp} from '#/alf'
|
import {android, atoms as a, flatten, native, type TextStyleProp} from '#/alf'
|
||||||
import {isOnlyEmoji} from '#/alf/typography'
|
import {isOnlyEmoji} from '#/alf/typography'
|
||||||
@@ -21,7 +21,6 @@ const URL_REGEX =
|
|||||||
export type RichTextProps = TextStyleProp &
|
export type RichTextProps = TextStyleProp &
|
||||||
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
|
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
|
||||||
value: RichTextAPI | string
|
value: RichTextAPI | string
|
||||||
language?: string
|
|
||||||
testID?: string
|
testID?: string
|
||||||
numberOfLines?: number
|
numberOfLines?: number
|
||||||
disableLinks?: boolean
|
disableLinks?: boolean
|
||||||
@@ -58,7 +57,6 @@ export type RichTextProps = TextStyleProp &
|
|||||||
export function RichText({
|
export function RichText({
|
||||||
testID,
|
testID,
|
||||||
value,
|
value,
|
||||||
language,
|
|
||||||
style,
|
style,
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
disableLinks,
|
disableLinks,
|
||||||
@@ -85,9 +83,10 @@ export function RichText({
|
|||||||
}
|
}
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
|
const {text, facets} = richText
|
||||||
const plainStyles = [
|
const plainStyles = [
|
||||||
style,
|
style,
|
||||||
isRTL(language) ? native({textAlign: 'right'}) : null,
|
isRTLText(text) ? native({textAlign: 'right'}) : null,
|
||||||
]
|
]
|
||||||
const suffixStyles =
|
const suffixStyles =
|
||||||
suffix && suffixOffset
|
suffix && suffixOffset
|
||||||
@@ -95,8 +94,6 @@ export function RichText({
|
|||||||
: null
|
: null
|
||||||
const interactiveStyles = [plainStyles, interactiveStyle]
|
const interactiveStyles = [plainStyles, interactiveStyle]
|
||||||
|
|
||||||
const {text, facets} = richText
|
|
||||||
|
|
||||||
if (!facets?.length) {
|
if (!facets?.length) {
|
||||||
if (isOnlyEmoji(text)) {
|
if (isOnlyEmoji(text)) {
|
||||||
const flattenedStyle = flatten(style)
|
const flattenedStyle = flatten(style)
|
||||||
|
|||||||
Vendored
+8
@@ -1,2 +1,10 @@
|
|||||||
// TS6.0 enables noUncheckedSideEffectImports
|
// TS6.0 enables noUncheckedSideEffectImports
|
||||||
declare module '*.css'
|
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'
|
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 LEFT_TO_RIGHT_EMBEDDING = '\u202A'
|
||||||
const POP_DIRECTIONAL_FORMATTING = '\u202C'
|
const POP_DIRECTIONAL_FORMATTING = '\u202C'
|
||||||
@@ -33,26 +31,3 @@ describe('forceLTR', () => {
|
|||||||
expect(forceLTR('@alice.bsky.social')).toBe('@alice.bsky.social')
|
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 LEFT_TO_RIGHT_EMBEDDING = '\u202A'
|
||||||
const POP_DIRECTIONAL_FORMATTING = '\u202C'
|
const POP_DIRECTIONAL_FORMATTING = '\u202C'
|
||||||
const languageDirectionCache = new Map<string, boolean>()
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Force LTR directionality in a string.
|
* Force LTR directionality in a string.
|
||||||
@@ -18,23 +17,3 @@ export function forceLTR(str: string) {
|
|||||||
if (IS_WEB) return str
|
if (IS_WEB) return str
|
||||||
return LEFT_TO_RIGHT_EMBEDDING + str + POP_DIRECTIONAL_FORMATTING
|
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
|
enableTags
|
||||||
testID="postText"
|
testID="postText"
|
||||||
value={rt}
|
value={rt}
|
||||||
language={record.langs?.[0]}
|
|
||||||
style={[a.text_sm, t.atoms.text_contrast_high]}
|
style={[a.text_sm, t.atoms.text_contrast_high]}
|
||||||
authorHandle={post.author.handle}
|
authorHandle={post.author.handle}
|
||||||
numberOfLines={3}
|
numberOfLines={3}
|
||||||
|
|||||||
@@ -405,7 +405,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
enableTags
|
enableTags
|
||||||
selectable
|
selectable
|
||||||
value={richText}
|
value={richText}
|
||||||
language={record.langs?.[0]}
|
|
||||||
style={[a.flex_1, a.text_lg]}
|
style={[a.flex_1, a.text_lg]}
|
||||||
authorHandle={post.author.handle}
|
authorHandle={post.author.handle}
|
||||||
shouldProxyLinks={true}
|
shouldProxyLinks={true}
|
||||||
|
|||||||
@@ -327,7 +327,6 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
|
|||||||
<RichText
|
<RichText
|
||||||
enableTags
|
enableTags
|
||||||
value={richText}
|
value={richText}
|
||||||
language={record.langs?.[0]}
|
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
authorHandle={post.author.handle}
|
authorHandle={post.author.handle}
|
||||||
|
|||||||
@@ -355,7 +355,6 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
|||||||
<RichText
|
<RichText
|
||||||
enableTags
|
enableTags
|
||||||
value={richText}
|
value={richText}
|
||||||
language={record.langs?.[0]}
|
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
authorHandle={post.author.handle}
|
authorHandle={post.author.handle}
|
||||||
|
|||||||
@@ -963,7 +963,6 @@ function Overlay({
|
|||||||
{record?.text?.trim() && (
|
{record?.text?.trim() && (
|
||||||
<ExpandableRichTextView
|
<ExpandableRichTextView
|
||||||
value={richText}
|
value={richText}
|
||||||
language={record.langs?.[0]}
|
|
||||||
authorHandle={post.author.handle}
|
authorHandle={post.author.handle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1021,11 +1020,9 @@ function Overlay({
|
|||||||
|
|
||||||
function ExpandableRichTextView({
|
function ExpandableRichTextView({
|
||||||
value,
|
value,
|
||||||
language,
|
|
||||||
authorHandle,
|
authorHandle,
|
||||||
}: {
|
}: {
|
||||||
value: RichTextAPI
|
value: RichTextAPI
|
||||||
language?: string
|
|
||||||
authorHandle?: string
|
authorHandle?: string
|
||||||
}) {
|
}) {
|
||||||
const {height: screenHeight} = useSafeAreaFrame()
|
const {height: screenHeight} = useSafeAreaFrame()
|
||||||
@@ -1060,7 +1057,6 @@ function ExpandableRichTextView({
|
|||||||
]}>
|
]}>
|
||||||
<RichText
|
<RichText
|
||||||
value={value}
|
value={value}
|
||||||
language={language}
|
|
||||||
style={[a.text_sm, a.flex_1, a.leading_relaxed]}
|
style={[a.text_sm, a.flex_1, a.leading_relaxed]}
|
||||||
authorHandle={authorHandle}
|
authorHandle={authorHandle}
|
||||||
enableTags
|
enableTags
|
||||||
|
|||||||
@@ -226,7 +226,6 @@ function PostInner({
|
|||||||
enableTags
|
enableTags
|
||||||
testID="postText"
|
testID="postText"
|
||||||
value={richText}
|
value={richText}
|
||||||
language={record?.langs?.[0]}
|
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
authorHandle={post.author.handle}
|
authorHandle={post.author.handle}
|
||||||
|
|||||||
@@ -520,7 +520,6 @@ let PostContent = ({
|
|||||||
enableTags
|
enableTags
|
||||||
testID="postText"
|
testID="postText"
|
||||||
value={richText}
|
value={richText}
|
||||||
language={record?.langs?.[0]}
|
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
authorHandle={postAuthor.handle}
|
authorHandle={postAuthor.handle}
|
||||||
|
|||||||
Reference in New Issue
Block a user