authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-10 13:33:09-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 23:03:09+05:30
log444fd8badd265ddde33babf2a21bf3f527334e98
treed75e2091e3ca686881678860f97c38405e65cf5a
parentd29c56a5c404cb0f58d3b2ac41762fa0d0ac28d4
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep Android markdown icons aligned with text (#11079)


9 files changed, 120 insertions(+), 22 deletions(-)

apps/mobile/modules/t3-markdown-text/android/build.gradle+12
...@@ -8,6 +8,10 @@ android {...@@ -8,6 +8,10 @@ android {
8 namespace 'expo.modules.t3markdowntext'8 namespace 'expo.modules.t3markdowntext'
9 compileSdk rootProject.ext.compileSdkVersion9 compileSdk rootProject.ext.compileSdkVersion
1010
11 testOptions {
12 unitTests.includeAndroidResources = true
13 }
14
11 defaultConfig {15 defaultConfig {
12 minSdkVersion rootProject.ext.minSdkVersion16 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion17 targetSdkVersion rootProject.ext.targetSdkVersion
...@@ -17,4 +21,12 @@ android {...@@ -17,4 +21,12 @@ android {
17dependencies {21dependencies {
18 implementation project(':expo-modules-core')22 implementation project(':expo-modules-core')
19 implementation 'com.facebook.react:react-android'23 implementation 'com.facebook.react:react-android'
24 testImplementation 'junit:junit:4.13.2'
25 testImplementation 'org.robolectric:robolectric:4.16.1'
26}
27
28tasks.withType(Test).configureEach {
29 javaLauncher = javaToolchains.launcherFor {
30 languageVersion = JavaLanguageVersion.of(21)
31 }
20}32}
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt+9-5
...@@ -18,19 +18,23 @@ import kotlin.math.min...@@ -18,19 +18,23 @@ import kotlin.math.min
1818
19private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"19private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"
2020
21private fun copyTextWithoutInlineImages(21internal fun copyTextWithoutInlineImages(
22 text: CharSequence,22 text: CharSequence,
23 start: Int,23 start: Int,
24 end: Int24 end: Int
25): String {25): String {
26 if (text !is Spanned) return text.subSequence(start, end).toString()26 if (text !is Spanned) return text.subSequence(start, end).toString()
2727
28 fun isInlineImage(index: Int): Boolean =
29 index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
30 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
31
28 return buildString {32 return buildString {
29 for (index in start until end) {33 for (index in start until end) {
30 val isInlineImage =34 // The renderer inserts one NBSP after each image to keep its label on the same line.
31 text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&35 // Inspect the original text even when selection starts after the image.
32 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()36 val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1)
33 if (!isInlineImage) append(text[index])37 if (!isInlineImage(index) && !isIconSpacer) append(text[index])
34 }38 }
35 }39 }
36}40}
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt created+48
...@@ -0,0 +1,48 @@
1package expo.modules.t3markdowntext
2
3import android.graphics.drawable.ColorDrawable
4import android.text.SpannableString
5import android.text.Spanned
6import android.text.style.ImageSpan
7import org.junit.Assert.assertEquals
8import org.junit.Test
9import org.junit.runner.RunWith
10import org.robolectric.RobolectricTestRunner
11import org.robolectric.annotation.Config
12
13@RunWith(RobolectricTestRunner::class)
14@Config(sdk = [36], manifest = Config.NONE)
15class MarkdownSelectionCopyTest {
16 private fun withIcon(value: String): SpannableString = SpannableString(value).apply {
17 val index = value.indexOf('\uFFFC')
18 setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
19 }
20
21 @Test
22 fun removesIconAndInjectedSpacer() {
23 val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
24 assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length))
25 }
26
27 @Test
28 fun removesSpacerWhenSelectionStartsAfterIcon() {
29 val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
30 assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12))
31 }
32
33 @Test
34 fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() {
35 val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0")
36 assertEquals(
37 "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0",
38 copyTextWithoutInlineImages(text, 0, text.length)
39 )
40 }
41
42 @Test
43 fun preservesTextWithoutImageSpans() {
44 val text = "\uFFFC\u00A0main.go"
45 assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length))
46 assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length))
47 }
48}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+25-10
...@@ -4,7 +4,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless";...@@ -4,7 +4,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless";
44
5import { CopyTextButton } from "./CopyTextButton";5import { CopyTextButton } from "./CopyTextButton";
6import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";6import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
7import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";7import {
8 nativeMarkdownBlockSpacing,
9 nativeMarkdownDocumentRuns,
10 nativeMarkdownListItemBlocks,
11} from "./nativeMarkdownText";
8import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";12import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
9import type {13import type {
10 MarkdownCodeHighlighter,14 MarkdownCodeHighlighter,
...@@ -595,17 +599,28 @@ export function NativeMarkdownBlock(props: {...@@ -595,17 +599,28 @@ export function NativeMarkdownBlock(props: {
595 switch (props.node.type) {599 switch (props.node.type) {
596 case "document":600 case "document":
597 return (601 return (
598 <View style={{ gap: 8 }}>602 <View>
599 {(props.node.children ?? []).map((child, index) => (603 {(props.node.children ?? []).map((child, index) => (
600 <NativeMarkdownBlock604 <View
601 key={nodeKey(child, index)}605 key={nodeKey(child, index)}
602 node={child}606 style={{
603 skills={props.skills}607 paddingTop:
604 textStyle={props.textStyle}608 Platform.OS === "android"
605 highlightCode={props.highlightCode}609 ? nativeMarkdownBlockSpacing(props.node.children?.[index - 1], child)
606 onLinkPress={props.onLinkPress}610 : index > 0
607 depth={depth}611 ? 8
608 />612 : 0,
613 }}
614 >
615 <NativeMarkdownBlock
616 node={child}
617 skills={props.skills}
618 textStyle={props.textStyle}
619 highlightCode={props.highlightCode}
620 onLinkPress={props.onLinkPress}
621 depth={depth}
622 />
623 </View>
609 ))}624 ))}
610 </View>625 </View>
611 );626 );
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+4
...@@ -222,6 +222,10 @@ export function NativeMarkdownSelectableText(props: {...@@ -222,6 +222,10 @@ export function NativeMarkdownSelectableText(props: {
222 }222 }
223 }223 }
224224
225 if (Platform.OS === "android" && (run.fileIcon || linkIcon)) {
226 text = `\u00A0${text}`;
227 }
228
225 return { key: `${signature}:${occurrence}`, run, text, linkIcon };229 return { key: `${signature}:${occurrence}`, run, text, linkIcon };
226 });230 });
227 // T3MarkdownText only rebuilds its attributed string during native layout. A231 // T3MarkdownText only rebuilds its attributed string during native layout. A
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+4-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2import { View } from "react-native";2import { Platform, View } from "react-native";
3import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";3import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
44
5import {5import {
...@@ -84,8 +84,10 @@ export function SelectableMarkdownText({...@@ -84,8 +84,10 @@ export function SelectableMarkdownText({
84 the resulting single-line width instead of reflowing it. */}84 the resulting single-line width instead of reflowing it. */}
85 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>85 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
86 {chunks.map((chunk, index) => {86 {chunks.map((chunk, index) => {
87 // Android inline images drift when one Text mixes paragraph and list
88 // spacer line heights. Keep those layouts in separate native blocks.
87 const content =89 const content =
88 chunk.kind === "rich" ? (90 chunk.kind === "rich" || Platform.OS === "android" ? (
89 <NativeMarkdownBlock91 <NativeMarkdownBlock
90 node={chunk.node}92 node={chunk.node}
91 skills={skills}93 skills={skills}
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+9-5
...@@ -589,6 +589,14 @@ function appendTable(...@@ -589,6 +589,14 @@ function appendTable(
589 return runs;589 return runs;
590}590}
591591
592export function nativeMarkdownBlockSpacing(
593 previous: MarkdownNode | undefined,
594 current: MarkdownNode,
595): number {
596 if (!previous) return 0;
597 return current.type === "heading" ? 20 : previous.type === "heading" ? 10 : 12;
598}
599
592function appendDocumentBlock(600function appendDocumentBlock(
593 runs: NativeMarkdownTextRun[],601 runs: NativeMarkdownTextRun[],
594 node: MarkdownNode,602 node: MarkdownNode,
...@@ -599,11 +607,7 @@ function appendDocumentBlock(...@@ -599,11 +607,7 @@ function appendDocumentBlock(
599 const children = node.children ?? [];607 const children = node.children ?? [];
600 for (const [index, child] of children.entries()) {608 for (const [index, child] of children.entries()) {
601 if (index > 0) {609 if (index > 0) {
602 const previous = children[index - 1];610 appendSpacer(runs, nativeMarkdownBlockSpacing(children[index - 1], child));
603 appendSpacer(
604 runs,
605 child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12,
606 );
607 }611 }
608 appendDocumentBlock(runs, child, depth);612 appendDocumentBlock(runs, child, depth);
609 }613 }
apps/mobile/src/lib/nativeMarkdownText.test.ts+6
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
2import type { MarkdownNode } from "react-native-nitro-markdown/headless";2import type { MarkdownNode } from "react-native-nitro-markdown/headless";
33
4import {4import {
5 nativeMarkdownBlockSpacing,
5 nativeMarkdownChunkSpacing,6 nativeMarkdownChunkSpacing,
6 nativeMarkdownDocumentChunks,7 nativeMarkdownDocumentChunks,
7 nativeMarkdownDocumentRuns,8 nativeMarkdownDocumentRuns,
...@@ -381,6 +382,11 @@ describe("nativeMarkdownDocumentRuns", () => {...@@ -381,6 +382,11 @@ describe("nativeMarkdownDocumentRuns", () => {
381 .filter((run) => run.role === "spacer")382 .filter((run) => run.role === "spacer")
382 .map((run) => run.spacing),383 .map((run) => run.spacing),
383 ).toEqual([20, 10, 12]);384 ).toEqual([20, 10, 12]);
385 expect(
386 node.children?.map((child, index) =>
387 nativeMarkdownBlockSpacing(node.children?.[index - 1], child),
388 ),
389 ).toEqual([0, 20, 10, 12]);
384 });390 });
385391
386 it("renders tight list items whose inline nodes are direct children", () => {392 it("renders tight list items whose inline nodes are direct children", () => {
docs/user/composer.md+3
...@@ -6,6 +6,9 @@ include a skill when the task needs more context....@@ -6,6 +6,9 @@ include a skill when the task needs more context.
6Messages can contain up to 120,000 characters. Longer drafts stay in the composer6Messages can contain up to 120,000 characters. Longer drafts stay in the composer
7so you can shorten them or split them into several messages.7so you can shorten them or split them into several messages.
88
9On Android, long-press message text to select within a paragraph or list item.
10Use the message’s copy button to copy the whole message.
11
9## Attach files12## Attach files
1013
11Attach up to eight files per message. Images can be up to 10 MB; other files can14Attach up to eight files per message. Images can be up to 10 MB; other files can