Fix invisible bidi characters copied with handles on web (#11066)

This commit is contained in:
Abdelrahman Youssef
2026-07-16 01:56:34 +03:00
committed by GitHub
parent 4705b7f408
commit 83a791d7c8
5 changed files with 53 additions and 2 deletions
+2
View File
@@ -29,6 +29,7 @@ import {
type TextStyleProp,
useTheme,
type ViewStyleProp,
web,
} from '#/alf'
import {
Button,
@@ -257,6 +258,7 @@ function InlineNameAndHandle({
a.leading_tight,
a.flex_shrink_0,
{maxWidth: '70%'},
web({direction: 'ltr', unicodeBidi: 'isolate'}),
]}
numberOfLines={1}>
{forceLTR(name)}
+33
View File
@@ -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')
})
})
+9
View File
@@ -1,10 +1,19 @@
import {IS_WEB} from '#/env'
const LEFT_TO_RIGHT_EMBEDDING = '\u202A'
const POP_DIRECTIONAL_FORMATTING = '\u202C'
/*
* Force LTR directionality in a string.
* 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) {
if (IS_WEB) return str
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 {TimeElapsed} from '#/view/com/util/TimeElapsed'
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 {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
@@ -243,7 +243,13 @@ let NotificationFeedItem = ({
<ProfileHoverCard did={firstAuthor.profile.did} inline>
<InlineLinkText
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}
disableMismatchWarning
emoji
+1
View File
@@ -99,6 +99,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
a.leading_tight,
a.flex_shrink_0,
{maxWidth: '70%'},
web({direction: 'ltr', unicodeBidi: 'isolate'}),
]}>
{forceLTR(
sanitizeDisplayName(