History

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

Dara Adedeji committed 2 months ago

Commit 09d13de1 parent 5f878d210 files · +246 −23Browse files

Unified · Split

10 files
  1. apps/mobile/modules/t3-markdown-text/android/build.gradle +20 −0
  2. apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +93 −0
  3. apps/mobile/modules/t3-markdown-text/expo-module.config.json +6 −0
  4. apps/mobile/modules/t3-markdown-text/package.json +3 −0
  5. apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +12 −5
  6. apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +17 −6
  7. apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +55 −10
  8. apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts +12 −0
  9. apps/mobile/src/native/SelectableMarkdownText.android.tsx +24 −0
  10. pnpm-lock.yaml +4 −2
apps/mobile/modules/t3-markdown-text/android/build.gradle+20−0
@@ -0,0 +1,20 @@
1+apply plugin: 'com.android.library'
2+apply plugin: 'org.jetbrains.kotlin.android'
3+
4+group = 'com.t3tools.markdowntext'
5+version = '0.0.0'
6+
7+android {
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+
17+dependencies {
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+93−0
@@ -0,0 +1,93 @@
1+package expo.modules.t3markdowntext
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.text.Spanned
7+import android.text.style.ReplacementSpan
8+import android.view.ActionMode
9+import android.view.Menu
10+import android.view.MenuItem
11+import android.widget.TextView
12+import com.facebook.react.bridge.ReactContext
13+import com.facebook.react.uimanager.UIManagerHelper
14+import expo.modules.kotlin.modules.Module
15+import expo.modules.kotlin.modules.ModuleDefinition
16+import kotlin.math.max
17+import kotlin.math.min
18+
19+private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"
20+
21+private 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+
38+private 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+
72+class 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+6−0
@@ -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−0
@@ -4,7 +4,9 @@ · 3 unmodified lines
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 @@ · 17 unmodified lines
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 @@
1−import React from "react";
1+import React, { type Ref } from "react";
22 import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native";
33 import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";
44 import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";
@@ -33,6 +33,7 @@ export type ContextMenuActionEvent = { · 28 unmodified lines
3333 * while the React Native Text fallback reports measured `TextLayoutLine`s.
3434 */
3535 export 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"> & { · 6 unmodified lines
4546 onSelectionChange?: (event: SelectionChangeEvent) => void;
4647 };
4748
48−function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) {
49+function 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 · 45 unmodified lines
97103 return <>{nativeChildren}</>;
98104 }
99105
100−function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) {
106+function 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
112118 export 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 @@
11 import { createContext, useContext, useEffect, useState } from "react";
2−import { Image, ScrollView, Text, useColorScheme, View } from "react-native";
2+import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native";
33 import type { MarkdownNode } from "react-native-nitro-markdown/headless";
44
55 import { CopyTextButton } from "./CopyTextButton";
@@ -22,6 +22,11 @@ type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>; · 16 unmodified lines
2222 const highlightedCodeCache = new Map<string, HighlightedCode>();
2323 const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();
2424 const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;
25+const MONO_FONT_FAMILY = Platform.select({
26+ ios: "ui-monospace",
27+ android: "monospace",
28+ default: "monospace",
29+});
2530
2631 function nodeKey(node: MarkdownNode, index: number): string {
2732 return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
@@ -171,7 +176,7 @@ function HighlightedCodeText(props: { · 143 unmodified lines
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: { · 28 unmodified lines
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: { · 5 unmodified lines
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: { · 49 unmodified lines
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: { · 13 unmodified lines
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: { · 30 unmodified lines
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 @@
1−import { createContext, useContext } from "react";
2−import { Image, Linking, type TextStyle, useColorScheme } from "react-native";
1+import { createContext, useCallback, useContext } from "react";
2+import {
3+ findNodeHandle,
4+ Image,
5+ Linking,
6+ Platform,
7+ StyleSheet,
8+ Text as RNText,
9+ type TextStyle,
10+ useColorScheme,
11+} from "react-native";
312
413 import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
514 import { markdownFileIconSource } from "./markdownFileIcons";
@@ -8,6 +17,7 @@ import type { · 2 unmodified lines
817 MarkdownFileContextMenu,
918 NativeMarkdownTextStyle,
1019 } from "./SelectableMarkdownText.types";
20+import { installMarkdownCopySanitizer } from "./T3MarkdownTextSelectionModule";
1121
1222 export interface MarkdownFileContextMenuHandlers {
1323 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
@@ -23,6 +33,19 @@ const EXTERNAL_LINK_PREFIX = "◉ "; · 9 unmodified lines
2333 const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
2434 const SKILL_ICON_PLACEHOLDER = "\uFFFC";
2535 const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;
36+const MONO_FONT_FAMILY = Platform.select({
37+ ios: "ui-monospace",
38+ android: "monospace",
39+ default: "monospace",
40+});
41+const styles = StyleSheet.create({
42+ inlineIcon: {
43+ width: 14,
44+ height: 14,
45+ marginHorizontal: 3,
46+ transform: [{ translateY: 2 }],
47+ },
48+});
2649
2750 function runKeySignature(run: NativeMarkdownTextRun): string {
2851 return [
@@ -102,7 +125,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle · 73 unmodified lines
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: { · 45 unmodified lines
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: { · 2 unmodified lines
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: { · 25 unmodified lines
197236 return (
198237 <MarkdownTextPrimitive
199238 key={appearanceKey}
239+ nativeTextRef={attachAndroidText}
200240 uiTextView
201241 selectable
202242 style={{
@@ -215,11 +255,13 @@ export function NativeMarkdownSelectableText(props: { · 12 unmodified lines
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: { · 14 unmodified lines
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+12−0
@@ -0,0 +1,12 @@
1+import { requireOptionalNativeModule } from "expo";
2+
3+interface T3MarkdownTextSelectionNativeModule {
4+ readonly installCopySanitizer: (reactTag: number) => void;
5+}
6+
7+const nativeModule =
8+ requireOptionalNativeModule<T3MarkdownTextSelectionNativeModule>("T3MarkdownTextSelection");
9+
10+export function installMarkdownCopySanitizer(reactTag: number): void {
11+ nativeModule?.installCopySanitizer(reactTag);
12+}
apps/mobile/src/native/SelectableMarkdownText.android.tsx+24−0
@@ -0,0 +1,24 @@
1+import {
2+ SelectableMarkdownText as T3SelectableMarkdownText,
3+ type SelectableMarkdownTextProps,
4+} from "@t3tools/mobile-markdown-text/renderer";
5+
6+import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter";
7+
8+type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
9+
10+export 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.
18+export function hasNativeSelectableMarkdownText(): boolean {
19+ return true;
20+}
21+
22+export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) {
23+ return <T3SelectableMarkdownText {...props} highlightCode={highlightCodeSnippet} />;
24+}
pnpm-lock.yaml+4−2
@@ -278,7 +278,7 @@ importers: · 277 unmodified lines
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: · 4228 unmodified lines
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: · 9853 unmodified lines
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)