authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-04 05:52:07-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 09:52:07+00:00
log09d13de4381925fa2a6dea74eff8185fa301e905
treeee52e1e7c05918e59cb8b1390fd47e93ae3d2e50
parent5f878d2a85807618a4c8571cdef5daa3124672d6
signature Signed by PGP key B5690EEEBB952194

feat(mobile): make chat text selectable on Android (#8779)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

10 files changed, 246 insertions(+), 23 deletions(-)

apps/mobile/modules/t3-markdown-text/android/build.gradle created+20
......@@ -0,0 +1,20 @@
1apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'
3
4group = 'com.t3tools.markdowntext'
5version = '0.0.0'
6
7android {
8 namespace 'expo.modules.t3markdowntext'
9 compileSdk rootProject.ext.compileSdkVersion
10
11 defaultConfig {
12 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion
14 }
15}
16
17dependencies {
18 implementation project(':expo-modules-core')
19 implementation 'com.facebook.react:react-android'
20}
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt created+93
......@@ -0,0 +1,93 @@
1package expo.modules.t3markdowntext
2
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.text.Spanned
7import android.text.style.ReplacementSpan
8import android.view.ActionMode
9import android.view.Menu
10import android.view.MenuItem
11import android.widget.TextView
12import com.facebook.react.bridge.ReactContext
13import com.facebook.react.uimanager.UIManagerHelper
14import expo.modules.kotlin.modules.Module
15import expo.modules.kotlin.modules.ModuleDefinition
16import kotlin.math.max
17import kotlin.math.min
18
19private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"
20
21private fun copyTextWithoutInlineImages(
22 text: CharSequence,
23 start: Int,
24 end: Int
25): String {
26 if (text !is Spanned) return text.subSequence(start, end).toString()
27
28 return buildString {
29 for (index in start until end) {
30 val isInlineImage =
31 text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
32 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
33 if (!isInlineImage) append(text[index])
34 }
35 }
36}
37
38private class SanitizingSelectionActionModeCallback(
39 private val textView: TextView,
40 private val delegate: ActionMode.Callback?
41) : ActionMode.Callback {
42 override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean =
43 delegate?.onCreateActionMode(mode, menu) ?: true
44
45 override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean =
46 delegate?.onPrepareActionMode(mode, menu) ?: false
47
48 override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
49 if (item.itemId == android.R.id.copy) {
50 val start = min(textView.selectionStart, textView.selectionEnd)
51 val end = max(textView.selectionStart, textView.selectionEnd)
52 if (start >= 0 && end > start) {
53 val originalText = textView.text.subSequence(start, end).toString()
54 val selectedText = copyTextWithoutInlineImages(textView.text, start, end)
55 if (selectedText != originalText) {
56 val clipboard =
57 textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
58 clipboard.setPrimaryClip(ClipData.newPlainText(null, selectedText))
59 mode.finish()
60 return true
61 }
62 }
63 }
64 return delegate?.onActionItemClicked(mode, item) ?: false
65 }
66
67 override fun onDestroyActionMode(mode: ActionMode) {
68 delegate?.onDestroyActionMode(mode)
69 }
70}
71
72class T3MarkdownTextSelectionModule : Module() {
73 override fun definition() = ModuleDefinition {
74 Name("T3MarkdownTextSelection")
75
76 Function("installCopySanitizer") { reactTag: Int ->
77 val reactContext = appContext.reactContext as? ReactContext ?: return@Function
78 reactContext.runOnUiQueueThread {
79 val textView =
80 runCatching {
81 UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag)
82 }
83 .getOrNull() as? TextView ?: return@runOnUiQueueThread
84 val currentCallback = textView.customSelectionActionModeCallback
85 if (currentCallback is SanitizingSelectionActionModeCallback) {
86 return@runOnUiQueueThread
87 }
88 textView.customSelectionActionModeCallback =
89 SanitizingSelectionActionModeCallback(textView, currentCallback)
90 }
91 }
92 }
93}
apps/mobile/modules/t3-markdown-text/expo-module.config.json created+6
......@@ -0,0 +1,6 @@
1{
2 "platforms": ["android"],
3 "android": {
4 "modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"]
5 }
6}
apps/mobile/modules/t3-markdown-text/package.json+3
......@@ -4,7 +4,9 @@
44 "private": true,
55 "source": "./index.ts",
66 "files": [
7 "android",
78 "assets",
9 "expo-module.config.json",
810 "ios",
911 "src",
1012 "index.ts",
......@@ -28,6 +30,7 @@
2830 "peerDependencies": {
2931 "@t3tools/client-runtime": "*",
3032 "@t3tools/shared": "*",
33 "expo": "*",
3134 "expo-asset": "*",
3235 "expo-clipboard": "*",
3336 "expo-haptics": "*",
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx+12-5
......@@ -1,4 +1,4 @@
1import React from "react";
1import React, { type Ref } from "react";
22import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native";
33import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";
44import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";
......@@ -33,6 +33,7 @@ export type ContextMenuActionEvent = {
3333 * while the React Native Text fallback reports measured `TextLayoutLine`s.
3434 */
3535export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
36 nativeTextRef?: Ref<RNText>;
3637 uiTextView?: boolean;
3738 contextMenuConfig?: string;
3839 onContextMenuAction?: (event: ContextMenuActionEvent) => void;
......@@ -45,7 +46,12 @@ export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
4546 onSelectionChange?: (event: SelectionChangeEvent) => void;
4647};
4748
48function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) {
49function MarkdownTextPrimitiveChild({
50 style,
51 children,
52 nativeTextRef: _nativeTextRef,
53 ...rest
54}: MarkdownTextPrimitiveProps) {
4955 const [isAncestor, rootStyle] = useTextAncestorContext();
5056
5157 // Flatten the styles, and apply the root styles when needed
......@@ -97,21 +103,22 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr
97103 return <>{nativeChildren}</>;
98104}
99105
100function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) {
106function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPrimitiveProps) {
101107 const [isAncestor] = useTextAncestorContext();
102108
103109 // Even if the uiTextView prop is set, we can still default to using
104110 // normal selection (i.e. base RN text) if the text doesn't need to be
105111 // selectable
106112 if ((!props.selectable || !props.uiTextView) && !isAncestor) {
107 return <RNText {...props} />;
113 return <RNText ref={nativeTextRef} {...props} />;
108114 }
109115 return <MarkdownTextPrimitiveChild {...props} />;
110116}
111117
112118export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) {
113119 if (Platform.OS !== "ios") {
114 return <RNText {...props} />;
120 const { nativeTextRef, ...textProps } = props;
121 return <RNText ref={nativeTextRef} {...textProps} />;
115122 }
116123 return <MarkdownTextPrimitiveInner {...props} />;
117124}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+17-6
......@@ -1,5 +1,5 @@
11import { createContext, useContext, useEffect, useState } from "react";
2import { Image, ScrollView, Text, useColorScheme, View } from "react-native";
2import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native";
33import type { MarkdownNode } from "react-native-nitro-markdown/headless";
44
55import { CopyTextButton } from "./CopyTextButton";
......@@ -22,6 +22,11 @@ type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
2222const highlightedCodeCache = new Map<string, HighlightedCode>();
2323const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();
2424const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;
25const MONO_FONT_FAMILY = Platform.select({
26 ios: "ui-monospace",
27 android: "monospace",
28 default: "monospace",
29});
2530
2631function nodeKey(node: MarkdownNode, index: number): string {
2732 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
......@@ -171,7 +176,7 @@ function HighlightedCodeText(props: {
171176 selectable
172177 style={{
173178 color: props.textStyle.codeColor,
174 fontFamily: "ui-monospace",
179 fontFamily: MONO_FONT_FAMILY,
175180 fontSize: codeBlockFontSize(props.textStyle),
176181 lineHeight: codeBlockLineHeight(props.textStyle),
177182 }}
......@@ -206,7 +211,7 @@ function HighlightedCodeText(props: {
206211 selectable
207212 style={{
208213 color: props.textStyle.codeColor,
209 fontFamily: "ui-monospace",
214 fontFamily: MONO_FONT_FAMILY,
210215 fontSize: codeBlockFontSize(props.textStyle),
211216 lineHeight: codeBlockLineHeight(props.textStyle),
212217 }}
......@@ -218,7 +223,7 @@ function HighlightedCodeText(props: {
218223 key={key}
219224 style={{
220225 color: token.color ?? props.textStyle.codeColor,
221 fontFamily: "ui-monospace",
226 fontFamily: MONO_FONT_FAMILY,
222227 fontStyle:
223228 token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal",
224229 fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",
......@@ -274,7 +279,7 @@ function NativeCodeBlock(props: {
274279 style={{
275280 flex: 1,
276281 color: props.textStyle.mutedColor,
277 fontFamily: "ui-monospace",
282 fontFamily: MONO_FONT_FAMILY,
278283 fontSize: codeBlockFontSize(props.textStyle),
279284 }}
280285 >
......@@ -294,6 +299,7 @@ function NativeCodeBlock(props: {
294299 <ScrollView
295300 horizontal
296301 bounces={false}
302 nestedScrollEnabled={Platform.OS === "android"}
297303 showsHorizontalScrollIndicator={false}
298304 contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}
299305 >
......@@ -330,7 +336,12 @@ function NativeTable(props: {
330336}) {
331337 const rows = collectTableRows(props.node);
332338 return (
333 <ScrollView horizontal bounces={false} showsHorizontalScrollIndicator={false}>
339 <ScrollView
340 horizontal
341 bounces={false}
342 nestedScrollEnabled={Platform.OS === "android"}
343 showsHorizontalScrollIndicator={false}
344 >
334345 <View
335346 style={{
336347 borderColor: props.textStyle.dividerColor,
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+55-10
......@@ -1,5 +1,14 @@
1import { createContext, useContext } from "react";
2import { Image, Linking, type TextStyle, useColorScheme } from "react-native";
1import { createContext, useCallback, useContext } from "react";
2import {
3 findNodeHandle,
4 Image,
5 Linking,
6 Platform,
7 StyleSheet,
8 Text as RNText,
9 type TextStyle,
10 useColorScheme,
11} from "react-native";
312
413import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
514import { markdownFileIconSource } from "./markdownFileIcons";
......@@ -8,6 +17,7 @@ import type {
817 MarkdownFileContextMenu,
918 NativeMarkdownTextStyle,
1019} from "./SelectableMarkdownText.types";
20import { installMarkdownCopySanitizer } from "./T3MarkdownTextSelectionModule";
1121
1222export interface MarkdownFileContextMenuHandlers {
1323 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
......@@ -23,6 +33,19 @@ const EXTERNAL_LINK_PREFIX = "◉ ";
2333const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
2434const SKILL_ICON_PLACEHOLDER = "\uFFFC";
2535const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;
36const MONO_FONT_FAMILY = Platform.select({
37 ios: "ui-monospace",
38 android: "monospace",
39 default: "monospace",
40});
41const styles = StyleSheet.create({
42 inlineIcon: {
43 width: 14,
44 height: 14,
45 marginHorizontal: 3,
46 transform: [{ translateY: 2 }],
47 },
48});
2649
2750function runKeySignature(run: NativeMarkdownTextRun): string {
2851 return [
......@@ -102,7 +125,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
102125 isFile || isSkill
103126 ? textStyle.boldFontFamily
104127 : run.code || isCodeBlock
105 ? "ui-monospace"
128 ? MONO_FONT_FAMILY
106129 : isHeading
107130 ? textStyle.headingFontFamily
108131 : run.bold
......@@ -154,6 +177,19 @@ export function NativeMarkdownSelectableText(props: {
154177}) {
155178 const colorScheme = useColorScheme();
156179 const menu = useContext(MarkdownFileContextMenuContext);
180 const containsInlineFileIcon = props.runs.some((run) => run.fileIcon != null);
181 const attachAndroidText = useCallback(
182 (textView: RNText | null) => {
183 if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) {
184 return;
185 }
186 const reactTag = findNodeHandle(textView);
187 if (reactTag !== null) {
188 installMarkdownCopySanitizer(reactTag);
189 }
190 },
191 [containsInlineFileIcon],
192 );
157193 const occurrences = new Map<string, number>();
158194 const prefixedExternalLinks = new Set<string>();
159195 const keyedRuns = props.runs.map((run) => {
......@@ -162,10 +198,13 @@ export function NativeMarkdownSelectableText(props: {
162198 occurrences.set(signature, occurrence + 1);
163199
164200 let text = run.text;
165 if (run.fileIcon) {
201 if (run.fileIcon && Platform.OS === "ios") {
166202 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
167203 } else if (run.skillName && run.skillLabel) {
168 text = `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`;
204 text =
205 Platform.OS === "ios"
206 ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`
207 : `$${run.skillName}`;
169208 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
170209 prefixedExternalLinks.add(run.href);
171210 text = `${EXTERNAL_LINK_PREFIX}${text}`;
......@@ -197,6 +236,7 @@ export function NativeMarkdownSelectableText(props: {
197236 return (
198237 <MarkdownTextPrimitive
199238 key={appearanceKey}
239 nativeTextRef={attachAndroidText}
200240 uiTextView
201241 selectable
202242 style={{
......@@ -215,11 +255,13 @@ export function NativeMarkdownSelectableText(props: {
215255 <MarkdownTextPrimitive
216256 key={key}
217257 nativeID={
218 run.fileIcon
219 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
220 : run.skillName
221 ? "t3-skill:sf:cube"
222 : undefined
258 Platform.OS === "ios"
259 ? run.fileIcon
260 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
261 : run.skillName
262 ? "t3-skill:sf:cube"
263 : undefined
264 : undefined
223265 }
224266 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
225267 style={runStyle(run, props.textStyle)}
......@@ -240,6 +282,9 @@ export function NativeMarkdownSelectableText(props: {
240282 : undefined
241283 }
242284 >
285 {Platform.OS === "android" && run.fileIcon ? (
286 <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} />
287 ) : null}
243288 {text}
244289 </MarkdownTextPrimitive>
245290 );
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts created+12
......@@ -0,0 +1,12 @@
1import { requireOptionalNativeModule } from "expo";
2
3interface T3MarkdownTextSelectionNativeModule {
4 readonly installCopySanitizer: (reactTag: number) => void;
5}
6
7const nativeModule =
8 requireOptionalNativeModule<T3MarkdownTextSelectionNativeModule>("T3MarkdownTextSelection");
9
10export function installMarkdownCopySanitizer(reactTag: number): void {
11 nativeModule?.installCopySanitizer(reactTag);
12}
apps/mobile/src/native/SelectableMarkdownText.android.tsx created+24
......@@ -0,0 +1,24 @@
1import {
2 SelectableMarkdownText as T3SelectableMarkdownText,
3 type SelectableMarkdownTextProps,
4} from "@t3tools/mobile-markdown-text/renderer";
5
6import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter";
7
8type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
9
10export type {
11 MarkdownImageRequest,
12 NativeMarkdownTextStyle,
13 SelectableMarkdownSkill,
14} from "@t3tools/mobile-markdown-text/types";
15
16// The renderer falls back to React Native Text outside iOS, so Android can use
17// the same Markdown chunking while retaining native text selection.
18export function hasNativeSelectableMarkdownText(): boolean {
19 return true;
20}
21
22export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) {
23 return <T3SelectableMarkdownText {...props} highlightCode={highlightCodeSnippet} />;
24}
pnpm-lock.yaml+4-2
......@@ -278,7 +278,7 @@ importers:
278278 version: link:../../packages/contracts
279279 '@t3tools/mobile-markdown-text':
280280 specifier: file:./modules/t3-markdown-text
281 version: file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919)
281 version: file:apps/mobile/modules/t3-markdown-text(eb96cd030e772ab91fe21f812240338a)
282282 '@t3tools/mobile-review-diff-native':
283283 specifier: file:./modules/t3-review-diff
284284 version: file:apps/mobile/modules/t3-review-diff
......@@ -4513,6 +4513,7 @@ packages:
45134513 peerDependencies:
45144514 '@t3tools/client-runtime': '*'
45154515 '@t3tools/shared': '*'
4516 expo: '*'
45164517 expo-asset: '*'
45174518 expo-clipboard: '*'
45184519 expo-haptics: '*'
......@@ -14372,10 +14373,11 @@ snapshots:
1437214373 dependencies:
1437314374 defer-to-connect: 2.0.1
1437414375
14375 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919)':
14376 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(eb96cd030e772ab91fe21f812240338a)':
1437614377 dependencies:
1437714378 '@t3tools/client-runtime': link:packages/client-runtime
1437814379 '@t3tools/shared': link:packages/shared
14380 expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc)
1437914381 expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
1438014382 expo-clipboard: 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
1438114383 expo-haptics: 57.0.2(expo@57.0.18)