Fix invisible bidi characters copied with handles on web (#11066)
This commit is contained in:
committed by
GitHub
parent
4705b7f408
commit
83a791d7c8
@@ -29,6 +29,7 @@ import {
|
|||||||
type TextStyleProp,
|
type TextStyleProp,
|
||||||
useTheme,
|
useTheme,
|
||||||
type ViewStyleProp,
|
type ViewStyleProp,
|
||||||
|
web,
|
||||||
} from '#/alf'
|
} from '#/alf'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -257,6 +258,7 @@ function InlineNameAndHandle({
|
|||||||
a.leading_tight,
|
a.leading_tight,
|
||||||
a.flex_shrink_0,
|
a.flex_shrink_0,
|
||||||
{maxWidth: '70%'},
|
{maxWidth: '70%'},
|
||||||
|
web({direction: 'ltr', unicodeBidi: 'isolate'}),
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{forceLTR(name)}
|
{forceLTR(name)}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {describe, expect, it, jest} from '@jest/globals'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* bidi.ts reads IS_WEB at call time, so a getter lets each test pick the
|
||||||
|
* platform. The mock-prefixed name is required by jest's factory scope rule.
|
||||||
|
*/
|
||||||
|
let mockIsWeb = false
|
||||||
|
jest.mock('#/env', () => ({
|
||||||
|
get IS_WEB() {
|
||||||
|
return mockIsWeb
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {forceLTR} from '../bidi'
|
||||||
|
|
||||||
|
const LEFT_TO_RIGHT_EMBEDDING = '\u202A'
|
||||||
|
const POP_DIRECTIONAL_FORMATTING = '\u202C'
|
||||||
|
|
||||||
|
describe('forceLTR', () => {
|
||||||
|
it('wraps the string in directional formatting characters on native', () => {
|
||||||
|
mockIsWeb = false
|
||||||
|
expect(forceLTR('@alice.bsky.social')).toBe(
|
||||||
|
LEFT_TO_RIGHT_EMBEDDING +
|
||||||
|
'@alice.bsky.social' +
|
||||||
|
POP_DIRECTIONAL_FORMATTING,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the string unchanged on web so copied text stays clean (#8451)', () => {
|
||||||
|
mockIsWeb = true
|
||||||
|
expect(forceLTR('@alice.bsky.social')).toBe('@alice.bsky.social')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Force LTR directionality in a string.
|
* Force LTR directionality in a string.
|
||||||
* https://www.unicode.org/reports/tr9/#Directional_Formatting_Characters
|
* https://www.unicode.org/reports/tr9/#Directional_Formatting_Characters
|
||||||
|
*
|
||||||
|
* On web, direction is isolated with CSS instead (direction: ltr + unicode-bidi:
|
||||||
|
* isolate on the surrounding Text), so these invisible control characters are not
|
||||||
|
* injected. Injecting them leaks the characters into the rendered text, where
|
||||||
|
* they end up in copy-paste and break handle lookups in other apps and tools
|
||||||
|
* (#8451). Native has no equivalent CSS, so the manual wrapping is kept there.
|
||||||
*/
|
*/
|
||||||
export function forceLTR(str: string) {
|
export function forceLTR(str: string) {
|
||||||
|
if (IS_WEB) return str
|
||||||
return LEFT_TO_RIGHT_EMBEDDING + str + POP_DIRECTIONAL_FORMATTING
|
return LEFT_TO_RIGHT_EMBEDDING + str + POP_DIRECTIONAL_FORMATTING
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import {Post} from '#/view/com/post/Post'
|
|||||||
import {formatCount} from '#/view/com/util/numeric/format'
|
import {formatCount} from '#/view/com/util/numeric/format'
|
||||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, platform, useTheme} from '#/alf'
|
import {atoms as a, platform, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
||||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
@@ -243,7 +243,13 @@ let NotificationFeedItem = ({
|
|||||||
<ProfileHoverCard did={firstAuthor.profile.did} inline>
|
<ProfileHoverCard did={firstAuthor.profile.did} inline>
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
key={firstAuthor.href}
|
key={firstAuthor.href}
|
||||||
style={[t.atoms.text, a.font_semi_bold, a.text_md, a.leading_tight]}
|
style={[
|
||||||
|
t.atoms.text,
|
||||||
|
a.font_semi_bold,
|
||||||
|
a.text_md,
|
||||||
|
a.leading_tight,
|
||||||
|
web({direction: 'ltr', unicodeBidi: 'isolate'}),
|
||||||
|
]}
|
||||||
to={firstAuthor.href}
|
to={firstAuthor.href}
|
||||||
disableMismatchWarning
|
disableMismatchWarning
|
||||||
emoji
|
emoji
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
|||||||
a.leading_tight,
|
a.leading_tight,
|
||||||
a.flex_shrink_0,
|
a.flex_shrink_0,
|
||||||
{maxWidth: '70%'},
|
{maxWidth: '70%'},
|
||||||
|
web({direction: 'ltr', unicodeBidi: 'isolate'}),
|
||||||
]}>
|
]}>
|
||||||
{forceLTR(
|
{forceLTR(
|
||||||
sanitizeDisplayName(
|
sanitizeDisplayName(
|
||||||
|
|||||||
Reference in New Issue
Block a user