Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 113 additions & 32 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from "react";
import {
Markdown,
parseMarkdown,
type CustomRenderers,
type NodeStyleOverrides,
type PartialMarkdownTheme,
Expand Down Expand Up @@ -79,6 +80,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
import { resolveMarkdownNodeTextDirection, type TextDirection } from "../../lib/textDirection";
import {
hasNativeSelectableMarkdownText,
SelectableMarkdownText,
Expand Down Expand Up @@ -513,7 +515,7 @@ interface MarkdownStyleSets {
interface MarkdownStyleSet {
readonly theme: PartialMarkdownTheme;
readonly styles: NodeStyleOverrides;
readonly renderers: CustomRenderers;
readonly renderers: Readonly<Record<TextDirection, CustomRenderers>>;
readonly nativeTextStyle: NativeMarkdownTextStyle;
}

Expand Down Expand Up @@ -637,6 +639,65 @@ function ArtifactTemplateCard(props: {
);
}

const NitroMarkdownMessage = memo(function NitroMarkdownMessage(props: {
readonly text: string;
readonly markdownStyles: MarkdownStyleSet;
}) {
const sourceAst = useMemo(() => parseMarkdown(props.text, { gfm: true }), [props.text]);
const direction = resolveMarkdownNodeTextDirection(sourceAst);
const styles = useMemo<NodeStyleOverrides>(
() => ({
...props.markdownStyles.styles,
document: {
...props.markdownStyles.styles.document,
direction,
},
Comment thread
cursor[bot] marked this conversation as resolved.
paragraph: {
...props.markdownStyles.styles.paragraph,
direction,
},
list: {
...props.markdownStyles.styles.list,
direction,
},
list_item: {
...props.markdownStyles.styles.list_item,
direction,
},
task_list_item: {
...props.markdownStyles.styles.task_list_item,
direction,
},
blockquote: {
...props.markdownStyles.styles.blockquote,
direction,
...(direction === "rtl"
? {
borderLeftWidth: 0,
borderRightWidth: props.markdownStyles.styles.blockquote?.borderLeftWidth,
borderRightColor: props.markdownStyles.styles.blockquote?.borderLeftColor,
paddingLeft: 0,
paddingRight: props.markdownStyles.styles.blockquote?.paddingLeft,
}
: null),
},
}),
[direction, props.markdownStyles.styles],
);

return (
<Markdown
options={{ gfm: true }}
renderers={props.markdownStyles.renderers[direction]}
sourceAst={sourceAst}
styles={styles}
theme={props.markdownStyles.theme}
>
{props.text}
</Markdown>
);
});

const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
readonly markdown: string;
readonly markdownStyles: MarkdownStyleSet;
Expand Down Expand Up @@ -673,15 +734,11 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
renderImage={props.renderImage}
/>
) : (
<Markdown
<NitroMarkdownMessage
key={`markdown:${segment.sourceOffset}`}
options={{ gfm: true }}
renderers={props.markdownStyles.renderers}
styles={props.markdownStyles.styles}
theme={props.markdownStyles.theme}
>
{markdown}
</Markdown>
text={markdown}
markdownStyles={props.markdownStyles}
/>
);
});
});
Expand Down Expand Up @@ -751,6 +808,8 @@ function MarkdownCodeBlock(props: {
color: props.textColor,
fontSize: props.fontSize,
lineHeight: props.lineHeight,
textAlign: "left",
writingDirection: "ltr",
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
}}
>
Expand Down Expand Up @@ -949,6 +1008,7 @@ function useMarkdownStyles(
copyTintColor: ColorValue,
preserveSoftBreaks: boolean,
highlightCode: boolean,
direction: TextDirection,
): CustomRenderers => ({
link: ({ children, href = "" }) => {
const presentation = resolveMarkdownLinkPresentation(href);
Expand Down Expand Up @@ -996,7 +1056,7 @@ function useMarkdownStyles(
);
},
list: ({ node, Renderer, ordered = false, start = 1 }) => (
<View className="mt-0.5 mb-2">
<View className="mt-0.5 mb-2" style={{ direction }}>
{node.children?.map((child, index) => {
const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`;
if (child.type === "task_list_item") {
Expand All @@ -1010,11 +1070,12 @@ function useMarkdownStyles(
className="font-sans"
style={{
width: ordered ? 22 : 12,
marginRight: 5,
marginLeft: direction === "rtl" ? 5 : 0,
marginRight: direction === "rtl" ? 0 : 5,
color: inlineTextColor,
fontSize: markdownFontSizes.m,
lineHeight: markdownFontSizes.bodyLineHeight,
textAlign: ordered ? "right" : "center",
textAlign: ordered ? (direction === "rtl" ? "left" : "right") : "center",
Comment thread
ShlomiPorush marked this conversation as resolved.
}}
>
{ordered ? `${start + index}.` : "•"}
Expand Down Expand Up @@ -1044,6 +1105,8 @@ function useMarkdownStyles(
color: inlineCodeTextColor,
fontSize: markdownFontSizes.codeBlockFontSize,
lineHeight: markdownFontSizes.bodyLineHeight,
textAlign: "left",
writingDirection: "ltr",
}}
>
{value}
Expand Down Expand Up @@ -1072,6 +1135,37 @@ function useMarkdownStyles(
),
});

const createDirectionalMarkdownRenderers = (
inlineTextColor: string,
inlineCodeTextColor: string,
blockBackgroundColor: string,
blockTextColor: string,
copyTintColor: ColorValue,
preserveSoftBreaks: boolean,
highlightCode: boolean,
): Readonly<Record<TextDirection, CustomRenderers>> => ({
ltr: createMarkdownRenderers(
inlineTextColor,
inlineCodeTextColor,
blockBackgroundColor,
blockTextColor,
copyTintColor,
preserveSoftBreaks,
highlightCode,
"ltr",
),
rtl: createMarkdownRenderers(
inlineTextColor,
inlineCodeTextColor,
blockBackgroundColor,
blockTextColor,
copyTintColor,
preserveSoftBreaks,
highlightCode,
"rtl",
),
});

const userTheme: PartialMarkdownTheme = {
...baseTheme,
colors: {
Expand Down Expand Up @@ -1121,7 +1215,7 @@ function useMarkdownStyles(
user: {
theme: userTheme,
styles: userStyles,
renderers: createMarkdownRenderers(
renderers: createDirectionalMarkdownRenderers(
markdownUserCodeText,
markdownUserInlineCodeText,
markdownUserFenceBg,
Expand Down Expand Up @@ -1154,7 +1248,7 @@ function useMarkdownStyles(
assistant: {
theme: assistantTheme,
styles: assistantStyles,
renderers: createMarkdownRenderers(
renderers: createDirectionalMarkdownRenderers(
markdownCodeText,
markdownInlineCodeText,
markdownCodeBg,
Expand Down Expand Up @@ -1462,16 +1556,7 @@ function UserMessageContent(props: {
/>
);
}
return (
<Markdown
options={{ gfm: true }}
renderers={props.markdownStyles.renderers}
styles={props.markdownStyles.styles}
theme={props.markdownStyles.theme}
>
{props.text}
</Markdown>
);
return <NitroMarkdownMessage text={props.text} markdownStyles={props.markdownStyles} />;
}

return (
Expand Down Expand Up @@ -1503,15 +1588,11 @@ function UserMessageContent(props: {
renderImage={props.renderImage}
/>
) : (
<Markdown
<NitroMarkdownMessage
key={segment.id}
options={{ gfm: true }}
renderers={props.markdownStyles.renderers}
styles={props.markdownStyles.styles}
theme={props.markdownStyles.theme}
>
{text}
</Markdown>
text={text}
markdownStyles={props.markdownStyles}
/>
);
})}
</View>
Expand Down
75 changes: 75 additions & 0 deletions apps/mobile/src/lib/textDirection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { MarkdownNode } from "react-native-nitro-markdown";
import { describe, expect, it } from "vite-plus/test";

import { resolveMarkdownNodeTextDirection, resolveTextDirection } from "./textDirection";

const documentWith = (...children: MarkdownNode[]): MarkdownNode => ({
type: "document",
children,
});

describe("resolveTextDirection", () => {
it("resolves Hebrew and Arabic text as right-to-left", () => {
expect(resolveTextDirection("הודעה בעברית")).toBe("rtl");
expect(resolveTextDirection("رسالة بالعربية")).toBe("rtl");
});

it("resolves English and other left-to-right scripts as left-to-right", () => {
expect(resolveTextDirection("English message")).toBe("ltr");
expect(resolveTextDirection("日本語のメッセージ")).toBe("ltr");
});

it("ignores neutral prefixes before the first letter", () => {
expect(resolveTextDirection("👋 123... שלום")).toBe("rtl");
expect(resolveTextDirection("(123) Hello")).toBe("ltr");
});

it("defaults neutral-only content to left-to-right", () => {
expect(resolveTextDirection("👋 123...")).toBe("ltr");
});

it("uses parsed prose instead of code content", () => {
expect(
resolveMarkdownNodeTextDirection(
documentWith(
{ type: "code_block", content: "npm test" },
{
type: "paragraph",
children: [
{ type: "code_inline", content: "English inline code" },
{ type: "text", content: " שלום" },
],
},
),
),
).toBe("rtl");

expect(
resolveMarkdownNodeTextDirection(
documentWith({
type: "paragraph",
children: [
{ type: "code_inline", content: "שלום" },
{ type: "text", content: " English prose" },
],
}),
),
).toBe("ltr");
});

it("ignores synthesized GitHub alert markers", () => {
expect(
resolveMarkdownNodeTextDirection(
documentWith({
type: "blockquote",
children: [
{
type: "paragraph",
children: [{ type: "text", content: "[!NOTE] הודעת התראה בעברית." }],
},
],
}),
),
).toBe("rtl");
});
});
32 changes: 32 additions & 0 deletions apps/mobile/src/lib/textDirection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { MarkdownNode } from "react-native-nitro-markdown";

export type TextDirection = "ltr" | "rtl";

const LETTER_CHARACTER = /^\p{Letter}$/u;
const RTL_SCRIPT_CHARACTER =
/^[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufeff\u{10800}-\u{10fff}\u{1e800}-\u{1eeff}]$/u;
const GITHUB_ALERT_MARKER = /\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/giu;

function markdownProse(node: MarkdownNode): string {
if (node.type === "code_inline" || node.type === "code_block" || node.type === "image") {
return "";
}

if (node.type === "text") {
return (node.content ?? "").replace(GITHUB_ALERT_MARKER, "");
}

return node.children?.map(markdownProse).join("") ?? "";
}

export function resolveTextDirection(text: string): TextDirection {
for (const character of text) {
if (!LETTER_CHARACTER.test(character)) continue;
return RTL_SCRIPT_CHARACTER.test(character) ? "rtl" : "ltr";
}
return "ltr";
}

export function resolveMarkdownNodeTextDirection(node: MarkdownNode): TextDirection {
return resolveTextDirection(markdownProse(node));
}
Comment thread
cursor[bot] marked this conversation as resolved.
Loading
Loading