diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 21c18e2769c..a177a33620a 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -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)} diff --git a/src/lib/strings/__tests__/bidi.test.ts b/src/lib/strings/__tests__/bidi.test.ts new file mode 100644 index 00000000000..1958aa76c76 --- /dev/null +++ b/src/lib/strings/__tests__/bidi.test.ts @@ -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') + }) +}) diff --git a/src/lib/strings/bidi.ts b/src/lib/strings/bidi.ts index 790c2b6ac1b..fe312aa4fca 100644 --- a/src/lib/strings/bidi.ts +++ b/src/lib/strings/bidi.ts @@ -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 } diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 894d7fcd3c0..a89431938d9 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -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 = ({ { a.leading_tight, a.flex_shrink_0, {maxWidth: '70%'}, + web({direction: 'ltr', unicodeBidi: 'isolate'}), ]}> {forceLTR( sanitizeDisplayName(