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 @@...@@ -4,7 +4,9 @@
4 "private": true,4 "private": true,
5 "source": "./index.ts",5 "source": "./index.ts",
6 "files": [6 "files": [
7 "android",
7 "assets",8 "assets",
9 "expo-module.config.json",
8 "ios",10 "ios",
9 "src",11 "src",
10 "index.ts",12 "index.ts",
...@@ -28,6 +30,7 @@...@@ -28,6 +30,7 @@
28 "peerDependencies": {30 "peerDependencies": {
29 "@t3tools/client-runtime": "*",31 "@t3tools/client-runtime": "*",
30 "@t3tools/shared": "*",32 "@t3tools/shared": "*",
33 "expo": "*",
31 "expo-asset": "*",34 "expo-asset": "*",
32 "expo-clipboard": "*",35 "expo-clipboard": "*",
33 "expo-haptics": "*",36 "expo-haptics": "*",
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx+12-5
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import React from "react";1import React, { type Ref } from "react";
2import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native";2import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native";
3import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";3import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";
4import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";4import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";
...@@ -33,6 +33,7 @@ export type ContextMenuActionEvent = {...@@ -33,6 +33,7 @@ export type ContextMenuActionEvent = {
33 * while the React Native Text fallback reports measured `TextLayoutLine`s.33 * while the React Native Text fallback reports measured `TextLayoutLine`s.
34 */34 */
35export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {35export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
36 nativeTextRef?: Ref<RNText>;
36 uiTextView?: boolean;37 uiTextView?: boolean;
37 contextMenuConfig?: string;38 contextMenuConfig?: string;
38 onContextMenuAction?: (event: ContextMenuActionEvent) => void;39 onContextMenuAction?: (event: ContextMenuActionEvent) => void;
...@@ -45,7 +46,12 @@ export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {...@@ -45,7 +46,12 @@ export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
45 onSelectionChange?: (event: SelectionChangeEvent) => void;46 onSelectionChange?: (event: SelectionChangeEvent) => void;
46};47};
4748
48function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) {49function MarkdownTextPrimitiveChild({
50 style,
51 children,
52 nativeTextRef: _nativeTextRef,
53 ...rest
54}: MarkdownTextPrimitiveProps) {
49 const [isAncestor, rootStyle] = useTextAncestorContext();55 const [isAncestor, rootStyle] = useTextAncestorContext();
5056
51 // Flatten the styles, and apply the root styles when needed57 // Flatten the styles, and apply the root styles when needed
...@@ -97,21 +103,22 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr...@@ -97,21 +103,22 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr
97 return <>{nativeChildren}</>;103 return <>{nativeChildren}</>;
98}104}
99105
100function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) {106function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPrimitiveProps) {
101 const [isAncestor] = useTextAncestorContext();107 const [isAncestor] = useTextAncestorContext();
102108
103 // Even if the uiTextView prop is set, we can still default to using109 // Even if the uiTextView prop is set, we can still default to using
104 // normal selection (i.e. base RN text) if the text doesn't need to be110 // normal selection (i.e. base RN text) if the text doesn't need to be
105 // selectable111 // selectable
106 if ((!props.selectable || !props.uiTextView) && !isAncestor) {112 if ((!props.selectable || !props.uiTextView) && !isAncestor) {
107 return <RNText {...props} />;113 return <RNText ref={nativeTextRef} {...props} />;
108 }114 }
109 return <MarkdownTextPrimitiveChild {...props} />;115 return <MarkdownTextPrimitiveChild {...props} />;
110}116}
111117
112export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) {118export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) {
113 if (Platform.OS !== "ios") {119 if (Platform.OS !== "ios") {
114 return <RNText {...props} />;120 const { nativeTextRef, ...textProps } = props;
121 return <RNText ref={nativeTextRef} {...textProps} />;
115 }122 }
116 return <MarkdownTextPrimitiveInner {...props} />;123 return <MarkdownTextPrimitiveInner {...props} />;
117}124}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+17-6
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { createContext, useContext, useEffect, useState } from "react";1import { 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";
3import type { MarkdownNode } from "react-native-nitro-markdown/headless";3import type { MarkdownNode } from "react-native-nitro-markdown/headless";
44
5import { CopyTextButton } from "./CopyTextButton";5import { CopyTextButton } from "./CopyTextButton";
...@@ -22,6 +22,11 @@ type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;...@@ -22,6 +22,11 @@ type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
22const highlightedCodeCache = new Map<string, HighlightedCode>();22const highlightedCodeCache = new Map<string, HighlightedCode>();
23const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();23const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();
24const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;24const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;
25const MONO_FONT_FAMILY = Platform.select({
26 ios: "ui-monospace",
27 android: "monospace",
28 default: "monospace",
29});
2530
26function nodeKey(node: MarkdownNode, index: number): string {31function nodeKey(node: MarkdownNode, index: number): string {
27 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;32 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
...@@ -171,7 +176,7 @@ function HighlightedCodeText(props: {...@@ -171,7 +176,7 @@ function HighlightedCodeText(props: {
171 selectable176 selectable
172 style={{177 style={{
173 color: props.textStyle.codeColor,178 color: props.textStyle.codeColor,
174 fontFamily: "ui-monospace",179 fontFamily: MONO_FONT_FAMILY,
175 fontSize: codeBlockFontSize(props.textStyle),180 fontSize: codeBlockFontSize(props.textStyle),
176 lineHeight: codeBlockLineHeight(props.textStyle),181 lineHeight: codeBlockLineHeight(props.textStyle),
177 }}182 }}
...@@ -206,7 +211,7 @@ function HighlightedCodeText(props: {...@@ -206,7 +211,7 @@ function HighlightedCodeText(props: {
206 selectable211 selectable
207 style={{212 style={{
208 color: props.textStyle.codeColor,213 color: props.textStyle.codeColor,
209 fontFamily: "ui-monospace",214 fontFamily: MONO_FONT_FAMILY,
210 fontSize: codeBlockFontSize(props.textStyle),215 fontSize: codeBlockFontSize(props.textStyle),
211 lineHeight: codeBlockLineHeight(props.textStyle),216 lineHeight: codeBlockLineHeight(props.textStyle),
212 }}217 }}
...@@ -218,7 +223,7 @@ function HighlightedCodeText(props: {...@@ -218,7 +223,7 @@ function HighlightedCodeText(props: {
218 key={key}223 key={key}
219 style={{224 style={{
220 color: token.color ?? props.textStyle.codeColor,225 color: token.color ?? props.textStyle.codeColor,
221 fontFamily: "ui-monospace",226 fontFamily: MONO_FONT_FAMILY,
222 fontStyle:227 fontStyle:
223 token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal",228 token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal",
224 fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",229 fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",
...@@ -274,7 +279,7 @@ function NativeCodeBlock(props: {...@@ -274,7 +279,7 @@ function NativeCodeBlock(props: {
274 style={{279 style={{
275 flex: 1,280 flex: 1,
276 color: props.textStyle.mutedColor,281 color: props.textStyle.mutedColor,
277 fontFamily: "ui-monospace",282 fontFamily: MONO_FONT_FAMILY,
278 fontSize: codeBlockFontSize(props.textStyle),283 fontSize: codeBlockFontSize(props.textStyle),
279 }}284 }}
280 >285 >
...@@ -294,6 +299,7 @@ function NativeCodeBlock(props: {...@@ -294,6 +299,7 @@ function NativeCodeBlock(props: {
294 <ScrollView299 <ScrollView
295 horizontal300 horizontal
296 bounces={false}301 bounces={false}
302 nestedScrollEnabled={Platform.OS === "android"}
297 showsHorizontalScrollIndicator={false}303 showsHorizontalScrollIndicator={false}
298 contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}304 contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}
299 >305 >
...@@ -330,7 +336,12 @@ function NativeTable(props: {...@@ -330,7 +336,12 @@ function NativeTable(props: {
330}) {336}) {
331 const rows = collectTableRows(props.node);337 const rows = collectTableRows(props.node);
332 return (338 return (
333 <ScrollView horizontal bounces={false} showsHorizontalScrollIndicator={false}>339 <ScrollView
340 horizontal
341 bounces={false}
342 nestedScrollEnabled={Platform.OS === "android"}
343 showsHorizontalScrollIndicator={false}
344 >
334 <View345 <View
335 style={{346 style={{
336 borderColor: props.textStyle.dividerColor,347 borderColor: props.textStyle.dividerColor,
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+55-10
...@@ -1,5 +1,14 @@...@@ -1,5 +1,14 @@
1import { createContext, useContext } from "react";1import { createContext, useCallback, useContext } from "react";
2import { Image, Linking, type TextStyle, useColorScheme } from "react-native";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
4import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";13import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
5import { markdownFileIconSource } from "./markdownFileIcons";14import { markdownFileIconSource } from "./markdownFileIcons";
...@@ -8,6 +17,7 @@ import type {...@@ -8,6 +17,7 @@ import type {
8 MarkdownFileContextMenu,17 MarkdownFileContextMenu,
9 NativeMarkdownTextStyle,18 NativeMarkdownTextStyle,
10} from "./SelectableMarkdownText.types";19} from "./SelectableMarkdownText.types";
20import { installMarkdownCopySanitizer } from "./T3MarkdownTextSelectionModule";
1121
12export interface MarkdownFileContextMenuHandlers {22export interface MarkdownFileContextMenuHandlers {
13 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;23 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
...@@ -23,6 +33,19 @@ const EXTERNAL_LINK_PREFIX = "◉ ";...@@ -23,6 +33,19 @@ const EXTERNAL_LINK_PREFIX = "◉ ";
23const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";33const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
24const SKILL_ICON_PLACEHOLDER = "\uFFFC";34const SKILL_ICON_PLACEHOLDER = "\uFFFC";
25const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;35const 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
27function runKeySignature(run: NativeMarkdownTextRun): string {50function runKeySignature(run: NativeMarkdownTextRun): string {
28 return [51 return [
...@@ -102,7 +125,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle...@@ -102,7 +125,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle
102 isFile || isSkill125 isFile || isSkill
103 ? textStyle.boldFontFamily126 ? textStyle.boldFontFamily
104 : run.code || isCodeBlock127 : run.code || isCodeBlock
105 ? "ui-monospace"128 ? MONO_FONT_FAMILY
106 : isHeading129 : isHeading
107 ? textStyle.headingFontFamily130 ? textStyle.headingFontFamily
108 : run.bold131 : run.bold
...@@ -154,6 +177,19 @@ export function NativeMarkdownSelectableText(props: {...@@ -154,6 +177,19 @@ export function NativeMarkdownSelectableText(props: {
154}) {177}) {
155 const colorScheme = useColorScheme();178 const colorScheme = useColorScheme();
156 const menu = useContext(MarkdownFileContextMenuContext);179 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 );
157 const occurrences = new Map<string, number>();193 const occurrences = new Map<string, number>();
158 const prefixedExternalLinks = new Set<string>();194 const prefixedExternalLinks = new Set<string>();
159 const keyedRuns = props.runs.map((run) => {195 const keyedRuns = props.runs.map((run) => {
...@@ -162,10 +198,13 @@ export function NativeMarkdownSelectableText(props: {...@@ -162,10 +198,13 @@ export function NativeMarkdownSelectableText(props: {
162 occurrences.set(signature, occurrence + 1);198 occurrences.set(signature, occurrence + 1);
163199
164 let text = run.text;200 let text = run.text;
165 if (run.fileIcon) {201 if (run.fileIcon && Platform.OS === "ios") {
166 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;202 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
167 } else if (run.skillName && run.skillLabel) {203 } 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}`;
169 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {208 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
170 prefixedExternalLinks.add(run.href);209 prefixedExternalLinks.add(run.href);
171 text = `${EXTERNAL_LINK_PREFIX}${text}`;210 text = `${EXTERNAL_LINK_PREFIX}${text}`;
...@@ -197,6 +236,7 @@ export function NativeMarkdownSelectableText(props: {...@@ -197,6 +236,7 @@ export function NativeMarkdownSelectableText(props: {
197 return (236 return (
198 <MarkdownTextPrimitive237 <MarkdownTextPrimitive
199 key={appearanceKey}238 key={appearanceKey}
239 nativeTextRef={attachAndroidText}
200 uiTextView240 uiTextView
201 selectable241 selectable
202 style={{242 style={{
...@@ -215,11 +255,13 @@ export function NativeMarkdownSelectableText(props: {...@@ -215,11 +255,13 @@ export function NativeMarkdownSelectableText(props: {
215 <MarkdownTextPrimitive255 <MarkdownTextPrimitive
216 key={key}256 key={key}
217 nativeID={257 nativeID={
218 run.fileIcon258 Platform.OS === "ios"
219 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`259 ? run.fileIcon
220 : run.skillName260 ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}`
221 ? "t3-skill:sf:cube"261 : run.skillName
222 : undefined262 ? "t3-skill:sf:cube"
263 : undefined
264 : undefined
223 }265 }
224 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}266 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
225 style={runStyle(run, props.textStyle)}267 style={runStyle(run, props.textStyle)}
...@@ -240,6 +282,9 @@ export function NativeMarkdownSelectableText(props: {...@@ -240,6 +282,9 @@ export function NativeMarkdownSelectableText(props: {
240 : undefined282 : undefined
241 }283 }
242 >284 >
285 {Platform.OS === "android" && run.fileIcon ? (
286 <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} />
287 ) : null}
243 {text}288 {text}
244 </MarkdownTextPrimitive>289 </MarkdownTextPrimitive>
245 );290 );
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:...@@ -278,7 +278,7 @@ importers:
278 version: link:../../packages/contracts278 version: link:../../packages/contracts
279 '@t3tools/mobile-markdown-text':279 '@t3tools/mobile-markdown-text':
280 specifier: file:./modules/t3-markdown-text280 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)
282 '@t3tools/mobile-review-diff-native':282 '@t3tools/mobile-review-diff-native':
283 specifier: file:./modules/t3-review-diff283 specifier: file:./modules/t3-review-diff
284 version: file:apps/mobile/modules/t3-review-diff284 version: file:apps/mobile/modules/t3-review-diff
...@@ -4513,6 +4513,7 @@ packages:...@@ -4513,6 +4513,7 @@ packages:
4513 peerDependencies:4513 peerDependencies:
4514 '@t3tools/client-runtime': '*'4514 '@t3tools/client-runtime': '*'
4515 '@t3tools/shared': '*'4515 '@t3tools/shared': '*'
4516 expo: '*'
4516 expo-asset: '*'4517 expo-asset: '*'
4517 expo-clipboard: '*'4518 expo-clipboard: '*'
4518 expo-haptics: '*'4519 expo-haptics: '*'
...@@ -14372,10 +14373,11 @@ snapshots:...@@ -14372,10 +14373,11 @@ snapshots:
14372 dependencies:14373 dependencies:
14373 defer-to-connect: 2.0.114374 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)':
14376 dependencies:14377 dependencies:
14377 '@t3tools/client-runtime': link:packages/client-runtime14378 '@t3tools/client-runtime': link:packages/client-runtime
14378 '@t3tools/shared': link:packages/shared14379 '@t3tools/shared': link:packages/shared
14380 expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc)
14379 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)14381 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)
14380 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)14382 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)
14381 expo-haptics: 57.0.2(expo@57.0.18)14383 expo-haptics: 57.0.2(expo@57.0.18)