Skip to content

Commit fdb1833

Browse files
fix(chat): align messages and questions with their text direction
Hebrew and Arabic prose, lists, quotes, and alerts follow their own direction. Code, file chips, and table chrome stay left to right. Composer questions use the same direction, including a Hebrew question that opens with a few English words.
1 parent 71dbaf1 commit fdb1833

16 files changed

Lines changed: 707 additions & 113 deletions

‎apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createContext, memo, useContext, useMemo } from "react";
22
import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native";
33
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
4+
import { resolveTextDirection } from "@t3tools/shared/textDirection";
45

56
import { CopyTextButton } from "./CopyTextButton";
67
import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
@@ -9,7 +10,10 @@ import {
910
nativeMarkdownListItemBlocks,
1011
nativeMarkdownNodePosition,
1112
} from "./nativeMarkdownText";
12-
import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText";
13+
import {
14+
MarkdownDirectionContext,
15+
NativeMarkdownSelectableText,
16+
} from "./NativeMarkdownSelectableText";
1317
import type {
1418
MarkdownCodeHighlighter,
1519
MarkdownHighlightedToken,
@@ -114,6 +118,7 @@ function HighlightedCodeText(props: {
114118
fontFamily: MONO_FONT_FAMILY,
115119
fontSize,
116120
lineHeight,
121+
textAlign: "left" as const,
117122
}),
118123
[props.textStyle.codeColor, fontSize, lineHeight],
119124
);
@@ -164,6 +169,7 @@ function NativeCodeBlock(props: {
164169
borderCurve: "continuous",
165170
borderRadius: 10,
166171
borderWidth: 1,
172+
direction: "ltr",
167173
marginVertical: props.compact ? 7 : 0,
168174
overflow: "hidden",
169175
}}
@@ -256,6 +262,7 @@ function NativeTable(props: {
256262
borderCurve: "continuous",
257263
borderRadius: 8,
258264
borderWidth: 1,
265+
direction: "ltr",
259266
overflow: "hidden",
260267
}}
261268
>
@@ -286,6 +293,7 @@ function NativeTable(props: {
286293
)}
287294
textStyle={props.textStyle}
288295
onLinkPress={props.onLinkPress}
296+
direction={resolveTextDirection(nodeText(cell))}
289297
/>
290298
</View>
291299
))}
@@ -423,9 +431,11 @@ function NativeList(props: {
423431
const ordered = props.node.ordered ?? false;
424432
const start = props.node.start ?? 1;
425433
const nested = props.depth > 0;
434+
const direction = useContext(MarkdownDirectionContext);
426435
return (
427436
<View
428437
style={{
438+
direction,
429439
gap: nested ? 3 : 5,
430440
}}
431441
>
@@ -447,13 +457,13 @@ function NativeList(props: {
447457
return (
448458
<View
449459
key={nodeKey(item, index)}
450-
style={{ alignItems: "flex-start", flexDirection: "row" }}
460+
style={{ alignItems: "flex-start", direction, flexDirection: "row" }}
451461
>
452462
<View
453463
style={{
454464
width: markerWidth,
455465
height: props.textStyle.lineHeight,
456-
marginRight: 6,
466+
marginEnd: 6,
457467
alignItems: ordered ? "flex-end" : "center",
458468
justifyContent: "flex-start",
459469
}}
@@ -501,6 +511,7 @@ export function NativeMarkdownBlock(props: {
501511
readonly depth?: number;
502512
readonly compact?: boolean;
503513
}) {
514+
const direction = useContext(MarkdownDirectionContext);
504515
const depth = props.depth ?? 0;
505516
switch (props.node.type) {
506517
case "document":
@@ -559,10 +570,13 @@ export function NativeMarkdownBlock(props: {
559570
return (
560571
<View
561572
style={{
562-
borderLeftColor: props.textStyle.quoteMarkerColor,
563-
borderLeftWidth: 2,
573+
borderLeftColor: direction === "rtl" ? undefined : props.textStyle.quoteMarkerColor,
574+
borderLeftWidth: direction === "rtl" ? 0 : 2,
575+
borderRightColor: direction === "rtl" ? props.textStyle.quoteMarkerColor : undefined,
576+
borderRightWidth: direction === "rtl" ? 2 : 0,
564577
marginVertical: props.compact ? 4 : 0,
565-
paddingLeft: 11,
578+
paddingLeft: direction === "rtl" ? 0 : 11,
579+
paddingRight: direction === "rtl" ? 11 : 0,
566580
paddingVertical: 2,
567581
gap: 6,
568582
}}

‎apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createContext, useCallback, useContext, useMemo } from "react";
22
import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
3+
import type { TextDirection } from "@t3tools/shared/textDirection";
34
import {
45
findNodeHandle,
56
Image,
@@ -31,6 +32,9 @@ import { contextChipPresentation } from "./nativeMarkdownText";
3132

3233
export const MarkdownContextClipboardContext = createContext("");
3334

35+
/** Document direction for selectable prose and the rich blocks beside it. */
36+
export const MarkdownDirectionContext = createContext<TextDirection>("ltr");
37+
3438
export interface MarkdownFileContextMenuHandlers {
3539
readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
3640
readonly onFileContextMenuAction: (href: string, actionId: string) => void;
@@ -196,8 +200,12 @@ export function NativeMarkdownSelectableText(props: {
196200
readonly runs: ReadonlyArray<NativeMarkdownTextRun>;
197201
readonly textStyle: NativeMarkdownTextStyle;
198202
readonly onLinkPress?: (href: string) => void;
203+
/** Overrides the surrounding markdown direction. Table cells resolve on their own. */
204+
readonly direction?: TextDirection;
199205
}) {
200206
const colorScheme = useColorScheme();
207+
const inheritedDirection = useContext(MarkdownDirectionContext);
208+
const direction = props.direction ?? inheritedDirection;
201209
const menu = useContext(MarkdownFileContextMenuContext);
202210
const contextClipboardFragment = useContext(MarkdownContextClipboardContext);
203211
const contextRecords = useMemo(
@@ -326,6 +334,7 @@ export function NativeMarkdownSelectableText(props: {
326334
props.textStyle.quoteMarkerColor,
327335
props.textStyle.dividerColor,
328336
props.textStyle.contextChipBorderColor,
337+
direction,
329338
].join(":");
330339

331340
return (
@@ -349,6 +358,7 @@ export function NativeMarkdownSelectableText(props: {
349358
fontFamily: props.textStyle.fontFamily,
350359
fontSize: props.textStyle.fontSize,
351360
lineHeight: props.textStyle.lineHeight,
361+
textAlign: direction === "rtl" ? "right" : "left",
352362
}}
353363
>
354364
{keyedRuns.map(({ key, run, text, linkIcon, chip, androidChip }) => {

‎apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx‎

Lines changed: 48 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useMemo } from "react";
22
import { View } from "react-native";
33
import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
4+
import { resolveMarkdownProseDirection } from "@t3tools/shared/textDirection";
45

56
import {
67
nativeMarkdownChunkSpacing,
@@ -12,6 +13,7 @@ import {
1213
import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock";
1314
import {
1415
MarkdownContextClipboardContext,
16+
MarkdownDirectionContext,
1517
MarkdownFileContextMenuContext,
1618
NativeMarkdownSelectableText,
1719
type MarkdownFileContextMenuHandlers,
@@ -51,22 +53,25 @@ export function SelectableMarkdownText({
5153
marginTop = 0,
5254
marginBottom = 0,
5355
}: SelectableMarkdownTextProps) {
54-
const chunks = useMemo(() => {
56+
const { chunks, direction } = useMemo(() => {
5557
const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths(
5658
parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }),
5759
markdown,
5860
);
5961
const document = preserveSoftBreaks
6062
? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
6163
: parsedDocument;
62-
return nativeMarkdownDocumentChunks(document).map((chunk) =>
63-
chunk.kind === "selectable"
64-
? {
65-
...chunk,
66-
runs: nativeMarkdownDocumentRuns(chunk.node, skills),
67-
}
68-
: chunk,
69-
);
64+
return {
65+
direction: resolveMarkdownProseDirection(document),
66+
chunks: nativeMarkdownDocumentChunks(document).map((chunk) =>
67+
chunk.kind === "selectable"
68+
? {
69+
...chunk,
70+
runs: nativeMarkdownDocumentRuns(chunk.node, skills),
71+
}
72+
: chunk,
73+
),
74+
};
7075
}, [markdown, preserveSoftBreaks, skills]);
7176

7277
const fileContextMenuHandlers = useMemo<MarkdownFileContextMenuHandlers | null>(
@@ -81,39 +86,41 @@ export function SelectableMarkdownText({
8186
<MarkdownContextClipboardContext.Provider value={contextClipboardFragment ?? ""}>
8287
<MarkdownImageRendererContext.Provider value={renderImage ?? null}>
8388
<MarkdownFileContextMenuContext.Provider value={fileContextMenuHandlers}>
84-
{/* A percentage width here creates a cyclic intrinsic measurement inside
85-
shrink-to-fit containers such as user-message bubbles. Yoga then gives
86-
the native text node an unbounded second pass and the parent only clips
87-
the resulting single-line width instead of reflowing it. */}
88-
<View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
89-
{chunks.map((chunk, index) => {
90-
const content =
91-
chunk.kind === "rich" ? (
92-
<NativeMarkdownBlock
93-
node={chunk.node}
94-
skills={skills}
95-
textStyle={textStyle}
96-
highlightCode={highlightCode}
97-
onLinkPress={onLinkPress}
98-
/>
99-
) : (
100-
<NativeMarkdownSelectableText
101-
runs={chunk.runs}
102-
textStyle={textStyle}
103-
onLinkPress={onLinkPress}
104-
/>
105-
);
89+
<MarkdownDirectionContext.Provider value={direction}>
90+
{/* A percentage width here creates a cyclic intrinsic measurement inside
91+
shrink-to-fit containers such as user-message bubbles. Yoga then gives
92+
the native text node an unbounded second pass and the parent only clips
93+
the resulting single-line width instead of reflowing it. */}
94+
<View style={{ direction, flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
95+
{chunks.map((chunk, index) => {
96+
const content =
97+
chunk.kind === "rich" ? (
98+
<NativeMarkdownBlock
99+
node={chunk.node}
100+
skills={skills}
101+
textStyle={textStyle}
102+
highlightCode={highlightCode}
103+
onLinkPress={onLinkPress}
104+
/>
105+
) : (
106+
<NativeMarkdownSelectableText
107+
runs={chunk.runs}
108+
textStyle={textStyle}
109+
onLinkPress={onLinkPress}
110+
/>
111+
);
106112

107-
return (
108-
<View
109-
key={chunk.key}
110-
style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
111-
>
112-
{content}
113-
</View>
114-
);
115-
})}
116-
</View>
113+
return (
114+
<View
115+
key={chunk.key}
116+
style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
117+
>
118+
{content}
119+
</View>
120+
);
121+
})}
122+
</View>
123+
</MarkdownDirectionContext.Provider>
117124
</MarkdownFileContextMenuContext.Provider>
118125
</MarkdownImageRendererContext.Provider>
119126
</MarkdownContextClipboardContext.Provider>

0 commit comments

Comments
 (0)