diff --git a/apps/mobile/modules/t3-markdown-text/android/build.gradle b/apps/mobile/modules/t3-markdown-text/android/build.gradle new file mode 100644 index 000000000..13584a00b --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/android/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.markdowntext' +version = '0.0.0' + +android { + namespace 'expo.modules.t3markdowntext' + compileSdk rootProject.ext.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') + implementation 'com.facebook.react:react-android' +} diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt new file mode 100644 index 000000000..af8675831 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -0,0 +1,93 @@ +package expo.modules.t3markdowntext + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.text.Spanned +import android.text.style.ReplacementSpan +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.widget.TextView +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.UIManagerHelper +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import kotlin.math.max +import kotlin.math.min + +private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" + +private fun copyTextWithoutInlineImages( + text: CharSequence, + start: Int, + end: Int +): String { + if (text !is Spanned) return text.subSequence(start, end).toString() + + return buildString { + for (index in start until end) { + val isInlineImage = + text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && + text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() + if (!isInlineImage) append(text[index]) + } + } +} + +private class SanitizingSelectionActionModeCallback( + private val textView: TextView, + private val delegate: ActionMode.Callback? +) : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean = + delegate?.onCreateActionMode(mode, menu) ?: true + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = + delegate?.onPrepareActionMode(mode, menu) ?: false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + if (item.itemId == android.R.id.copy) { + val start = min(textView.selectionStart, textView.selectionEnd) + val end = max(textView.selectionStart, textView.selectionEnd) + if (start >= 0 && end > start) { + val originalText = textView.text.subSequence(start, end).toString() + val selectedText = copyTextWithoutInlineImages(textView.text, start, end) + if (selectedText != originalText) { + val clipboard = + textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(null, selectedText)) + mode.finish() + return true + } + } + } + return delegate?.onActionItemClicked(mode, item) ?: false + } + + override fun onDestroyActionMode(mode: ActionMode) { + delegate?.onDestroyActionMode(mode) + } +} + +class T3MarkdownTextSelectionModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3MarkdownTextSelection") + + Function("installCopySanitizer") { reactTag: Int -> + val reactContext = appContext.reactContext as? ReactContext ?: return@Function + reactContext.runOnUiQueueThread { + val textView = + runCatching { + UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) + } + .getOrNull() as? TextView ?: return@runOnUiQueueThread + val currentCallback = textView.customSelectionActionModeCallback + if (currentCallback is SanitizingSelectionActionModeCallback) { + return@runOnUiQueueThread + } + textView.customSelectionActionModeCallback = + SanitizingSelectionActionModeCallback(textView, currentCallback) + } + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/expo-module.config.json b/apps/mobile/modules/t3-markdown-text/expo-module.config.json new file mode 100644 index 000000000..f41f760ee --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"] + } +} diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index 7ab3f1fbd..1e52d7695 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -4,7 +4,9 @@ "private": true, "source": "./index.ts", "files": [ + "android", "assets", + "expo-module.config.json", "ios", "src", "index.ts", @@ -28,6 +30,7 @@ "peerDependencies": { "@t3tools/client-runtime": "*", "@t3tools/shared": "*", + "expo": "*", "expo-asset": "*", "expo-clipboard": "*", "expo-haptics": "*", diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx index 2cd54b5c1..8e88b118b 100644 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { type Ref } from "react"; import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; @@ -28,7 +28,12 @@ export type ContextMenuActionEvent = { nativeEvent: { target: number; actionIdentifier: string }; }; -export type MarkdownTextPrimitiveProps = TextProps & { +/** + * `onTextLayout` is not offered: the native view reports plain line strings + * while the React Native Text fallback reports measured `TextLayoutLine`s. + */ +export type MarkdownTextPrimitiveProps = Omit & { + nativeTextRef?: Ref; uiTextView?: boolean; contextMenuConfig?: string; onContextMenuAction?: (event: ContextMenuActionEvent) => void; @@ -41,7 +46,12 @@ export type MarkdownTextPrimitiveProps = TextProps & { onSelectionChange?: (event: SelectionChangeEvent) => void; }; -function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) { +function MarkdownTextPrimitiveChild({ + style, + children, + nativeTextRef: _nativeTextRef, + ...rest +}: MarkdownTextPrimitiveProps) { const [isAncestor, rootStyle] = useTextAncestorContext(); // Flatten the styles, and apply the root styles when needed @@ -95,21 +105,22 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr return <>{nativeChildren}; } -function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) { +function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPrimitiveProps) { const [isAncestor] = useTextAncestorContext(); // Even if the uiTextView prop is set, we can still default to using // normal selection (i.e. base RN text) if the text doesn't need to be // selectable if ((!props.selectable || !props.uiTextView) && !isAncestor) { - return ; + return ; } return ; } export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { if (Platform.OS !== "ios") { - return ; + const { nativeTextRef, ...textProps } = props; + return ; } return ; } diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index b0934e873..348a3c489 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect, useState } from "react"; -import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; +import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native"; import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; @@ -22,6 +22,11 @@ type HighlightedCode = ReadonlyArray>; const highlightedCodeCache = new Map(); const highlightedCodePromiseCache = new Map>(); const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; +const MONO_FONT_FAMILY = Platform.select({ + ios: "ui-monospace", + android: "monospace", + default: "monospace", +}); function nodeKey(node: MarkdownNode, index: number): string { return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; @@ -171,7 +176,7 @@ function HighlightedCodeText(props: { selectable style={{ color: props.textStyle.codeColor, - fontFamily: "ui-monospace", + fontFamily: MONO_FONT_FAMILY, fontSize: codeBlockFontSize(props.textStyle), lineHeight: codeBlockLineHeight(props.textStyle), }} @@ -206,7 +211,7 @@ function HighlightedCodeText(props: { selectable style={{ color: props.textStyle.codeColor, - fontFamily: "ui-monospace", + fontFamily: MONO_FONT_FAMILY, fontSize: codeBlockFontSize(props.textStyle), lineHeight: codeBlockLineHeight(props.textStyle), }} @@ -218,7 +223,7 @@ function HighlightedCodeText(props: { key={key} style={{ color: token.color ?? props.textStyle.codeColor, - fontFamily: "ui-monospace", + fontFamily: MONO_FONT_FAMILY, fontStyle: token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal", fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400", @@ -274,7 +279,7 @@ function NativeCodeBlock(props: { style={{ flex: 1, color: props.textStyle.mutedColor, - fontFamily: "ui-monospace", + fontFamily: MONO_FONT_FAMILY, fontSize: codeBlockFontSize(props.textStyle), }} > @@ -294,6 +299,7 @@ function NativeCodeBlock(props: { @@ -330,7 +336,12 @@ function NativeTable(props: { }) { const rows = collectTableRows(props.node); return ( - + MarkdownFileContextMenu | undefined; @@ -23,6 +33,19 @@ const EXTERNAL_LINK_PREFIX = "◉ "; const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; const SKILL_ICON_PLACEHOLDER = "\uFFFC"; const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; +const MONO_FONT_FAMILY = Platform.select({ + ios: "ui-monospace", + android: "monospace", + default: "monospace", +}); +const styles = StyleSheet.create({ + inlineIcon: { + width: 14, + height: 14, + marginHorizontal: 3, + transform: [{ translateY: 2 }], + }, +}); function runKeySignature(run: NativeMarkdownTextRun): string { return [ @@ -102,7 +125,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle isFile || isSkill ? textStyle.boldFontFamily : run.code || isCodeBlock - ? "ui-monospace" + ? MONO_FONT_FAMILY : isHeading ? textStyle.headingFontFamily : run.bold @@ -154,6 +177,19 @@ export function NativeMarkdownSelectableText(props: { }) { const colorScheme = useColorScheme(); const menu = useContext(MarkdownFileContextMenuContext); + const containsInlineFileIcon = props.runs.some((run) => run.fileIcon != null); + const attachAndroidText = useCallback( + (textView: RNText | null) => { + if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) { + return; + } + const reactTag = findNodeHandle(textView); + if (reactTag !== null) { + installMarkdownCopySanitizer(reactTag); + } + }, + [containsInlineFileIcon], + ); const occurrences = new Map(); const prefixedExternalLinks = new Set(); const keyedRuns = props.runs.map((run) => { @@ -162,10 +198,13 @@ export function NativeMarkdownSelectableText(props: { occurrences.set(signature, occurrence + 1); let text = run.text; - if (run.fileIcon) { + if (run.fileIcon && Platform.OS === "ios") { text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } else if (run.skillName && run.skillLabel) { - text = `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`; + text = + Platform.OS === "ios" + ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}` + : `$${run.skillName}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); text = `${EXTERNAL_LINK_PREFIX}${text}`; @@ -197,6 +236,7 @@ export function NativeMarkdownSelectableText(props: { return ( + {Platform.OS === "android" && run.fileIcon ? ( + + ) : null} {text} ); diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts new file mode 100644 index 000000000..4df810abd --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts @@ -0,0 +1,12 @@ +import { requireOptionalNativeModule } from "expo"; + +interface T3MarkdownTextSelectionNativeModule { + readonly installCopySanitizer: (reactTag: number) => void; +} + +const nativeModule = + requireOptionalNativeModule("T3MarkdownTextSelection"); + +export function installMarkdownCopySanitizer(reactTag: number): void { + nativeModule?.installCopySanitizer(reactTag); +} diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index f370401e8..411450b9d 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -6,6 +6,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, View } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import * as Schema from "effect/Schema"; import { KeyboardController, KeyboardEvents, @@ -27,6 +29,7 @@ import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; +import { useServerConfigs } from "../../state/entities"; import { useWorkspaceState } from "../../state/workspace"; import { MAX_TERMINAL_FONT_SIZE, @@ -65,6 +68,13 @@ import { resolveTerminalSessionLabel, type TerminalMenuSession, } from "./terminalMenu"; +import { + hostPlatformFromOs, + resolveModifiedTerminalInput, + type HostPlatform, + type PendingModifier, +} from "./terminalInput"; +import { createTerminalPasteSession } from "./terminalPaste"; import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; const DEFAULT_TERMINAL_COLS = 80; @@ -72,12 +82,19 @@ const DEFAULT_TERMINAL_ROWS = 24; const TERMINAL_ACCESSORY_HEIGHT = 52; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; -type PendingModifier = "ctrl" | "meta"; -type HostPlatform = "mac" | "linux" | "windows" | "unknown"; +class TerminalClipboardReadError extends Schema.TaggedErrorClass()( + "TerminalClipboardReadError", + { terminalId: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Failed to read the clipboard for a paste into terminal ${this.terminalId}.`; + } +} type TerminalToolbarAction = | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string } | { readonly kind: "clear"; readonly key: string; readonly label: string } + | { readonly kind: "paste"; readonly key: string; readonly label: string } | { readonly kind: "modifier"; readonly key: string; @@ -114,28 +131,6 @@ function inferHostPlatform(environmentLabel: string | null): HostPlatform { return "unknown"; } -function applyCtrlModifier(input: string): string { - const firstCharacter = input[0]; - if (!firstCharacter) { - return input; - } - - const lowerCharacter = firstCharacter.toLowerCase(); - if (lowerCharacter >= "a" && lowerCharacter <= "z") { - return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); - } - - if (firstCharacter === "@") return "\u0000"; - if (firstCharacter === "[") return "\u001b"; - if (firstCharacter === "\\") return "\u001c"; - if (firstCharacter === "]") return "\u001d"; - if (firstCharacter === "^") return "\u001e"; - if (firstCharacter === "_") return "\u001f"; - if (firstCharacter === "?") return "\u007f"; - - return input; -} - function pickRunningTerminalSessionForBootstrap( sessions: ReadonlyArray, ): KnownTerminalSession | null { @@ -462,9 +457,17 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); }, [terminal.buffer, terminal.buffer.length, terminalKey]); const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; + const serverConfigs = useServerConfigs(); + const hostOs = + routeEnvironmentId === null + ? null + : (serverConfigs.get(routeEnvironmentId)?.environment.platform.os ?? null); + // The descriptor is authoritative; the label is only a hint until it arrives. const hostPlatform = useMemo( - () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), - [selectedEnvironmentConnection?.environmentLabel], + () => + hostPlatformFromOs(hostOs) ?? + inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), + [hostOs, selectedEnvironmentConnection?.environmentLabel], ); const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme); @@ -488,6 +491,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { kind: "send", key: "esc", label: "esc", data: "\u001b" }, ...modifierActions, { kind: "send", key: "tab", label: "tab", data: "\t" }, + { kind: "paste", key: "paste", label: "paste" }, { kind: "clear", key: "clear", label: "clear" }, { kind: "send", key: "up", label: "↑", data: "\u001b[A" }, { kind: "send", key: "down", label: "↓", data: "\u001b[B" }, @@ -693,13 +697,14 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) setHasMeasuredSurface(true); }, [routeEnvironmentId, routeThreadId, terminalId]); + /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */ const writeInput = useCallback( - (data: string) => { + async (data: string): Promise => { if (!selectedThread || !isRunning) { - return; + return false; } - void writeTerminal({ + const result = await writeTerminal({ environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, @@ -707,27 +712,67 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) data, }, }); + return result._tag === "Success"; }, [isRunning, selectedThread, terminalId, writeTerminal], ); + const pasteSessionRef = useRef | null>(null); + if (pasteSessionRef.current === null) { + pasteSessionRef.current = createTerminalPasteSession(); + } + const pasteSession = pasteSessionRef.current; + + // Drop delayed clipboard reads whenever the route or attached pty changes. + useEffect(() => { + pasteSession.reset(isRunning); + return () => { + pasteSession.reset(false); + }; + }, [isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]); + + const pasteFromClipboard = useCallback(async () => { + await pasteSession.paste({ + readText: Clipboard.getStringAsync, + write: writeInput, + onReadError: (cause) => { + console.error(new TerminalClipboardReadError({ terminalId, cause })); + }, + }); + }, [pasteSession, terminalId, writeInput]); + + /** Sends a key through the armed toolbar modifier, if any, and disarms it. */ + const writeModifiedInput = useCallback( + (data: string) => { + if (pendingModifier === null) { + void writeInput(data); + return; + } + + setPendingModifierState({ terminalId, value: null }); + const resolved = resolveModifiedTerminalInput({ + data, + modifier: pendingModifier, + hostPlatform, + }); + if (resolved.kind === "paste") { + void pasteFromClipboard(); + return; + } + void writeInput(resolved.data); + }, + [hostPlatform, pasteFromClipboard, pendingModifier, terminalId, writeInput], + ); + const handleInput = useCallback( (data: string) => { if (data.length === 0) { return; } - if (pendingModifier === "ctrl") { - setPendingModifierState({ terminalId, value: null }); - writeInput(applyCtrlModifier(data)); - } else if (pendingModifier === "meta") { - setPendingModifierState({ terminalId, value: null }); - writeInput(`\u001b${data}`); - } else { - writeInput(data); - } + writeModifiedInput(data); }, - [pendingModifier, terminalId, writeInput], + [writeModifiedInput], ); const handleResize = useCallback( @@ -1023,16 +1068,15 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) return; } - setPendingModifierState({ terminalId, value: null }); - if (pendingModifier === "ctrl") { - writeInput(applyCtrlModifier(action.data)); - } else if (pendingModifier === "meta") { - writeInput(`\u001b${action.data}`); - } else { - writeInput(action.data); + if (action.kind === "paste") { + setPendingModifierState({ terminalId, value: null }); + void pasteFromClipboard(); + return; } + + writeModifiedInput(action.data); }, - [handleClearTerminal, pendingModifier, terminalId, writeInput], + [handleClearTerminal, pasteFromClipboard, terminalId, writeModifiedInput], ); const handleDismissKeyboard = useCallback(() => { diff --git a/apps/mobile/src/features/terminal/terminalInput.test.ts b/apps/mobile/src/features/terminal/terminalInput.test.ts new file mode 100644 index 000000000..aebc00649 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalInput.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applyCtrlModifier, + chunkTerminalWrite, + encodeTerminalPaste, + hostPlatformFromOs, + resolveModifiedTerminalInput, + TERMINAL_WRITE_MAX_LENGTH, +} from "./terminalInput"; + +const byte = (code: number) => String.fromCharCode(code); +const ESC = byte(0x1b); +const CTRL_C = byte(0x03); +const CTRL_V = byte(0x16); + +describe("applyCtrlModifier", () => { + it("maps letters to control bytes regardless of case", () => { + expect(applyCtrlModifier("c")).toBe(CTRL_C); + expect(applyCtrlModifier("C")).toBe(CTRL_C); + expect(applyCtrlModifier("z")).toBe(byte(0x1a)); + }); + + it("maps the punctuation control keys and leaves the rest untouched", () => { + expect(applyCtrlModifier("[")).toBe(ESC); + expect(applyCtrlModifier("?")).toBe(byte(0x7f)); + expect(applyCtrlModifier("1")).toBe("1"); + expect(applyCtrlModifier("")).toBe(""); + }); +}); + +describe("resolveModifiedTerminalInput", () => { + it("pastes on ctrl+v for windows, linux, and unknown hosts", () => { + for (const hostPlatform of ["windows", "linux", "unknown"] as const) { + expect(resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform })).toEqual({ + kind: "paste", + }); + expect(resolveModifiedTerminalInput({ data: "V", modifier: "ctrl", hostPlatform })).toEqual({ + kind: "paste", + }); + } + }); + + it("keeps alt+v as a meta chord on non-mac hosts", () => { + expect( + resolveModifiedTerminalInput({ data: "v", modifier: "meta", hostPlatform: "windows" }), + ).toEqual({ kind: "write", data: `${ESC}v` }); + }); + + it("pastes on cmd+v and forwards raw ctrl+v on mac hosts", () => { + expect( + resolveModifiedTerminalInput({ data: "v", modifier: "meta", hostPlatform: "mac" }), + ).toEqual({ kind: "paste" }); + expect( + resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform: "mac" }), + ).toEqual({ kind: "write", data: CTRL_V }); + }); + + it("still encodes every other modified key", () => { + expect( + resolveModifiedTerminalInput({ data: "c", modifier: "ctrl", hostPlatform: "windows" }), + ).toEqual({ kind: "write", data: CTRL_C }); + expect( + resolveModifiedTerminalInput({ data: "[A", modifier: "meta", hostPlatform: "linux" }), + ).toEqual({ kind: "write", data: `${ESC}[A` }); + }); +}); + +describe("encodeTerminalPaste", () => { + it("passes single-line text through unchanged", () => { + expect(encodeTerminalPaste("git switch -c fix/paste")).toBe("git switch -c fix/paste"); + expect(encodeTerminalPaste("")).toBe(""); + }); + + it("turns LF and CRLF line breaks into a single carriage return each", () => { + expect(encodeTerminalPaste("one\ntwo\r\nthree\n")).toBe("one\rtwo\rthree\r"); + }); + + it("replaces unsafe control bytes with spaces but keeps tabs", () => { + expect(encodeTerminalPaste(`a${byte(0)}b${ESC}c${byte(0x7f)}d\te`)).toBe("a b c d\te"); + }); + + it("never lets a bracketed-paste end marker reach the shell", () => { + expect(encodeTerminalPaste(`safe${ESC}[201~; rm -rf /\n`)).toBe("safe [201~; rm -rf /\r"); + }); +}); + +describe("chunkTerminalWrite", () => { + it("leaves writes within the wire limit whole", () => { + expect(chunkTerminalWrite("")).toEqual([]); + expect(chunkTerminalWrite("ls")).toEqual(["ls"]); + expect(chunkTerminalWrite("x".repeat(TERMINAL_WRITE_MAX_LENGTH))).toHaveLength(1); + }); + + it("splits oversized writes so every chunk fits the contract", () => { + const chunks = chunkTerminalWrite("y".repeat(TERMINAL_WRITE_MAX_LENGTH * 2 + 5)); + expect(chunks.map((chunk) => chunk.length)).toEqual([ + TERMINAL_WRITE_MAX_LENGTH, + TERMINAL_WRITE_MAX_LENGTH, + 5, + ]); + expect(chunks.join("")).toHaveLength(TERMINAL_WRITE_MAX_LENGTH * 2 + 5); + }); + + it("does not cut a surrogate pair in half at the boundary", () => { + const data = `${"z".repeat(TERMINAL_WRITE_MAX_LENGTH - 1)}😀tail`; + const chunks = chunkTerminalWrite(data); + expect(chunks[0]).toHaveLength(TERMINAL_WRITE_MAX_LENGTH - 1); + expect(chunks[1]).toBe("😀tail"); + expect(chunks.join("")).toBe(data); + }); +}); + +describe("hostPlatformFromOs", () => { + it("maps the descriptor os onto the toolbar layout", () => { + expect(hostPlatformFromOs("darwin")).toBe("mac"); + expect(hostPlatformFromOs("windows")).toBe("windows"); + expect(hostPlatformFromOs("linux")).toBe("linux"); + }); + + it("defers to the caller when the os is unknown or not loaded yet", () => { + expect(hostPlatformFromOs("unknown")).toBeNull(); + expect(hostPlatformFromOs(null)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalInput.ts b/apps/mobile/src/features/terminal/terminalInput.ts new file mode 100644 index 000000000..d7cd60dd7 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalInput.ts @@ -0,0 +1,108 @@ +import type { ExecutionEnvironmentPlatformOs } from "@t3tools/contracts"; + +export type PendingModifier = "ctrl" | "meta"; +export type HostPlatform = "mac" | "linux" | "windows" | "unknown"; + +/** Upper bound of `TerminalWriteInput.data`; longer writes are rejected by the server. */ +export const TERMINAL_WRITE_MAX_LENGTH = 65_536; + +export type ModifiedTerminalInput = + | { readonly kind: "write"; readonly data: string } + | { readonly kind: "paste" }; + +// C0 controls other than tab, LF, and CR, plus DEL. +// eslint-disable-next-line no-control-regex -- Pasted text must not carry raw terminal controls. +const UNSAFE_PASTE_BYTES = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; + +/** + * Encodes a key pressed while the toolbar's one-shot ctrl modifier is armed + * into the control byte a terminal expects. + */ +export function applyCtrlModifier(input: string): string { + const firstCharacter = input[0]; + if (!firstCharacter) { + return input; + } + + const lowerCharacter = firstCharacter.toLowerCase(); + if (lowerCharacter >= "a" && lowerCharacter <= "z") { + return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); + } + + if (firstCharacter === "@") return "\u0000"; + if (firstCharacter === "[") return "\u001b"; + if (firstCharacter === "\\") return "\u001c"; + if (firstCharacter === "]") return "\u001d"; + if (firstCharacter === "^") return "\u001e"; + if (firstCharacter === "_") return "\u001f"; + if (firstCharacter === "?") return "\u007f"; + + return input; +} + +/** + * Resolves what a keypress means once a toolbar modifier is armed. The host's + * paste chord (cmd+v on a macOS host, ctrl+v elsewhere) pastes the device + * clipboard instead of reaching the remote shell as a raw control byte, which + * matches what the web terminal does with the same chord. Forwarding the byte + * is never what a phone user means: PowerShell binds ctrl+v to paste from the + * host machine's clipboard, so the shell inserts whatever the desktop last + * copied rather than the text on the phone. + */ +export function resolveModifiedTerminalInput(input: { + readonly data: string; + readonly modifier: PendingModifier; + readonly hostPlatform: HostPlatform; +}): ModifiedTerminalInput { + const pasteModifier: PendingModifier = input.hostPlatform === "mac" ? "meta" : "ctrl"; + if (input.modifier === pasteModifier && input.data.toLowerCase() === "v") { + return { kind: "paste" }; + } + + return { + kind: "write", + data: input.modifier === "ctrl" ? applyCtrlModifier(input.data) : `\u001b${input.data}`, + }; +} + +/** + * Encodes clipboard text for the remote pty the way the web terminal does when + * bracketed paste is off: unsafe control bytes become spaces (which also + * defuses an embedded bracketed-paste end marker, since its ESC goes too) and + * line breaks become carriage returns, since a bare LF is Ctrl+J to a raw-mode + * TUI. The native mobile surface does not expose DECSET 2004, so mobile never + * wraps a paste in bracketed-paste markers. + */ +export function encodeTerminalPaste(text: string): string { + return text.replace(UNSAFE_PASTE_BYTES, " ").replace(/\r\n|\n/g, "\r"); +} + +/** + * Splits terminal input into writes the wire contract accepts, never cutting + * through a surrogate pair so every chunk stays valid UTF-16. + */ +export function chunkTerminalWrite(data: string): ReadonlyArray { + const chunks: string[] = []; + let start = 0; + while (start < data.length) { + let end = Math.min(start + TERMINAL_WRITE_MAX_LENGTH, data.length); + const last = data.charCodeAt(end - 1); + if (end < data.length && last >= 0xd800 && last <= 0xdbff) { + end -= 1; + } + chunks.push(data.slice(start, end)); + start = end; + } + return chunks; +} + +/** + * Maps the OS reported by the environment descriptor onto the toolbar's host + * layout. Returns null for "unknown" so callers can fall back to a weaker signal. + */ +export function hostPlatformFromOs(os: ExecutionEnvironmentPlatformOs | null): HostPlatform | null { + if (os === "darwin") return "mac"; + if (os === "linux") return "linux"; + if (os === "windows") return "windows"; + return null; +} diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 3e09dc872..2f8ce1377 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -64,6 +64,7 @@ function makeKnownSession(input: { hasRunningSubprocess: false, updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", version: 1, + lifecycleVersion: 1, }, }; } diff --git a/apps/mobile/src/features/terminal/terminalPaste.test.ts b/apps/mobile/src/features/terminal/terminalPaste.test.ts new file mode 100644 index 000000000..ec9bd9d74 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalPaste.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { TERMINAL_WRITE_MAX_LENGTH } from "./terminalInput"; +import { createTerminalPasteSession } from "./terminalPaste"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("terminal paste session", () => { + it("drops a clipboard read when the pty restarts in place", async () => { + const session = createTerminalPasteSession(); + session.reset(true); + const clipboardRead = deferred(); + const writes: string[] = []; + + const paste = session.paste({ + readText: () => clipboardRead.promise, + write: async (data) => { + writes.push(data); + return true; + }, + onReadError: () => undefined, + }); + + session.reset(true); + clipboardRead.resolve("stale"); + await paste; + + expect(writes).toEqual([]); + }); + + it("never overlaps writes from rapid paste requests", async () => { + const session = createTerminalPasteSession(); + session.reset(true); + + const firstWrite = deferred(); + const firstWriteStarted = deferred(); + const writes: string[] = []; + let activeWrites = 0; + let maximumActiveWrites = 0; + const write = async (data: string) => { + writes.push(data); + activeWrites += 1; + maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites); + if (writes.length === 1) { + firstWriteStarted.resolve(); + await firstWrite.promise; + } + activeWrites -= 1; + return true; + }; + + const olderPaste = session.paste({ + readText: async () => "a".repeat(TERMINAL_WRITE_MAX_LENGTH + 1), + write, + onReadError: () => undefined, + }); + await firstWriteStarted.promise; + + const newerPaste = session.paste({ + readText: async () => "newer", + write, + onReadError: () => undefined, + }); + await Promise.resolve(); + + expect(writes.map((chunk) => chunk.length)).toEqual([TERMINAL_WRITE_MAX_LENGTH]); + expect(maximumActiveWrites).toBe(1); + + firstWrite.resolve(true); + await Promise.all([olderPaste, newerPaste]); + + expect(writes.map((chunk) => chunk.length)).toEqual([TERMINAL_WRITE_MAX_LENGTH, 5]); + expect(writes[1]).toBe("newer"); + expect(maximumActiveWrites).toBe(1); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalPaste.ts b/apps/mobile/src/features/terminal/terminalPaste.ts new file mode 100644 index 000000000..3368cd3a5 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalPaste.ts @@ -0,0 +1,60 @@ +import { chunkTerminalWrite, encodeTerminalPaste } from "./terminalInput"; + +interface TerminalPasteInput { + readonly readText: () => Promise; + readonly write: (data: string) => Promise; + readonly onReadError: (cause: unknown) => void; +} + +export interface TerminalPasteSession { + readonly reset: (active: boolean) => void; + readonly paste: (input: TerminalPasteInput) => Promise; +} + +/** Coordinates clipboard reads and writes for the currently attached pty. */ +export function createTerminalPasteSession(): TerminalPasteSession { + let liveTarget: object | null = null; + let latestRequest = 0; + let writeTail: Promise = Promise.resolve(); + + return { + reset(active) { + liveTarget = active ? {} : null; + }, + + async paste({ readText, write, onReadError }) { + const target = liveTarget; + if (target === null) { + return; + } + const request = ++latestRequest; + const isCurrent = () => liveTarget === target && latestRequest === request; + + let text: string; + try { + text = await readText(); + } catch (cause) { + onReadError(cause); + return; + } + + if (!isCurrent()) { + return; + } + + const writePaste = async () => { + for (const chunk of chunkTerminalWrite(encodeTerminalPaste(text))) { + if (!isCurrent() || !(await write(chunk))) { + return; + } + } + }; + const queuedWrite = writeTail.then(writePaste, writePaste); + writeTail = queuedWrite.then( + () => undefined, + () => undefined, + ); + await queuedWrite; + }, + }; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.android.tsx b/apps/mobile/src/native/SelectableMarkdownText.android.tsx new file mode 100644 index 000000000..a59a039cb --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.android.tsx @@ -0,0 +1,24 @@ +import { + SelectableMarkdownText as T3SelectableMarkdownText, + type SelectableMarkdownTextProps, +} from "@t3tools/mobile-markdown-text/renderer"; + +import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + MarkdownImageRequest, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +// The renderer falls back to React Native Text outside iOS, so Android can use +// the same Markdown chunking while retaining native text selection. +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { + return ; +} diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index c1adfbc5d..ce6b3d6c3 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -37,6 +37,14 @@ an over-the-air JavaScript update alone is insufficient. The Android view manage implements the generated identifier setter as a no-op because this behavior is specific to iOS 26 and later. +On iOS 26 the full-screen back swipe is UIKit's `interactiveContentPopGestureRecognizer`, +and react-native-screens makes it wait for any horizontally scrollable ScrollView to +fail first. Upstream applies that to every horizontal ScrollView regardless of +position, so a back swipe on a code block or table that is already at its leading +edge only bounces. The patch narrows the rule to ScrollViews with content still +hidden to the left; at the leading edge they yield to the pop gesture like plain +text does. + After changing a dependency patch, refresh CocoaPods before rebuilding an existing iOS project. pnpm installs each patch hash in a different directory; an old Pods project can keep compiling the previous directory even though Metro diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index d9438860d..98dbec446 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -138,6 +138,44 @@ describe("terminal session reducers", () => { expect(terminalOutputText(output.output)).toBe("lo world"); }); + it("does not advance the lifecycle for the initial attach snapshot", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + + expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); + }); + + it("advances the lifecycle for a live started snapshot", () => { + const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const started = applyTerminalAttachStreamEvent(initial, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, pid: 456 }, + }); + + expect(started).toMatchObject({ status: "running", lifecycleVersion: 1 }); + }); + + it("advances the lifecycle when a running terminal restarts in place", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const restarted = applyTerminalAttachStreamEvent(snapshot, { + type: "restarted", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + snapshot: { ...BASE_SNAPSHOT, pid: 456 }, + }); + + expect(snapshot).toMatchObject({ status: "running", lifecycleVersion: 0 }); + expect(restarted).toMatchObject({ status: "running", lifecycleVersion: 1 }); + }); + it("reduces terminal metadata snapshots, upserts, and removals", () => { const initial = applyTerminalMetadataStreamEvent([], { type: "snapshot", diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index 499ed95eb..b1ef6500d 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -32,6 +32,7 @@ export interface TerminalSessionState { readonly hasRunningSubprocess: boolean; readonly updatedAt: string | null; readonly version: number; + readonly lifecycleVersion: number; } export interface TerminalBufferState { @@ -40,6 +41,7 @@ export interface TerminalBufferState { readonly error: string | null; readonly updatedAt: string | null; readonly version: number; + readonly lifecycleVersion: number; } export interface KnownTerminalSessionTarget { @@ -67,6 +69,7 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ error: null, updatedAt: null, version: 0, + lifecycleVersion: 0, }); export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ @@ -77,6 +80,7 @@ export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze( hasRunningSubprocess: false, updatedAt: null, version: 0, + lifecycleVersion: 0, }); let terminalAttachGeneration = 0; @@ -103,6 +107,7 @@ function terminalBufferStateFromSnapshot( error: null, updatedAt: snapshot.updatedAt, version: current.version + 1, + lifecycleVersion: current.lifecycleVersion, }; } @@ -124,6 +129,7 @@ export function combineTerminalSessionState( hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), version: buffer.version, + lifecycleVersion: buffer.lifecycleVersion, }; } @@ -134,8 +140,16 @@ export function applyTerminalAttachStreamEvent( ): TerminalBufferState { switch (event.type) { case "snapshot": + return { + ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), + lifecycleVersion: + current.version === 0 ? current.lifecycleVersion : current.lifecycleVersion + 1, + }; case "restarted": - return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current); + return { + ...terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes, current), + lifecycleVersion: current.lifecycleVersion + 1, + }; case "output": return { ...current, diff --git a/patches/react-native-screens@4.26.2.patch b/patches/react-native-screens@4.26.2.patch index ff5168e8c..ee017ce19 100644 --- a/patches/react-native-screens@4.26.2.patch +++ b/patches/react-native-screens@4.26.2.patch @@ -1106,6 +1106,55 @@ index 1c844846a5c66e31cfa530ca462774d2fb6bbea1..a60b96b44e85d3f4b29dc0d190b95ab6 [self updateViewControllerIfNeeded]; if (needsNavigationControllerLayout) { +diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm +--- a/ios/RNSScreenStack.mm ++++ b/ios/RNSScreenStack.mm +@@ -1074,6 +1074,15 @@ RNS_IGNORE_SUPER_CALL_END + return scrollView.contentSize.width > scrollView.frame.size.width; + } + ++// Whether a horizontally scrollable ScrollView is resting at its leading edge, so a ++// pan in the back-gesture direction has nothing left to scroll. Checked in the ++// ScrollView's own coordinate space: React Native mirrors the view for RTL layout, ++// which keeps the leading edge at the minimum content offset in both directions. ++- (BOOL)scrollViewIsAtLeadingEdge:(UIScrollView *)scrollView ++{ ++ return scrollView.contentOffset.x <= -scrollView.adjustedContentInset.left + 1.0; ++} ++ + // Custom method for compatibility with iOS < 13.4 + // RNSScreenStackView is a UIGestureRecognizerDelegate for three types of gesture recognizers: + // RNSPanGestureRecognizer, RNSScreenEdgeGestureRecognizer, _UIParallaxTransitionPanGestureRecognizer +@@ -1167,9 +1176,11 @@ RNS_IGNORE_SUPER_CALL_END + if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer && + [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) { + // ScrollView should take precedence when scrolling horizontally (it should be required to fail). +- // However, if it does not allow for horizontal scrolling, there should be no such restriction, ++ // However, if it does not allow for horizontal scrolling, or is already resting at its leading ++ // edge so a back swipe has nothing left to scroll, there should be no such restriction, + // and swiping horizontally should dismiss the screen. +- return [self scrollViewHasHorizontalPart:static_cast(otherGestureRecognizer.view)]; ++ UIScrollView *scrollView = static_cast(otherGestureRecognizer.view); ++ return [self scrollViewHasHorizontalPart:scrollView] && ![self scrollViewIsAtLeadingEdge:scrollView]; + } + } + +@@ -1189,6 +1200,15 @@ RNS_IGNORE_SUPER_CALL_END + // gestureRecognizer:shouldRequireFailureOfGestureRecognizer + isEdgeSwipeGestureRecognizer = + isEdgeSwipeGestureRecognizer && gestureRecognizer != _controller.interactiveContentPopGestureRecognizer; ++ ++ // The exception is a horizontal ScrollView resting at its leading edge: its pan recognizer would ++ // still begin (and merely bounce) on a back swipe, so it must yield to the pop gesture the same ++ // way plain content does. ++ if (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer && ++ [self isScrollViewPanGestureRecognizer:otherGestureRecognizer]) { ++ UIScrollView *scrollView = static_cast(otherGestureRecognizer.view); ++ return [self scrollViewHasHorizontalPart:scrollView] && [self scrollViewIsAtLeadingEdge:scrollView]; ++ } + } + #endif // check for iOS >= 26 + diff --git a/ios/RNSScreenStackHeaderSubview.mm b/ios/RNSScreenStackHeaderSubview.mm index add33c4807e038dcd846f6c72b2fc472ded02809..489afa5fa2da09c1ec6986d1832b58bb595396ae 100644 --- a/ios/RNSScreenStackHeaderSubview.mm diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb1026b6d..04a799a03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,7 +102,7 @@ patchedDependencies: react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-screens@4.26.2: 149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006 + react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90 importers: @@ -257,7 +257,7 @@ importers: version: 7.3.4(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) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(d307537762dff86bcf277a4ec64a11d8) + version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(ad1eff2c3e588b799b6541240bb21d97) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -278,7 +278,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919) + version: file:apps/mobile/modules/t3-markdown-text(eb96cd030e772ab91fe21f812240338a) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -431,7 +431,7 @@ importers: version: 5.7.0(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) react-native-screens: specifier: ~4.26.0 - version: 4.26.2(patch_hash=149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006)(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) + version: 4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(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) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(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) @@ -4868,6 +4868,7 @@ packages: peerDependencies: '@t3tools/client-runtime': '*' '@t3tools/shared': '*' + expo: '*' expo-asset: '*' expo-clipboard: '*' expo-haptics: '*' @@ -14985,7 +14986,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(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) - '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(d307537762dff86bcf277a4ec64a11d8)': + '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(ad1eff2c3e588b799b6541240bb21d97)': dependencies: '@react-navigation/elements': 2.9.26(c10301b6e0c42fc6434d2b643197a81e) '@react-navigation/native': 7.3.4(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) @@ -14993,7 +14994,7 @@ snapshots: react: 19.2.3 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-native-safe-area-context: 5.7.0(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) - react-native-screens: 4.26.2(patch_hash=149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006)(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) + react-native-screens: 4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(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) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -15367,10 +15368,11 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(eb96cd030e772ab91fe21f812240338a)': dependencies: '@t3tools/client-runtime': link:packages/client-runtime '@t3tools/shared': link:packages/shared + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) 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) 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) expo-haptics: 57.0.2(expo@57.0.18) @@ -20923,7 +20925,7 @@ snapshots: react: 19.2.3 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-native-screens@4.26.2(patch_hash=149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006)(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): + react-native-screens@4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(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): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3)