diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png b/apps/mobile/assets/file-icons/pierre_agents.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png rename to apps/mobile/assets/file-icons/pierre_agents.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png b/apps/mobile/assets/file-icons/pierre_astro.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png rename to apps/mobile/assets/file-icons/pierre_astro.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png b/apps/mobile/assets/file-icons/pierre_babel.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png rename to apps/mobile/assets/file-icons/pierre_babel.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png b/apps/mobile/assets/file-icons/pierre_bash.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png rename to apps/mobile/assets/file-icons/pierre_bash.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png b/apps/mobile/assets/file-icons/pierre_biome.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png rename to apps/mobile/assets/file-icons/pierre_biome.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png b/apps/mobile/assets/file-icons/pierre_bootstrap.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png rename to apps/mobile/assets/file-icons/pierre_bootstrap.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png b/apps/mobile/assets/file-icons/pierre_browserslist.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png rename to apps/mobile/assets/file-icons/pierre_browserslist.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png b/apps/mobile/assets/file-icons/pierre_bun.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png rename to apps/mobile/assets/file-icons/pierre_bun.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png b/apps/mobile/assets/file-icons/pierre_c.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png rename to apps/mobile/assets/file-icons/pierre_c.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png b/apps/mobile/assets/file-icons/pierre_claude.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png rename to apps/mobile/assets/file-icons/pierre_claude.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png b/apps/mobile/assets/file-icons/pierre_cpp.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png rename to apps/mobile/assets/file-icons/pierre_cpp.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png b/apps/mobile/assets/file-icons/pierre_css.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png rename to apps/mobile/assets/file-icons/pierre_css.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png b/apps/mobile/assets/file-icons/pierre_database.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png rename to apps/mobile/assets/file-icons/pierre_database.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png b/apps/mobile/assets/file-icons/pierre_default.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png rename to apps/mobile/assets/file-icons/pierre_default.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png b/apps/mobile/assets/file-icons/pierre_docker.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png rename to apps/mobile/assets/file-icons/pierre_docker.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png b/apps/mobile/assets/file-icons/pierre_eslint.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png rename to apps/mobile/assets/file-icons/pierre_eslint.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png b/apps/mobile/assets/file-icons/pierre_font.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png rename to apps/mobile/assets/file-icons/pierre_font.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png b/apps/mobile/assets/file-icons/pierre_git.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png rename to apps/mobile/assets/file-icons/pierre_git.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png b/apps/mobile/assets/file-icons/pierre_go.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png rename to apps/mobile/assets/file-icons/pierre_go.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png b/apps/mobile/assets/file-icons/pierre_graphql.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png rename to apps/mobile/assets/file-icons/pierre_graphql.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png b/apps/mobile/assets/file-icons/pierre_html.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png rename to apps/mobile/assets/file-icons/pierre_html.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png b/apps/mobile/assets/file-icons/pierre_image.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png rename to apps/mobile/assets/file-icons/pierre_image.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png b/apps/mobile/assets/file-icons/pierre_javascript.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png rename to apps/mobile/assets/file-icons/pierre_javascript.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png b/apps/mobile/assets/file-icons/pierre_json.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png rename to apps/mobile/assets/file-icons/pierre_json.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png b/apps/mobile/assets/file-icons/pierre_markdown.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png rename to apps/mobile/assets/file-icons/pierre_markdown.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png b/apps/mobile/assets/file-icons/pierre_mcp.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png rename to apps/mobile/assets/file-icons/pierre_mcp.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png b/apps/mobile/assets/file-icons/pierre_nextjs.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png rename to apps/mobile/assets/file-icons/pierre_nextjs.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png b/apps/mobile/assets/file-icons/pierre_npm.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png rename to apps/mobile/assets/file-icons/pierre_npm.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png b/apps/mobile/assets/file-icons/pierre_oxc.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png rename to apps/mobile/assets/file-icons/pierre_oxc.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png b/apps/mobile/assets/file-icons/pierre_pnpm.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png rename to apps/mobile/assets/file-icons/pierre_pnpm.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png b/apps/mobile/assets/file-icons/pierre_postcss.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png rename to apps/mobile/assets/file-icons/pierre_postcss.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png b/apps/mobile/assets/file-icons/pierre_prettier.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png rename to apps/mobile/assets/file-icons/pierre_prettier.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png b/apps/mobile/assets/file-icons/pierre_python.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png rename to apps/mobile/assets/file-icons/pierre_python.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png b/apps/mobile/assets/file-icons/pierre_react.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png rename to apps/mobile/assets/file-icons/pierre_react.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png b/apps/mobile/assets/file-icons/pierre_ruby.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png rename to apps/mobile/assets/file-icons/pierre_ruby.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png b/apps/mobile/assets/file-icons/pierre_rust.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png rename to apps/mobile/assets/file-icons/pierre_rust.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png b/apps/mobile/assets/file-icons/pierre_sass.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png rename to apps/mobile/assets/file-icons/pierre_sass.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png b/apps/mobile/assets/file-icons/pierre_stylelint.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png rename to apps/mobile/assets/file-icons/pierre_stylelint.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png b/apps/mobile/assets/file-icons/pierre_svelte.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png rename to apps/mobile/assets/file-icons/pierre_svelte.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png b/apps/mobile/assets/file-icons/pierre_svg.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png rename to apps/mobile/assets/file-icons/pierre_svg.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png b/apps/mobile/assets/file-icons/pierre_svgo.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png rename to apps/mobile/assets/file-icons/pierre_svgo.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png b/apps/mobile/assets/file-icons/pierre_swift.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png rename to apps/mobile/assets/file-icons/pierre_swift.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png b/apps/mobile/assets/file-icons/pierre_table.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png rename to apps/mobile/assets/file-icons/pierre_table.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png b/apps/mobile/assets/file-icons/pierre_tailwind.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png rename to apps/mobile/assets/file-icons/pierre_tailwind.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png b/apps/mobile/assets/file-icons/pierre_terraform.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png rename to apps/mobile/assets/file-icons/pierre_terraform.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png b/apps/mobile/assets/file-icons/pierre_text.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png rename to apps/mobile/assets/file-icons/pierre_text.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png b/apps/mobile/assets/file-icons/pierre_typescript.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png rename to apps/mobile/assets/file-icons/pierre_typescript.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png b/apps/mobile/assets/file-icons/pierre_video.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png rename to apps/mobile/assets/file-icons/pierre_video.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png b/apps/mobile/assets/file-icons/pierre_vite.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png rename to apps/mobile/assets/file-icons/pierre_vite.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png b/apps/mobile/assets/file-icons/pierre_vscode.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png rename to apps/mobile/assets/file-icons/pierre_vscode.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png b/apps/mobile/assets/file-icons/pierre_vue.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png rename to apps/mobile/assets/file-icons/pierre_vue.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png b/apps/mobile/assets/file-icons/pierre_wasm.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png rename to apps/mobile/assets/file-icons/pierre_wasm.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png b/apps/mobile/assets/file-icons/pierre_webpack.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png rename to apps/mobile/assets/file-icons/pierre_webpack.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png b/apps/mobile/assets/file-icons/pierre_yml.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png rename to apps/mobile/assets/file-icons/pierre_yml.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png b/apps/mobile/assets/file-icons/pierre_zig.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png rename to apps/mobile/assets/file-icons/pierre_zig.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png b/apps/mobile/assets/file-icons/pierre_zip.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png rename to apps/mobile/assets/file-icons/pierre_zip.png diff --git a/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png b/apps/mobile/assets/link-icons/github.png similarity index 100% rename from apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png rename to apps/mobile/assets/link-icons/github.png diff --git a/apps/mobile/deps/README.md b/apps/mobile/deps/README.md new file mode 100644 index 000000000000..da0daf4bbd1d --- /dev/null +++ b/apps/mobile/deps/README.md @@ -0,0 +1,22 @@ +# Enriched Markdown prototype package + +The tarball contains the prepared `react-native-enriched-markdown` package based on +upstream [bb2b0942](https://github.com/software-mansion/enriched-markdown/commit/bb2b0942a55555463fe129899d9c922038f10c41), +including the prototype's generic API extensions and native fixes. It replaces +the original pnpm patch and includes opt-in per-link context menus on iOS 17+. +JS, TypeScript declarations, generated native bindings, native sources, and the +MIT license are included. Enriched's postinstall restores its grammar dependencies +as usual. T3 supplies menu titles, actions and callbacks in JS through +`linkContextMenus`; the native package contains no app-specific menu actions. + +To inspect the package, extract it with `tar -xzf `. The resulting +`package/` directory contains the sources as well as the built outputs. + +To replace it, build and prepare the updated Enriched package for npm publication, +then run `npm pack --ignore-scripts --pack-destination ` from that +prepared package directory. Do not pack an installed copy after postinstall has +downloaded the grammar sources. Use a new filename for each rebuilt package, +update the mobile dependency path, and run `vp install` to refresh the lockfile's +integrity hash and the matching `allowBuilds` entry in `pnpm-workspace.yaml`. + +Switch back to the published dependency once the required upstream APIs ship. diff --git a/apps/mobile/deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz b/apps/mobile/deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz new file mode 100644 index 000000000000..0cb97a439222 Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz differ diff --git a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz b/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz deleted file mode 100644 index 8fafc3896813..000000000000 Binary files a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz and /dev/null differ diff --git a/apps/mobile/modules/t3-composer-editor/android/build.gradle b/apps/mobile/modules/t3-composer-editor/android/build.gradle index 0a3e7ed7e17b..9425c8f7de36 100644 --- a/apps/mobile/modules/t3-composer-editor/android/build.gradle +++ b/apps/mobile/modules/t3-composer-editor/android/build.gradle @@ -20,7 +20,6 @@ android { dependencies { implementation project(':expo-modules-core') - implementation project(':t3tools-mobile-markdown-text') testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.16.1' } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 35d796b7876f..d5cb5030987f 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -29,7 +29,6 @@ import android.widget.EditText import expo.modules.kotlin.AppContext import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.views.ExpoView -import expo.modules.t3markdowntext.T3ContextChip import org.json.JSONObject import kotlin.math.max diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt similarity index 98% rename from apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt rename to apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt index df0dd88724e0..0c6757a588a1 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt @@ -1,4 +1,4 @@ -package expo.modules.t3markdowntext +package expo.modules.t3composereditor import android.graphics.Canvas import android.graphics.Color @@ -11,7 +11,7 @@ import android.text.TextUtils import kotlin.math.ceil import kotlin.math.min -/** Shared by editable spans and inline chat images so their metrics and colors agree. */ +/** Draws the context chips embedded in the composer. */ class T3ContextChip( content: Content, fontSize: Float, diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 50ac2afbcb46..324eed704319 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -928,8 +928,6 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro iconImage: UIImage?, style: ComposerChipStyle ) -> UIImage { - // Kept in step with `T3ContextChipVectorIcon` in the markdown module: a chip drawn here and - // the same chip drawn in a sent message have to be the same picture. let chipFontSize = fontSize * 0.86 let font = UIFont(name: "DMSans-Medium", size: chipFontSize) ?? UIFont.systemFont(ofSize: chipFontSize, weight: .medium) diff --git a/apps/mobile/modules/t3-markdown-text/android/build.gradle b/apps/mobile/modules/t3-markdown-text/android/build.gradle deleted file mode 100644 index 5b7e372006f3..000000000000 --- a/apps/mobile/modules/t3-markdown-text/android/build.gradle +++ /dev/null @@ -1,32 +0,0 @@ -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 - - testOptions { - unitTests.includeAndroidResources = true - } - - defaultConfig { - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - } -} - -dependencies { - implementation project(':expo-modules-core') - implementation 'com.facebook.react:react-android' - testImplementation 'junit:junit:4.13.2' - testImplementation 'org.robolectric:robolectric:4.16.1' -} - -tasks.withType(Test).configureEach { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(21) - } -} 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 deleted file mode 100644 index bca3c6430304..000000000000 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ /dev/null @@ -1,302 +0,0 @@ -package expo.modules.t3markdowntext - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Typeface -import android.os.Build -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.Spanned -import android.text.TextPaint -import android.text.style.ReplacementSpan -import android.view.ActionMode -import android.view.Menu -import android.view.MenuItem -import android.widget.TextView -import android.util.Base64 -import android.util.LruCache -import com.facebook.react.bridge.ReactContext -import com.facebook.react.common.assets.ReactFontManager -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 -import org.json.JSONObject -import org.json.JSONArray -import java.net.URLEncoder -import java.io.ByteArrayOutputStream -import kotlin.math.ceil - -private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" - -// Match React Native's measurement buffer. Android orders tied line-height -// spans differently in SpannableString, shifting inline images once RN's -// span priorities are exhausted. -private object MarkdownSpannableFactory : Spannable.Factory() { - override fun newSpannable(source: CharSequence): Spannable = - SpannableStringBuilder(source) -} - -internal fun copyTextWithoutInlineImages( - text: CharSequence, - start: Int, - end: Int -): String { - if (text !is Spanned) return text.subSequence(start, end).toString() - - fun isInlineImage(index: Int): Boolean = - index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && - text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() - - return buildString { - for (index in start until end) { - // The renderer inserts one NBSP after each image to keep its label on the same line. - // Inspect the original text even when selection starts after the image. - val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1) - if (!isInlineImage(index) && !isIconSpacer) append(text[index]) - } - } -} - -private fun canonicalSelection( - originalText: String, - start: Int, - end: Int, - ranges: JSONArray? -): String? { - if (ranges == null) return null - val canonical = StringBuilder(originalText) - var hasContext = false - for (index in ranges.length() - 1 downTo 0) { - val range = ranges.optJSONObject(index) ?: continue - val first = max(start, range.optInt("start")) - val last = min(end, range.optInt("end")) - if (last > first) { - canonical.replace(first - start, last - start, range.optString("text")) - hasContext = true - } - } - return if (hasContext) canonical.toString().replace(OBJECT_REPLACEMENT_CHARACTER, "") else null -} - -private fun selectedContextRecords(records: JSONArray, selectedText: String): JSONArray { - val ids = mutableSetOf() - for (index in 0 until records.length()) { - val record = records.getJSONObject(index) - if (selectedText.contains("/${record.optString("contextId")})")) { - ids.add(record.optString("contextId")) - if (record.has("screenshotContextId")) ids.add(record.getString("screenshotContextId")) - } - } - val copied = JSONArray() - for (index in 0 until records.length()) { - val record = records.getJSONObject(index) - if (ids.contains(record.optString("contextId"))) copied.put(record) - } - return copied -} - -private fun contextClipData(selectedText: String, fragment: String): ClipData { - val payload = runCatching { JSONObject(fragment) }.getOrNull() - val records = payload?.optJSONArray("records") - val copied = records?.let { selectedContextRecords(it, selectedText) } - if (payload == null || copied == null || copied.length() == 0) { - return ClipData.newPlainText(null, selectedText) - } - payload.put("records", copied) - val attribute = URLEncoder.encode(payload.toString(), "UTF-8").replace("+", "%20") - val escaped = selectedText.replace("&", "&").replace("<", "<").replace(">", ">") - return ClipData.newHtmlText( - null, - selectedText, - "
$escaped
" - ) -} - -private class SanitizingSelectionActionModeCallback( - private val textView: TextView, - private val delegate: ActionMode.Callback?, - var contextClipboardConfig: String -) : 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 && copySelection()) { - mode.finish() - return true - } - return delegate?.onActionItemClicked(mode, item) ?: false - } - - private fun copySelection(): Boolean { - val start = min(textView.selectionStart, textView.selectionEnd) - val end = max(textView.selectionStart, textView.selectionEnd) - if (start < 0 || end <= start) return false - val originalText = textView.text.subSequence(start, end).toString() - val config = runCatching { JSONObject(contextClipboardConfig) }.getOrNull() - val canonical = canonicalSelection(originalText, start, end, config?.optJSONArray("ranges")) - val selectedText = canonical ?: copyTextWithoutInlineImages(textView.text, start, end) - val handled = canonical != null || selectedText != originalText - if (handled) { - val clipboard = - textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = if (canonical != null) { - contextClipData(selectedText, config?.optString("fragment") ?: "") - } else { - ClipData.newPlainText(null, selectedText) - } - clipboard.setPrimaryClip(clip) - } - return handled - } - - override fun onDestroyActionMode(mode: ActionMode) { - delegate?.onDestroyActionMode(mode) - } -} - -internal fun applySelectionHandleColor(textView: TextView, color: Int) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return - textView.textSelectHandle?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandle) - textView.textSelectHandleLeft?.mutate()?.apply { - setTint(color) - }?.let(textView::setTextSelectHandleLeft) - textView.textSelectHandleRight?.mutate()?.apply { - setTint(color) - }?.let(textView::setTextSelectHandleRight) -} - -class T3MarkdownTextSelectionModule : Module() { - private val chipImages = LruCache>(128) - - /** - * Metrics of the paragraph font a chip sits in, so the inline box it reports can be sized - * from the same ascent and descent the surrounding text lays out with. - */ - private fun paragraphFontMetrics(text: JSONObject?, scale: Float): Paint.FontMetricsInt = - TextPaint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = (text?.optDouble("fontSize", 15.0)?.toFloat() ?: 15f).coerceIn(6f, 80f) * scale - val fontFamily = text?.optString("fontFamily").orEmpty() - typeface = if (fontFamily.isEmpty()) { - Typeface.DEFAULT - } else { - ReactFontManager.getInstance() - .getTypeface(fontFamily, Typeface.NORMAL, appContext.reactContext?.assets) - } - }.fontMetricsInt - - private fun setSelectionHandleColor(reactTag: Int, color: Int) { - val reactContext = appContext.reactContext as? ReactContext ?: return - reactContext.runOnUiQueueThread { - val textView = runCatching { - UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) - }.getOrNull() as? TextView ?: return@runOnUiQueueThread - applySelectionHandleColor(textView, color) - } - } - - override fun definition() = ModuleDefinition { - Name("T3MarkdownTextSelection") - - Function("setSelectionHandleColor") { reactTag: Int, color: Int -> - setSelectionHandleColor(reactTag, color) - } - - Function("renderContextChip") { payloadJson: String -> - val resources = appContext.reactContext?.resources ?: return@Function null - val metrics = resources.displayMetrics - val fontScale = resources.configuration.fontScale - val key = "${metrics.density}:$fontScale:${metrics.widthPixels}:$payloadJson" - chipImages.get(key)?.let { return@Function it } - val payload = JSONObject(payloadJson) - val chip = T3ContextChip( - content = T3ContextChip.Content( - label = payload.optString("label").take(4096), - symbol = payload.optString("symbol", "doc") - ), - // The line box around the chip is measured with `fontScale` below, so the chip has to - // carry it too, or it shrinks against the words beside it at a larger text size. - fontSize = - payload.optDouble("fontSize", 12.0).toFloat().coerceIn(10f, 40f) * - metrics.density * fontScale, - colors = T3ContextChip.Colors( - accent = T3ContextChip.color(payload.optString("accent"), Color.GRAY), - foreground = T3ContextChip.color(payload.optString("foreground"), Color.BLACK), - border = T3ContextChip.color(payload.optString("border"), Color.GRAY) - ), - maximumWidth = (metrics.widthPixels - 80 * metrics.density).coerceAtLeast(100f), - density = metrics.density, - ) - // toInt() truncates, so a fractional pixel of the chip would fall outside the bitmap - // and take the right-hand border with it. Round up: a spare column costs nothing. - // One spare pixel of transparency on each side. Whatever rounding happens between the - // bitmap's pixels and the box's dp then falls on padding instead of on the border. - val bleed = 1 - val bitmap = Bitmap.createBitmap( - ceil(chip.width).toInt() + bleed * 2, - ceil(chip.height).toInt() + bleed * 2, - Bitmap.Config.ARGB_8888 - ) - chip.draw(Canvas(bitmap), bleed.toFloat(), bleed.toFloat()) - val bytes = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes) - bitmap.recycle() - // React Native sits an inline view's box on the text baseline and grows the line's - // ascent to fit it, so a box as tall as the chip lifts the chip above the words and - // pushes the baseline down. Report a box no taller than the paragraph font's ascent, - // which leaves the line exactly as tall as a line of plain text, plus where inside - // that box the bitmap must sit so the chip centres on the font's ascent/descent box: - // the same rule the composer's ReplacementSpan uses to draw its chips. - val lineMetrics = paragraphFontMetrics( - payload.optJSONObject("text"), - fontScale * metrics.density, - ) - val ascent = -lineMetrics.ascent - val descent = lineMetrics.descent - val bitmapWidth = ceil(chip.width) + bleed * 2 - val bitmapHeight = ceil(chip.height) + bleed * 2 - val result = mapOf( - "uri" to - "data:image/png;base64,${Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)}", - // Layout rounds dp back to whole pixels. Reporting a hair less than the bitmap lets - // that rounding land inside the image and crop its right-hand border, so round the - // box up: an extra fraction of a pixel is invisible, a missing border is not. - "width" to bitmapWidth / metrics.density, - "height" to bitmapHeight / metrics.density, - "boxHeight" to ascent / metrics.density, - "offsetY" to (ascent + descent - bitmapHeight) / 2f / metrics.density, - ) - chipImages.put(key, result) - result - } - - Function("installCopySanitizer") { reactTag: Int, contextClipboardConfig: String -> - 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) { - currentCallback.contextClipboardConfig = contextClipboardConfig - return@runOnUiQueueThread - } - textView.setSpannableFactory(MarkdownSpannableFactory) - textView.customSelectionActionModeCallback = - SanitizingSelectionActionModeCallback(textView, currentCallback, contextClipboardConfig) - } - } - } -} diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt deleted file mode 100644 index 81703090a3f0..000000000000 --- a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package expo.modules.t3markdowntext - -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.widget.TextView -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [36], manifest = Config.NONE) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class MarkdownSelectionColorTest { - private fun textView() = TextView(RuntimeEnvironment.getApplication()).apply { - setTextSelectHandle(ColorDrawable(Color.WHITE)) - setTextSelectHandleLeft(ColorDrawable(Color.WHITE)) - setTextSelectHandleRight(ColorDrawable(Color.WHITE)) - } - - private fun renderedColor(drawable: Drawable?): Int { - requireNotNull(drawable) - val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888) - drawable.setBounds(0, 0, 4, 4) - drawable.draw(Canvas(bitmap)) - val color = bitmap.getPixel(2, 2) - bitmap.recycle() - return color - } - - @Test - fun retintsAllHandlesWhenTheThemeChangesWithoutChangingTheHighlight() { - val text = textView() - val highlight = 0x52FF0088 - text.highlightColor = highlight - - for (color in listOf(Color.MAGENTA, Color.GREEN, Color.MAGENTA)) { - applySelectionHandleColor(text, color) - assertEquals(color, renderedColor(text.textSelectHandle)) - assertEquals(color, renderedColor(text.textSelectHandleLeft)) - assertEquals(color, renderedColor(text.textSelectHandleRight)) - assertEquals(highlight, text.highlightColor) - } - } - - @Test - fun doesNotTintOtherTextViewsSharingDrawableState() { - val original = ColorDrawable(Color.WHITE) - val first = textView().apply { - setTextSelectHandleLeft(original.constantState!!.newDrawable()) - } - val second = textView().apply { - setTextSelectHandleLeft(original.constantState!!.newDrawable()) - } - - applySelectionHandleColor(first, Color.MAGENTA) - - assertEquals(Color.MAGENTA, renderedColor(first.textSelectHandleLeft)) - assertEquals(Color.WHITE, renderedColor(second.textSelectHandleLeft)) - } -} diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt deleted file mode 100644 index da9012ee1665..000000000000 --- a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package expo.modules.t3markdowntext - -import android.graphics.drawable.ColorDrawable -import android.text.SpannableString -import android.text.Spanned -import android.text.style.ImageSpan -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [36], manifest = Config.NONE) -class MarkdownSelectionCopyTest { - private fun withIcon(value: String): SpannableString = SpannableString(value).apply { - val index = value.indexOf('\uFFFC') - setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - - @Test - fun removesIconAndInjectedSpacer() { - val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") - assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length)) - } - - @Test - fun removesSpacerWhenSelectionStartsAfterIcon() { - val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") - assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12)) - } - - @Test - fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() { - val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0") - assertEquals( - "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0", - copyTextWithoutInlineImages(text, 0, text.length) - ) - } - - @Test - fun preservesTextWithoutImageSpans() { - val text = "\uFFFC\u00A0main.go" - assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length)) - assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length)) - } -} diff --git a/apps/mobile/modules/t3-markdown-text/expo-module.config.json b/apps/mobile/modules/t3-markdown-text/expo-module.config.json deleted file mode 100644 index f41f760ee7ca..000000000000 --- a/apps/mobile/modules/t3-markdown-text/expo-module.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "platforms": ["android"], - "android": { - "modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"] - } -} diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts deleted file mode 100644 index 81b5f13f28df..000000000000 --- a/apps/mobile/modules/t3-markdown-text/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -export { markdownFileIconSource } from "./src/markdownFileIcons"; -export { - resolveMarkdownFileIcon, - resolveMarkdownLinkPresentation, - type MarkdownFileIcon, - type MarkdownLinkPresentation, -} from "./src/markdownLinks"; -export { - nativeMarkdownChunkSpacing, - nativeMarkdownDocumentChunks, - nativeMarkdownDocumentRuns, - nativeMarkdownListItemBlocks, - nativeMarkdownTextRuns, - type NativeMarkdownDocumentChunk, - type NativeMarkdownTextRun, -} from "./src/nativeMarkdownText"; -export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive"; -export { - SelectableMarkdownText, - type MarkdownCodeHighlighter, - type MarkdownHighlightedToken, -} from "./src/SelectableMarkdownText"; -export type { - MarkdownFileContextMenu, - MarkdownFileContextMenuAction, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./src/SelectableMarkdownText.types"; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h b/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h deleted file mode 100644 index 625b7ca97c7b..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h +++ /dev/null @@ -1,162 +0,0 @@ -#pragma once - -#import - -// Used by both the shadow measurement and UITextView rendering paths. -static NSDictionary *T3ContextChipPayload(NSString *uri) -{ - if (![uri hasPrefix:@"chip:"]) return nil; - NSData *data = [[uri substringFromIndex:5] dataUsingEncoding:NSUTF8StringEncoding]; - id payload = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![payload isKindOfClass:NSDictionary.class]) return nil; - // Every consumer draws or substitutes `label`; a missing or non-string one would raise inside - // `replaceCharactersInRange:withString:` while a message is rendering. - if (![payload[@"label"] isKindOfClass:NSString.class]) return nil; - return payload; -} - -static UIColor *T3ContextChipColor(NSString *hex) -{ - unsigned int rgb = 0; - if (![hex isKindOfClass:NSString.class] || hex.length != 7) return UIColor.labelColor; - [[NSScanner scannerWithString:[hex substringFromIndex:1]] scanHexInt:&rgb]; - return [UIColor colorWithRed:((rgb >> 16) & 255) / 255.0 - green:((rgb >> 8) & 255) / 255.0 - blue:(rgb & 255) / 255.0 alpha:1]; -} - -static UIColor *T3ContextChipBlend(UIColor *accent, UIColor *base, CGFloat weight) -{ - CGFloat ar = 0, ag = 0, ab = 0, aa = 0, br = 0, bg = 0, bb = 0, ba = 0; - [accent getRed:&ar green:&ag blue:&ab alpha:&aa]; - [base getRed:&br green:&bg blue:&bb alpha:&ba]; - return [UIColor colorWithRed:ar * weight + br * (1 - weight) - green:ag * weight + bg * (1 - weight) - blue:ab * weight + bb * (1 - weight) - alpha:aa * weight + ba * (1 - weight)]; -} - -// Some chip glyphs have no SF Symbol that reads correctly: the pull request one would land on -// `arrow.triangle.branch`, a road-sign fork that says "branch", not "pull request". Draw those -// from the same lucide geometry web and Android use so one chip looks alike on every surface. -static UIImage *T3ContextChipVectorIcon(NSString *symbol, CGFloat size, UIColor *color) -{ - if (![symbol isEqualToString:@"git-pull-request"]) return nil; - UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] - initWithSize:CGSizeMake(size, size)]; - return [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { - CGFloat s = size / 24.0; // lucide authors on a 24pt grid. - UIBezierPath *path = [UIBezierPath bezierPath]; - [path appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(18 * s, 18 * s) - radius:3 * s startAngle:0 - endAngle:M_PI * 2 clockwise:YES]]; - [path appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(6 * s, 6 * s) - radius:3 * s startAngle:0 - endAngle:M_PI * 2 clockwise:YES]]; - [path moveToPoint:CGPointMake(13 * s, 6 * s)]; - [path addLineToPoint:CGPointMake(16 * s, 6 * s)]; - [path addCurveToPoint:CGPointMake(18 * s, 8 * s) - controlPoint1:CGPointMake(17.1 * s, 6 * s) - controlPoint2:CGPointMake(18 * s, 6.9 * s)]; - [path addLineToPoint:CGPointMake(18 * s, 15 * s)]; - [path moveToPoint:CGPointMake(6 * s, 9 * s)]; - [path addLineToPoint:CGPointMake(6 * s, 21 * s)]; - path.lineWidth = 2 * s; - path.lineCapStyle = kCGLineCapRound; - path.lineJoinStyle = kCGLineJoinRound; - [color setStroke]; - [path stroke]; - }]; -} - -// Centres the chip on the run font's ascent/descent box, the rule the composer span and -// Android use, so the chip lands in the same place beside the words on every surface. -static inline CGRect T3ContextChipBounds(UIFont *font, CGSize size) -{ - CGFloat y = font != nil ? (font.ascender + font.descender - size.height) / 2 : -3; - return CGRectMake(0, y, size.width, size.height); -} - -// A bare attachment string carries none of the run's attributes. Losing the paragraph -// style at a paragraph's first character drops its line height, and losing the font lets -// a chip-only line shrink to the bitmap, so the placeholder keeps both, plus the run -// colour so a later re-apply (after an image loads) still tints with it. -static inline NSAttributedString *T3MarkdownTextAttachmentString( - NSTextAttachment *attachment, NSDictionary *runAttributes) -{ - NSMutableAttributedString *string = - [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy]; - for (NSAttributedStringKey key in - @[ - NSFontAttributeName, NSParagraphStyleAttributeName, NSForegroundColorAttributeName, - NSBaselineOffsetAttributeName - ]) { - id value = runAttributes[key]; - if (value != nil) { - [string addAttribute:key value:value range:NSMakeRange(0, string.length)]; - } - } - return string; -} - -static UIFont *T3ContextChipFont(NSDictionary *payload) -{ - CGFloat size = MAX(10, MIN(40, [payload[@"fontSize"] doubleValue])); - size *= payload[@"fontSizeMultiplier"] != nil ? [payload[@"fontSizeMultiplier"] doubleValue] : 1; - return [UIFont fontWithName:@"DMSans-Medium" size:size] - ?: [UIFont systemFontOfSize:size weight:UIFontWeightMedium]; -} - -static inline CGSize T3ContextChipSize(NSDictionary *payload, CGFloat maximumWidth) -{ - UIFont *font = T3ContextChipFont(payload); - NSString *label = payload[@"label"]; - CGFloat textWidth = [label sizeWithAttributes:@{ NSFontAttributeName: font }].width; - return CGSizeMake(MIN(maximumWidth, ceil(textWidth + font.pointSize * 2.5)), - ceil(font.pointSize * 1.41)); -} - -static inline UIImage *T3ContextChipImage(NSDictionary *payload, CGSize size, UIImage *fileIcon) -{ - static NSCache *cache; - static dispatch_once_t once; - dispatch_once(&once, ^{ cache = [NSCache new]; cache.countLimit = 256; }); - NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:NSJSONWritingSortedKeys error:nil]; - NSString *key = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - key = [key stringByAppendingString:NSStringFromCGSize(size)]; - if (fileIcon != nil) key = [key stringByAppendingString:@":file-icon"]; - UIImage *cached = [cache objectForKey:key]; - if (cached) return cached; - UIFont *font = T3ContextChipFont(payload); - CGFloat em = font.pointSize; - UIColor *accent = T3ContextChipColor(payload[@"accent"]); - UIColor *foreground = T3ContextChipBlend(accent, T3ContextChipColor(payload[@"foreground"]), 0.22); - UIColor *border = T3ContextChipBlend(accent, T3ContextChipColor(payload[@"border"]), 0.34); - UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size]; - UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { - UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect: - CGRectInset(CGRectMake(0, 0, size.width, size.height), 0.5, 0.5) - cornerRadius:em * 0.5]; - [[accent colorWithAlphaComponent:0.11] setFill]; - [path fill]; - [border setStroke]; - path.lineWidth = 1; - [path stroke]; - CGFloat iconSize = em * 1.17; - UIImage *icon = fileIcon - ?: T3ContextChipVectorIcon(payload[@"symbol"], iconSize, foreground) - ?: [[UIImage systemImageNamed:payload[@"symbol"] - withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:em weight:UIImageSymbolWeightMedium]] - imageWithTintColor:foreground renderingMode:UIImageRenderingModeAlwaysOriginal]; - [icon drawInRect:CGRectMake(em * 0.5, (size.height - iconSize) / 2, iconSize, iconSize)]; - NSMutableParagraphStyle *paragraph = [NSMutableParagraphStyle new]; - paragraph.lineBreakMode = NSLineBreakByTruncatingMiddle; - CGFloat x = em * 2; - [payload[@"label"] drawInRect:CGRectMake(x, (size.height - font.lineHeight) / 2, - MAX(0, size.width - x - em * 0.5), font.lineHeight) - withAttributes:@{ NSFontAttributeName: font, NSForegroundColorAttributeName: foreground, - NSParagraphStyleAttributeName: paragraph }]; - }]; - [cache setObject:image forKey:key]; - return image; -} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm deleted file mode 100644 index 533c13108865..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ /dev/null @@ -1,829 +0,0 @@ -#import "T3MarkdownText.h" -#import "T3MarkdownTextShadowNode.h" -#import "T3MarkdownTextComponentDescriptor.h" -#import "T3MarkdownTextRun.h" -#import "T3ContextChip.h" -#import -#import - -#import -#import -#import -#import -#import "RCTFabricComponentsPlugins.h" - -using namespace facebook::react; - -@interface T3ContextChipAccessibilityElement : UIAccessibilityElement -@property(nonatomic, weak) T3MarkdownTextRun *run; -@end - -@implementation T3ContextChipAccessibilityElement -- (BOOL)accessibilityActivate -{ - if (self.run == nil) return NO; - [self.run onPress]; - return YES; -} -@end - -/** Preserve canonical references and their payload when copying a native text selection. */ -@interface T3ContextCopyTextView : UITextView -@property(nonatomic, copy) NSDictionary *contextClipboardConfig; -@end - -@implementation T3ContextCopyTextView -// Read-only text still supports selecting the entire document after selecting a word. -- (BOOL)canPerformAction:(SEL)action withSender:(id)sender -{ - if (action == @selector(selectAll:)) { - return self.selectable && self.text.length > 0 && self.selectedRange.length < self.text.length; - } - return [super canPerformAction:action withSender:sender]; -} - -- (void)copy:(id)sender -{ - NSRange selected = self.selectedRange; - NSArray *ranges = self.contextClipboardConfig[@"ranges"]; - if (selected.location == NSNotFound || selected.length == 0 || NSMaxRange(selected) > self.text.length || ranges.count == 0) { - [super copy:sender]; - return; - } - NSMutableString *text = [[self.text substringWithRange:selected] mutableCopy]; - BOOL hasContext = NO; - for (NSDictionary *range in [ranges reverseObjectEnumerator]) { - NSUInteger start = [range[@"start"] unsignedIntegerValue]; - NSUInteger end = [range[@"end"] unsignedIntegerValue]; - if (end <= start || end > self.text.length) continue; - NSRange overlap = NSIntersectionRange(selected, NSMakeRange(start, end - start)); - if (overlap.length == 0 || ![range[@"text"] isKindOfClass:NSString.class]) continue; - [text replaceCharactersInRange:NSMakeRange(overlap.location - selected.location, overlap.length) withString:range[@"text"]]; - hasContext = YES; - } - if (!hasContext) { [super copy:sender]; return; } - [text replaceOccurrencesOfString:@"\uFFFC\u00A0" withString:@"" options:0 range:NSMakeRange(0, text.length)]; - NSString *fragment = self.contextClipboardConfig[@"fragment"]; - NSMutableDictionary *payload = [[NSJSONSerialization JSONObjectWithData:[fragment dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:nil] mutableCopy]; - NSArray *records = payload[@"records"]; - NSMutableArray *copied = [NSMutableArray array]; - NSMutableSet *screenshots = [NSMutableSet set]; - for (NSDictionary *record in records) { - if ([text containsString:[NSString stringWithFormat:@"/%@)", record[@"contextId"]]]) { - [copied addObject:record]; - if ([record[@"screenshotContextId"] isKindOfClass:NSString.class]) [screenshots addObject:record[@"screenshotContextId"]]; - } - } - for (NSDictionary *record in records) { - if ([screenshots containsObject:record[@"contextId"]] && ![copied containsObject:record]) [copied addObject:record]; - } - payload[@"records"] = copied; - NSData *encoded = payload ? [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil] : nil; - NSMutableDictionary *item = [@{@"public.utf8-plain-text": text} mutableCopy]; - if (encoded && copied.count > 0) { - NSString *raw = [[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding]; - NSString *attribute = [raw stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.alphanumericCharacterSet]; - NSString *escaped = [[[text stringByReplacingOccurrencesOfString:@"&" withString:@"&"] stringByReplacingOccurrencesOfString:@"<" withString:@"<"] stringByReplacingOccurrencesOfString:@">" withString:@">"]; - item[@"app.t3.context-fragment"] = encoded; - item[@"public.html"] = [[NSString stringWithFormat:@"
%@
", attribute, escaped] dataUsingEncoding:NSUTF8StringEncoding]; - } - UIPasteboard.generalPasteboard.items = @[item]; -} -@end - -static void T3MarkdownTextApplyParagraphStyles( - NSMutableAttributedString *attributedString, - const std::vector &styleRanges) -{ - for (const auto &styleRange : styleRanges) { - if (styleRange.length == 0 || styleRange.location >= attributedString.length) { - continue; - } - - const NSRange markerRange = NSMakeRange( - styleRange.location, - MIN(styleRange.length, attributedString.length - styleRange.location)); - const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; - const NSParagraphStyle *existingStyle = - [attributedString attribute:NSParagraphStyleAttributeName - atIndex:paragraphRange.location - effectiveRange:nil]; - NSMutableParagraphStyle *paragraphStyle = - existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; - paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; - paragraphStyle.headIndent = styleRange.headIndent; - paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; - paragraphStyle.tabStops = @[ - [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft - location:styleRange.headIndent - options:@{}] - ]; - paragraphStyle.defaultTabInterval = styleRange.headIndent; - [attributedString addAttribute:NSParagraphStyleAttributeName - value:paragraphStyle - range:paragraphRange]; - } -} - -static void T3MarkdownTextApplyAttachments( - NSMutableAttributedString *attributedString, - const std::vector &attachmentRanges, - NSDictionary *images) -{ - for (const auto &attachmentRange : attachmentRanges) { - if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { - continue; - } - - NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; - NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; - UIImage *image = images[imageUri]; - const BOOL isSymbol = [imageUri hasPrefix:@"sf:"]; - if (isSymbol) { - image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]]; - } - NSDictionary *runAttributes = - [attributedString attributesAtIndex:attachmentRange.location effectiveRange:nil]; - UIColor *foregroundColor = runAttributes[NSForegroundColorAttributeName]; - if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) { - image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor - renderingMode:UIImageRenderingModeAlwaysOriginal]; - } - attachment.image = image ?: [[UIImage alloc] init]; - const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); - attachment.bounds = CGRectMake( - 0, - T3MarkdownTextAttachmentBaselineOffset(attachmentRange), - attachmentSize, - attachmentSize); - NSDictionary *chip = T3ContextChipPayload(imageUri); - if (chip != nil) { - CGSize size = CGSizeMake(attachmentRange.chipWidth, attachmentRange.chipHeight); - attachment.bounds = T3ContextChipBounds(runAttributes[NSFontAttributeName], size); - NSString *iconUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil; - attachment.image = T3ContextChipImage(chip, size, iconUri ? images[iconUri] : nil); - } - const NSRange range = NSMakeRange( - attachmentRange.location, - MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); - [attributedString replaceCharactersInRange:range - withAttributedString:T3MarkdownTextAttachmentString(attachment, runAttributes)]; - } -} - -@protocol T3MarkdownOutsideTapTarget -- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; -@end - -@interface T3MarkdownOutsideTapCoordinator : NSObject - -- (instancetype)initWithWindow:(UIWindow *)window; -- (void)addTarget:(id)target; -- (void)removeTarget:(id)target; - -@end - -static const void *T3MarkdownOutsideTapCoordinatorKey = - &T3MarkdownOutsideTapCoordinatorKey; - -@implementation T3MarkdownOutsideTapCoordinator { - __weak UIWindow *_window; - UITapGestureRecognizer *_recognizer; - NSHashTable> *_targets; -} - -- (instancetype)initWithWindow:(UIWindow *)window -{ - if (self = [super init]) { - _window = window; - _targets = [NSHashTable weakObjectsHashTable]; - _recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(handleTap:)]; - _recognizer.cancelsTouchesInView = NO; - _recognizer.delegate = self; - [window addGestureRecognizer:_recognizer]; - } - return self; -} - -- (void)addTarget:(id)target -{ - [_targets addObject:target]; -} - -- (void)removeTarget:(id)target -{ - [_targets removeObject:target]; - if (_targets.count > 0) { - return; - } - - UIWindow *window = _window; - [window removeGestureRecognizer:_recognizer]; - if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) { - objc_setAssociatedObject( - window, - T3MarkdownOutsideTapCoordinatorKey, - nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } -} - -- (void)handleTap:(UITapGestureRecognizer *)sender -{ - UIWindow *window = _window; - if (window == nil) { - return; - } - - UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil]; - if (hitView == nil) { - return; - } - for (id target in _targets.allObjects) { - [target clearSelectionForOutsideTapWithHitView:hitView]; - } -} - -- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer - shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer -{ - return YES; -} - -@end - -static T3MarkdownOutsideTapCoordinator * -T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) -{ - T3MarkdownOutsideTapCoordinator *coordinator = - objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey); - if (coordinator == nil) { - coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window]; - objc_setAssociatedObject( - window, - T3MarkdownOutsideTapCoordinatorKey, - coordinator, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - return coordinator; -} - -@interface T3MarkdownText () - -@end - -@interface T3MarkdownText () -@end - -@implementation T3MarkdownText { - UIView * _view; - T3ContextCopyTextView * _textView; - T3MarkdownTextShadowNode::ConcreteState::Shared _state; - __weak UIWindow * _outsideTapWindow; - BOOL _suppressSelectionChange; - NSMutableDictionary * _attachmentImages; - NSMutableSet * _pendingAttachmentUris; - UILongPressGestureRecognizer *_longPressGestureRecognizer; - UITapGestureRecognizer *_pressGestureRecognizer; - NSArray *_contextAccessibilityElements; -} - -+ (ComponentDescriptorProvider)componentDescriptorProvider -{ - return concreteComponentDescriptorProvider(); -} - -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; - - _view = [[UIView alloc] init]; - self.contentView = _view; - self.clipsToBounds = true; - - _textView = [[T3ContextCopyTextView alloc] init]; - _attachmentImages = [[NSMutableDictionary alloc] init]; - _pendingAttachmentUris = [[NSMutableSet alloc] init]; - _textView.scrollEnabled = false; - _textView.editable = false; - _textView.textContainerInset = UIEdgeInsetsZero; - _textView.textContainer.lineFragmentPadding = 0; - _textView.delegate = self; - // Chat text supports selection and contextual actions, but not drag-and-drop. - _textView.textDragInteraction.enabled = NO; - _textView.linkTextAttributes = @{}; - // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. - _textView.layoutManager.usesFontLeading = NO; - [self addSubview:_textView]; - - _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self - action:@selector(handleLongPressIfNecessary:)]; - _longPressGestureRecognizer.delegate = self; - - _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(handlePressIfNecessary:)]; - _pressGestureRecognizer.delegate = self; - [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer]; - - [_textView addGestureRecognizer:_pressGestureRecognizer]; - [_textView addGestureRecognizer:_longPressGestureRecognizer]; - } - - return self; -} - -- (void)didMoveToWindow -{ - [super didMoveToWindow]; - if (_outsideTapWindow == self.window) { - return; - } - if (_outsideTapWindow != nil) { - T3MarkdownOutsideTapCoordinator *coordinator = - objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); - [coordinator removeTarget:self]; - } - _outsideTapWindow = self.window; - if (_outsideTapWindow != nil) { - [T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self]; - } -} - -- (void)dealloc -{ - T3MarkdownOutsideTapCoordinator *coordinator = - objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); - [coordinator removeTarget:self]; -} - -- (NSArray *)accessibilityElements -{ - return _contextAccessibilityElements ?: [super accessibilityElements]; -} - -// See RCTParagraphComponentView -- (void)prepareForRecycle -{ - [super prepareForRecycle]; - T3MarkdownOutsideTapCoordinator *coordinator = - objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); - [coordinator removeTarget:self]; - _outsideTapWindow = nil; - _state.reset(); - - // Reset the frame to zero so that when it properly lays out on the next use - _textView.frame = CGRectZero; - _textView.attributedText = nil; - _contextAccessibilityElements = nil; -} - -- (void)layoutSubviews -{ - [super layoutSubviews]; - // _textView's frame is assigned inside drawRect, which only fires when - // state changes. Trigger a redraw whenever the host frame moves out from - // under it (rotation, parent relayout) so the text view resizes and - // onTextLayout re-fires with the new line wrapping. - if (!CGRectEqualToRect(_textView.frame, _view.frame)) { - [self setNeedsDisplay]; - } -} - -- (void)drawRect:(CGRect)rect -{ - if (!_state) { - return; - } - - const auto &props = *std::static_pointer_cast(_props); - - const auto attrString = _state->getData().attributedString; - NSMutableAttributedString *convertedAttrString = - [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy]; - T3MarkdownTextApplyParagraphStyles( - convertedAttrString, - _state->getData().paragraphStyleRanges); - T3MarkdownTextApplyAttachments( - convertedAttrString, - _state->getData().attachmentRanges, - _attachmentImages); - // Matches the shadow node so drawn lines sit where measurement put them. - RCTApplyBaselineOffset(convertedAttrString); - NSUInteger runLocation = 0; - for (UIView *child in self.subviews) { - if (![child isKindOfClass:[T3MarkdownTextRun class]]) { - continue; - } - - T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; - const NSRange runRange = NSMakeRange(runLocation, textChild.text.length); - runLocation = NSMaxRange(runRange); - if (![textChild hasContextMenu] || runRange.length == 0 || - NSMaxRange(runRange) > convertedAttrString.length) { - continue; - } - - NSURL *link = [NSURL URLWithString: - [NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]]; - if (link != nil) { - // A glyph must not be both a link and an attachment. UIKit caches them as - // different text-item classes and can send `attachment` to a cached link - // on a later tap. Attachment actions already use primaryActionForTextItem. - [convertedAttrString enumerateAttribute:NSAttachmentAttributeName - inRange:runRange - options:0 - usingBlock:^(id attachment, NSRange range, BOOL *stop) { - if (attachment == nil) { - [convertedAttrString addAttribute:NSLinkAttributeName value:link range:range]; - } - }]; - } - } - [self loadAttachmentImages:_state->getData().attachmentRanges]; - - // Setting attributedText clears any active text selection, and re-assigning - // the frame triggers a layout flush that has the same effect. Bail out - // entirely when nothing actually changed so a JS-side state update made in - // response to onSelectionChange doesn't deselect what the user is selecting. - const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; - const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); - if (!textChanged && !frameChanged) { - return; - } - if (textChanged) { - // Reassigning attributedText clears any active selection. Save it and - // restore after, while suppressing the synthetic textViewDidChangeSelection - // events the clear-then-restore would otherwise produce — those would - // round-trip to JS and re-trigger this same path, causing a loop. - const NSRange savedRange = _textView.selectedRange; - _suppressSelectionChange = YES; - _textView.attributedText = convertedAttrString; - NSMutableString *accessibleText = [convertedAttrString.string mutableCopy]; - for (auto it = _state->getData().attachmentRanges.rbegin(); - it != _state->getData().attachmentRanges.rend(); ++it) { - NSDictionary *chip = T3ContextChipPayload([NSString stringWithUTF8String:it->imageUri.c_str()]); - if (chip != nil && it->location < accessibleText.length) { - [accessibleText replaceCharactersInRange:NSMakeRange(it->location, 1) withString:chip[@"label"]]; - } - } - _textView.accessibilityLabel = accessibleText; - if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) { - _textView.selectedRange = savedRange; - } - _suppressSelectionChange = NO; - } - if (frameChanged) { - _textView.frame = _view.frame; - } - - // Text attachments have no native link element. Expose their existing runs - // at the measured glyph bounds, without inserting views into text layout. - NSMutableArray *accessibleElements = [NSMutableArray arrayWithObject:_textView]; - for (UIView *child in self.subviews) { - if (![child isKindOfClass:T3MarkdownTextRun.class]) continue; - T3MarkdownTextRun *run = (T3MarkdownTextRun *)child; - run.contextChipInteractive = NO; - } - for (const auto &attachmentRange : _state->getData().attachmentRanges) { - NSDictionary *chip = T3ContextChipPayload( - [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]); - if (![chip[@"interactive"] boolValue]) continue; - NSRange range = NSMakeRange(attachmentRange.location, 1); - T3MarkdownTextRun *run = [self childForCharacterRange:range]; - if (!run || NSMaxRange(range) > convertedAttrString.length) continue; - NSRange glyphRange = [_textView.layoutManager glyphRangeForCharacterRange:range actualCharacterRange:nil]; - CGRect bounds = [_textView.layoutManager boundingRectForGlyphRange:glyphRange - inTextContainer:_textView.textContainer]; - bounds = CGRectOffset(bounds, _textView.textContainerInset.left, _textView.textContainerInset.top); - run.contextChipInteractive = YES; - T3ContextChipAccessibilityElement *element = - [[T3ContextChipAccessibilityElement alloc] initWithAccessibilityContainer:self]; - element.run = run; - element.accessibilityLabel = chip[@"label"]; - element.accessibilityTraits = UIAccessibilityTraitButton; - element.accessibilityFrameInContainerSpace = [_textView convertRect:bounds toView:self]; - [accessibleElements addObject:element]; - } - _contextAccessibilityElements = accessibleElements; - - __block std::vector lines; - const int maxLines = props.numberOfLines; - [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect, - CGRect usedRect, - NSTextContainer * _Nonnull textContainer, - NSRange glyphRange, - BOOL * _Nonnull stop) { - const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil]; - const auto line = [self->_textView.text substringWithRange:charRange]; - lines.push_back(line.UTF8String); - // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS - // 18, so cap explicitly. - if (maxLines > 0 && lines.size() >= (size_t)maxLines) { - *stop = YES; - } - }]; - - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast(_eventEmitter) - ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast(self.tag), lines}); - }; -} - -- (void)loadAttachmentImages:(const std::vector &)attachmentRanges -{ - for (const auto &attachmentRange : attachmentRanges) { - NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; - if ([imageUri hasPrefix:@"sf:"]) { - continue; - } - NSDictionary *chip = T3ContextChipPayload(imageUri); - if (chip != nil) { - imageUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil; - if (imageUri.length == 0) continue; - } - if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) { - continue; - } - - NSURL *url = [NSURL URLWithString:imageUri]; - if (url == nil) { - continue; - } - if (url.isFileURL) { - UIImage *image = [UIImage imageWithContentsOfFile:url.path]; - if (image != nil) { - _attachmentImages[imageUri] = image; - dispatch_async(dispatch_get_main_queue(), ^{ - [self refreshDisplayedAttachments]; - }); - } - continue; - } - - [_pendingAttachmentUris addObject:imageUri]; - [[[NSURLSession sharedSession] dataTaskWithURL:url - completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { - UIImage *image = data == nil ? nil : [UIImage imageWithData:data]; - dispatch_async(dispatch_get_main_queue(), ^{ - [self->_pendingAttachmentUris removeObject:imageUri]; - if (image != nil) { - self->_attachmentImages[imageUri] = image; - [self refreshDisplayedAttachments]; - } - }); - }] resume]; - } -} - -- (void)refreshDisplayedAttachments -{ - if (!_state || _textView.attributedText == nil) { - return; - } - - NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy]; - T3MarkdownTextApplyAttachments( - attributedText, - _state->getData().attachmentRanges, - _attachmentImages); - - const NSRange savedRange = _textView.selectedRange; - _suppressSelectionChange = YES; - _textView.attributedText = attributedText; - if (savedRange.location != NSNotFound && - NSMaxRange(savedRange) <= _textView.attributedText.length) { - _textView.selectedRange = savedRange; - } - _suppressSelectionChange = NO; - [_textView setNeedsDisplay]; -} - -- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps -{ - const auto &oldViewProps = *std::static_pointer_cast(_props); - const auto &newViewProps = *std::static_pointer_cast(props); - if (oldViewProps.contextClipboardConfig != newViewProps.contextClipboardConfig) { - NSString *config = RCTNSStringFromString(newViewProps.contextClipboardConfig); - _textView.contextClipboardConfig = config.length ? [NSJSONSerialization JSONObjectWithData:[config dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil] : nil; - } - - if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { - _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; - } - - if (oldViewProps.selectable != newViewProps.selectable) { - _textView.selectable = newViewProps.selectable; - } - - if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { - if (@available(iOS 11.0, *)) { - _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; - } - } - - if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { - if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { - _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead; - } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { - _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle; - } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { - _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail; - } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) { - _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping; - } - } - - - // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text - // is in will have this background color applied. To apply it just to a particular part of a string, you'd need - // to do Hello. - // This is how the base component works though, so we'll go with it for now. Can change later if we want. - if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { - _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor); - } - - [super updateProps:props oldProps:oldProps]; -} - -// See RCTParagraphComponentView -- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState -{ - _state = std::static_pointer_cast(state); - [self setNeedsDisplay]; -} - -// MARK: - UIGestureRecognizerDelegate - -- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer -{ - return YES; -} - -- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer -{ - if (gestureRecognizer != _longPressGestureRecognizer && - gestureRecognizer != _pressGestureRecognizer) { - return YES; - } - - const auto location = [self getLocationOfPress:gestureRecognizer]; - const auto child = [self getTouchChild:location]; - return ![child hasContextMenu]; -} - -- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch -{ - return YES; -} - -- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView -{ - if ([hitView isDescendantOfView:self]) { - return; - } - // Defer past the current event loop turn so any in-flight edit-menu action - // (Copy / Define / Look Up / …) reads the live selection before we clear it. - UITextView *textView = _textView; - dispatch_async(dispatch_get_main_queue(), ^{ - UITextRange *range = textView.selectedTextRange; - if (range != nil && !range.isEmpty) { - textView.selectedTextRange = nil; - } - }); -} - -// MARK: - Touch handling - -- (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange -{ - NSUInteger location = 0; - for (UIView *child in self.subviews) { - if (![child isKindOfClass:[T3MarkdownTextRun class]]) { - continue; - } - - T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child; - const NSRange range = NSMakeRange(location, textChild.text.length); - if (NSIntersectionRange(range, characterRange).length > 0) { - return textChild; - } - location = NSMaxRange(range); - } - return nil; -} - -- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender -{ - return [sender locationInView:_textView]; -} - -- (T3MarkdownTextRun*)getTouchChild:(CGPoint)location -{ - const auto charIndex = [_textView.layoutManager characterIndexForPoint:location - inTextContainer:_textView.textContainer - fractionOfDistanceBetweenInsertionPoints:nil - ]; - - int currIndex = -1; - for (UIView* child in self.subviews) { - if (![child isKindOfClass:[T3MarkdownTextRun class]]) { - continue; - } - - T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child; - - // This is UTF16 code units!! - currIndex += textChild.text.length; - - if (charIndex <= currIndex) { - return textChild; - } - } - - return nil; -} - -- (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender -{ - const auto location = [self getLocationOfPress:sender]; - const auto child = [self getTouchChild:location]; - - if (child) { - [child onPress]; - } -} - -- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender -{ - if (sender.state != UIGestureRecognizerStateBegan) { - return; - } - - const auto location = [self getLocationOfPress:sender]; - const auto child = [self getTouchChild:location]; - - if (child) { - [child onLongPress]; - } -} - -// MARK: - UITextViewDelegate - -- (nullable UIAction *)textView:(UITextView *)textView - primaryActionForTextItem:(UITextItem *)textItem - defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0)) -{ - T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; - if (![child hasContextMenu] && !child.contextChipInteractive) { - return defaultAction; - } - - __weak T3MarkdownTextRun *weakChild = child; - return [UIAction actionWithHandler:^(__kindof UIAction *action) { - [weakChild onPress]; - }]; -} - -- (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView - menuConfigurationForTextItem:(UITextItem *)textItem - defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0)) -{ - T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range]; - UIMenu *menu = [child contextMenu]; - if (child.contextChipInteractive && menu == nil) return nil; - return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu]; -} - -- (void)textViewDidChangeSelection:(UITextView *)textView -{ - if (_suppressSelectionChange) { - return; - } - if (_eventEmitter == nullptr) { - return; - } - - const NSRange selectedRange = textView.selectedRange; - if (selectedRange.location == NSNotFound) { - return; - } - - // Fires on programmatic selection changes too (e.g. the outside-tap clear - // in handleOutsideTap:), so JS will see a synthetic empty-range event then. - std::dynamic_pointer_cast(_eventEmitter) - ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{ - static_cast(self.tag), - static_cast(selectedRange.location), - static_cast(selectedRange.location + selectedRange.length), - }); -} - -Class T3MarkdownTextCls(void) -{ - return T3MarkdownText.class; -} - -@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h deleted file mode 100644 index 77e21d585100..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include "T3MarkdownTextShadowNode.h" - -#include -#include - -namespace facebook::react { -using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor; - -void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen( - std::shared_ptr registry); -} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm deleted file mode 100644 index 3ca2b1eee5b9..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm +++ /dev/null @@ -1,36 +0,0 @@ -#import -#import -#import "RCTBridge.h" -#import "Utils.h" - -@interface T3MarkdownTextManager : RCTViewManager -@end - -@implementation T3MarkdownTextManager - -RCT_EXPORT_MODULE(T3MarkdownText) - -- (UIView *)view -{ - return [[UIView alloc] init]; -} - -RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView) -{ -} - -@end - -@interface T3MarkdownTextRunManager : RCTViewManager -@end - -@implementation T3MarkdownTextRunManager - -RCT_EXPORT_MODULE(T3MarkdownTextRun) - -- (UIView *)view -{ - return nil; -} - -@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h deleted file mode 100644 index f5f812451b70..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h +++ /dev/null @@ -1,28 +0,0 @@ -// This guard prevent this file to be compiled in the old architecture. -#ifdef RCT_NEW_ARCH_ENABLED -#import -#import -#import - -#ifndef T3MarkdownTextRunNativeComponent_h -#define T3MarkdownTextRunNativeComponent_h - -NS_ASSUME_NONNULL_BEGIN - -@interface T3MarkdownTextRun : RCTViewComponentView - -@property (nonatomic, copy, nullable) NSString *text; -@property (nonatomic, assign) BOOL contextChipInteractive; - -- (nullable UIMenu *)contextMenu; -- (BOOL)hasContextMenu; -- (void)onContextMenuAction:(NSString *)actionIdentifier; -- (void)onPress; -- (void)onLongPress; - -@end - -NS_ASSUME_NONNULL_END - -#endif /* UitextviewViewNativeComponent_h */ -#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm deleted file mode 100644 index d2de6884396f..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm +++ /dev/null @@ -1,140 +0,0 @@ -#import "T3MarkdownTextRun.h" -#import "T3MarkdownText.h" -#import "T3MarkdownTextRunComponentDescriptor.h" -#import -#import -#import -#import "RCTFabricComponentsPlugins.h" -#import "Utils.h" - -using namespace facebook::react; - -@interface T3MarkdownTextRun () - -@end - -@implementation T3MarkdownTextRun { - NSString * _text; - NSString * _contextMenuConfig; -} - -+ (ComponentDescriptorProvider)componentDescriptorProvider -{ - return concreteComponentDescriptorProvider(); -} - -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; - } - return self; -} - -- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps -{ - const auto &oldViewProps = *std::static_pointer_cast(_props); - const auto &newViewProps = *std::static_pointer_cast(props); - - if (newViewProps.text != oldViewProps.text) { - NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; - _text = text; - } - - if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) { - _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()]; - } - - [super updateProps:props oldProps:oldProps]; -} - -- (BOOL)hasContextMenu -{ - return _contextMenuConfig.length > 0; -} - -- (nullable UIMenu *)contextMenu -{ - if (_contextMenuConfig.length == 0) { - return nil; - } - - NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding]; - NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![config isKindOfClass:[NSDictionary class]]) { - return nil; - } - - NSArray *actionConfigs = config[@"actions"]; - if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) { - return nil; - } - - NSMutableArray *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count]; - __weak T3MarkdownTextRun *weakSelf = self; - for (NSDictionary *actionConfig in actionConfigs) { - if (![actionConfig isKindOfClass:[NSDictionary class]]) { - continue; - } - NSString *actionIdentifier = actionConfig[@"id"]; - NSString *title = actionConfig[@"title"]; - if (![actionIdentifier isKindOfClass:[NSString class]] || - ![title isKindOfClass:[NSString class]]) { - continue; - } - - UIAction *action = [UIAction actionWithTitle:title - image:nil - identifier:actionIdentifier - handler:^(__kindof UIAction *selectedAction) { - [weakSelf onContextMenuAction:selectedAction.identifier]; - }]; - if ([actionConfig[@"disabled"] boolValue]) { - action.attributes = UIMenuElementAttributesDisabled; - } - [actions addObject:action]; - } - - if (actions.count == 0) { - return nil; - } - NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @""; - return [UIMenu menuWithTitle:title children:actions]; -} - -- (void)onContextMenuAction:(NSString *)actionIdentifier -{ - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast(_eventEmitter) - ->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{ - static_cast(self.tag), - actionIdentifier.UTF8String, - }); - } -} - -- (void)onPress { - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast(_eventEmitter) - ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{}); - } -} - -- (void)onLongPress { - if (_eventEmitter != nullptr) { - std::dynamic_pointer_cast(_eventEmitter) - ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{}); - } -} - -+ (BOOL)shouldBeRecycled { - return NO; -} - -Class T3MarkdownTextRunCls(void) -{ - return T3MarkdownTextRun.class; -} - -@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h deleted file mode 100644 index 61f9e1a129ee..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include "T3MarkdownTextRunShadowNode.h" - -#include -#include - -namespace facebook::react { -using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor; - -void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen( - std::shared_ptr registry); -} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp deleted file mode 100644 index a1af619205df..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include "T3MarkdownTextRunShadowNode.h" - -namespace facebook::react { - -extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun"; -} // namespace facebook::react diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h deleted file mode 100644 index c00bd1f24079..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace facebook::react { -extern const char T3MarkdownTextRunComponentName[]; - -using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode< - T3MarkdownTextRunComponentName, - T3MarkdownTextRunProps, - T3MarkdownTextRunEventEmitter, - T3MarkdownTextRunState>; -} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h deleted file mode 100644 index 0f7b284594bb..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ /dev/null @@ -1,82 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace facebook::react { - -extern const char T3MarkdownTextComponentName[]; - -struct T3MarkdownTextParagraphStyleRange { - size_t location; - size_t length; - Float firstLineHeadIndent; - Float headIndent; - Float paragraphSpacing; -}; - -struct T3MarkdownTextAttachmentRange { - size_t location; - size_t length; - std::string imageUri; - /// Recolor the loaded image with the run's foreground color, like `sf:` symbols. - bool tintWithForeground; - Float chipWidth = 0; - Float chipHeight = 0; -}; - -inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { - return 14; -} - -inline Float T3MarkdownTextAttachmentBaselineOffset( - const T3MarkdownTextAttachmentRange &) { - return -2; -} - -class T3MarkdownTextStateReal final { - public: - AttributedString attributedString; - std::vector paragraphStyleRanges; - std::vector attachmentRanges; -}; - -class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< -T3MarkdownTextComponentName, -T3MarkdownTextProps, -T3MarkdownTextEventEmitter, -T3MarkdownTextStateReal> { -public: - using ConcreteViewShadowNode::ConcreteViewShadowNode; - - T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment - ); - - static ShadowNodeTraits BaseTraits() { - auto traits = ConcreteViewShadowNode::BaseTraits(); - traits.set(ShadowNodeTraits::Trait::LeafYogaNode); - traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); - return traits; - } - - void layout(LayoutContext layoutContext) override; - - Size measureContent( - const LayoutContext& layoutContext, - const LayoutConstraints& layoutConstraints) const override; - -private: - mutable AttributedString _attributedString; - mutable std::vector _paragraphStyleRanges; - mutable std::vector _attachmentRanges; -}; -} // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm deleted file mode 100644 index e1cc7c2046b2..000000000000 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ /dev/null @@ -1,303 +0,0 @@ -#include "T3MarkdownTextShadowNode.h" -#include "T3MarkdownTextRunShadowNode.h" -#import "T3ContextChip.h" -#include -#import - -#include -#include - -namespace facebook::react { - -static constexpr Float ParagraphStyleEncodingOffset = 1000; -static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; -static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; -static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:"; - -static void applyParagraphStyles( - NSMutableAttributedString *attributedString, - const std::vector &styleRanges) -{ - for (const auto &styleRange : styleRanges) { - if (styleRange.length == 0 || styleRange.location >= attributedString.length) { - continue; - } - - const NSRange markerRange = NSMakeRange( - styleRange.location, - MIN(styleRange.length, attributedString.length - styleRange.location)); - const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; - const NSParagraphStyle *existingStyle = - [attributedString attribute:NSParagraphStyleAttributeName - atIndex:paragraphRange.location - effectiveRange:nil]; - NSMutableParagraphStyle *paragraphStyle = - existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; - paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; - paragraphStyle.headIndent = styleRange.headIndent; - paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; - paragraphStyle.tabStops = @[ - [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft - location:styleRange.headIndent - options:@{}] - ]; - paragraphStyle.defaultTabInterval = styleRange.headIndent; - [attributedString addAttribute:NSParagraphStyleAttributeName - value:paragraphStyle - range:paragraphRange]; - } -} - -static void applyAttachments( - NSMutableAttributedString *attributedString, - const std::vector &attachmentRanges) -{ - for (const auto &attachmentRange : attachmentRanges) { - if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { - continue; - } - - NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; - attachment.image = [[UIImage alloc] init]; - const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); - attachment.bounds = CGRectMake( - 0, - T3MarkdownTextAttachmentBaselineOffset(attachmentRange), - attachmentSize, - attachmentSize); - NSDictionary *runAttributes = - [attributedString attributesAtIndex:attachmentRange.location effectiveRange:nil]; - if (attachmentRange.chipWidth > 0) { - attachment.bounds = T3ContextChipBounds( - runAttributes[NSFontAttributeName], - CGSizeMake(attachmentRange.chipWidth, attachmentRange.chipHeight)); - } - const NSRange range = NSMakeRange( - attachmentRange.location, - MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); - [attributedString replaceCharactersInRange:range - withAttributedString:T3MarkdownTextAttachmentString(attachment, runAttributes)]; - } -} - -T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment -) : ConcreteViewShadowNode(sourceShadowNode, fragment) { -}; - -Size T3MarkdownTextShadowNode::measureContent( - const LayoutContext& layoutContext, - const LayoutConstraints& layoutConstraints) const { - const auto &baseProps = getConcreteProps(); - - auto baseTextAttributes = TextAttributes::defaultTextAttributes(); - baseTextAttributes.backgroundColor = baseProps.backgroundColor; - baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; - - Float fontSizeMultiplier = 1.0; - if (baseTextAttributes.allowFontScaling) { - fontSizeMultiplier = layoutContext.fontSizeMultiplier; - } - - auto baseAttributedString = AttributedString{}; - auto paragraphStyleRanges = std::vector{}; - auto attachmentRanges = std::vector{}; - size_t utf16Offset = 0; - const auto &children = getChildren(); - for (size_t i = 0; i < children.size(); i++) { - const auto child = children[i].get(); - if (auto textViewChild = dynamic_cast(child)) { - auto &props = textViewChild->getConcreteProps(); - auto fragment = AttributedString::Fragment{}; - auto textAttributes = TextAttributes::defaultTextAttributes(); - - textAttributes.allowFontScaling = baseProps.allowFontScaling; - textAttributes.backgroundColor = props.backgroundColor; - textAttributes.fontSize = props.fontSize * fontSizeMultiplier; - textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; - textAttributes.foregroundColor = props.color; - const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset; - if (!hasParagraphStyle) { - textAttributes.textShadowColor = props.shadowColor; - textAttributes.textShadowOffset = props.shadowOffset; - textAttributes.textShadowRadius = props.shadowRadius; - } - textAttributes.letterSpacing = props.letterSpacing; - textAttributes.textDecorationColor = props.textDecorationColor; - textAttributes.fontFamily = props.fontFamily; - - if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) { - textAttributes.fontStyle = FontStyle::Italic; - } else { - textAttributes.fontStyle = FontStyle::Normal; - } - - if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) { - textAttributes.fontWeight = FontWeight::Bold; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) { - textAttributes.fontWeight = FontWeight::UltraLight; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) { - textAttributes.fontWeight = FontWeight::Light; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) { - textAttributes.fontWeight = FontWeight::Medium; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) { - textAttributes.fontWeight = FontWeight::Semibold; - } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) { - textAttributes.fontWeight = FontWeight::Heavy; - } else { - textAttributes.fontWeight = FontWeight::Regular; - } - - if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) { - textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough; - } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) { - textAttributes.textDecorationLineType = TextDecorationLineType::Underline; - } else { - textAttributes.textDecorationLineType = TextDecorationLineType::None; - } - - if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) { - textAttributes.textDecorationStyle = TextDecorationStyle::Solid; - } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) { - textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; - } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) { - textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; - } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) { - textAttributes.textDecorationStyle = TextDecorationStyle::Double; - } - - if (props.textAlign == T3MarkdownTextRunTextAlign::Left) { - textAttributes.alignment = TextAlignment::Left; - } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) { - textAttributes.alignment = TextAlignment::Right; - } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) { - textAttributes.alignment = TextAlignment::Center; - } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) { - textAttributes.alignment = TextAlignment::Justified; - } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) { - textAttributes.alignment = TextAlignment::Natural; - } - - textAttributes.backgroundColor = props.backgroundColor; - - fragment.string = props.text; - fragment.textAttributes = textAttributes; - - NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()]; - const size_t fragmentLength = fragmentText.length; - if (hasParagraphStyle) { - paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{ - utf16Offset, - fragmentLength, - props.shadowOffset.width, - props.shadowOffset.height, - props.shadowRadius - ParagraphStyleEncodingOffset, - }); - } - if (props.nativeId.rfind("t3-chip:", 0) == 0 && fragmentLength > 0) { - const std::string uri = props.nativeId.substr(3); - NSMutableDictionary *payload = - [T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]) mutableCopy]; - // Chips must scale with the paragraph or smaller Dynamic Type sizes clip them. - // Store the scaled payload so measurement and the rendered bitmap use the same font. - payload[@"fontSizeMultiplier"] = @(fontSizeMultiplier); - NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; - NSString *scaledUri = [@"chip:" stringByAppendingString: - [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]]; - const CGFloat maxWidth = std::isfinite(layoutConstraints.maximumSize.width) - ? layoutConstraints.maximumSize.width : 320; - const CGSize size = T3ContextChipSize(payload, maxWidth); - attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, 1, std::string(scaledUri.UTF8String), false, - static_cast(size.width), static_cast(size.height), - }); - } else if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { - attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, - 1, - props.nativeId.substr(std::char_traits::length(FileAttachmentNativeIdPrefix)), - false, - }); - } else if ( - props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { - attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, - 1, - props.nativeId.substr( - std::char_traits::length(SkillAttachmentNativeIdPrefix)), - false, - }); - } else if ( - props.nativeId.rfind(LinkAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { - attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ - utf16Offset, - 1, - props.nativeId.substr(std::char_traits::length(LinkAttachmentNativeIdPrefix)), - true, - }); - } - utf16Offset += fragmentLength; - baseAttributedString.appendFragment(std::move(fragment)); - } - } - - _attributedString = baseAttributedString; - _paragraphStyleRanges = paragraphStyleRanges; - _attachmentRanges = attachmentRanges; - - NSMutableAttributedString *convertedAttributedString = - [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; - applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); - applyAttachments(convertedAttributedString, attachmentRanges); - // TextKit stacks a paragraph's extra line height above the glyphs. React Native's own - // layout manager centres them with a baseline offset; do the same, after attachments - // so chips shift with the words. - RCTApplyBaselineOffset(convertedAttributedString); - - const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) - ? layoutConstraints.maximumSize.width - : CGFLOAT_MAX; - NSTextStorage *textStorage = - [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString]; - NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; - layoutManager.usesFontLeading = NO; - NSTextContainer *textContainer = - [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; - textContainer.lineFragmentPadding = 0; - textContainer.maximumNumberOfLines = baseProps.numberOfLines; - if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { - textContainer.lineBreakMode = NSLineBreakByTruncatingHead; - } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { - textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; - } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { - textContainer.lineBreakMode = NSLineBreakByTruncatingTail; - } else { - textContainer.lineBreakMode = NSLineBreakByClipping; - } - [layoutManager addTextContainer:textContainer]; - [textStorage addLayoutManager:layoutManager]; - [layoutManager ensureLayoutForTextContainer:textContainer]; - const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; - - return { - std::clamp( - static_cast(std::ceil(usedRect.size.width)), - layoutConstraints.minimumSize.width, - layoutConstraints.maximumSize.width), - std::clamp( - static_cast(std::ceil(usedRect.size.height)), - layoutConstraints.minimumSize.height, - layoutConstraints.maximumSize.height), - }; -} - -void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { - ensureUnsealed(); - setStateData(T3MarkdownTextStateReal{ - _attributedString, - _paragraphStyleRanges, - _attachmentRanges, - }); -} -} diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json deleted file mode 100644 index 376befbeb152..000000000000 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@t3tools/mobile-markdown-text", - "version": "0.0.0", - "private": true, - "source": "./index.ts", - "files": [ - "android", - "assets", - "expo-module.config.json", - "ios", - "src", - "index.ts", - "LICENSE", - "UPSTREAM.md", - "T3MarkdownText.podspec", - "react-native.config.js" - ], - "main": "./index.ts", - "types": "./index.ts", - "react-native": "./index.ts", - "exports": { - ".": "./index.ts", - "./file-icons": "./src/markdownFileIcons.ts", - "./link-icons": "./src/markdownLinkIcons.ts", - "./links": "./src/markdownLinks.ts", - "./markdown": "./src/nativeMarkdownText.ts", - "./primitive": "./src/MarkdownTextPrimitive.tsx", - "./renderer": "./src/SelectableMarkdownText.tsx", - "./types": "./src/SelectableMarkdownText.types.ts" - }, - "peerDependencies": { - "@t3tools/client-runtime": "*", - "@t3tools/shared": "*", - "expo": "*", - "expo-asset": "*", - "expo-clipboard": "*", - "expo-haptics": "*", - "expo-symbols": "*", - "react": "*", - "react-native": "*", - "react-native-nitro-markdown": "*" - }, - "codegenConfig": { - "name": "T3MarkdownTextSpec", - "type": "all", - "jsSrcsDir": "src", - "ios": { - "componentProvider": { - "T3MarkdownText": "T3MarkdownText", - "T3MarkdownTextRun": "T3MarkdownTextRun" - } - }, - "outputDir": { - "ios": "ios/generated" - } - } -} diff --git a/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx deleted file mode 100644 index ffbc0e2fcb63..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { SymbolView } from "expo-symbols"; -import * as Clipboard from "expo-clipboard"; -import * as Haptics from "expo-haptics"; -import { memo, useEffect, useRef, useState } from "react"; -import { Pressable, type ColorValue } from "react-native"; - -const COPY_FEEDBACK_DURATION_MS = 1200; - -function copyTextWithHaptic(value: string): void { - void Clipboard.setStringAsync(value); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); -} - -export const CopyTextButton = memo(function CopyTextButton(props: { - readonly accessibilityLabel: string; - readonly text: string; - readonly tintColor: ColorValue; - readonly copiedTintColor?: ColorValue; - readonly backgroundColor?: ColorValue; - readonly borderColor?: ColorValue; - readonly iconSize?: number; - readonly buttonSize?: number; -}) { - const [copied, setCopied] = useState(false); - const resetTimeoutRef = useRef | null>(null); - - useEffect( - () => () => { - if (resetTimeoutRef.current) { - clearTimeout(resetTimeoutRef.current); - } - }, - [], - ); - - return ( - { - copyTextWithHaptic(props.text); - setCopied(true); - if (resetTimeoutRef.current) { - clearTimeout(resetTimeoutRef.current); - } - resetTimeoutRef.current = setTimeout(() => { - setCopied(false); - resetTimeoutRef.current = null; - }, COPY_FEEDBACK_DURATION_MS); - }} - style={({ pressed }) => ({ - width: props.buttonSize ?? 30, - height: props.buttonSize ?? 30, - alignItems: "center", - justifyContent: "center", - borderRadius: 9, - borderWidth: props.borderColor ? 1 : 0, - borderColor: props.borderColor, - backgroundColor: props.backgroundColor, - opacity: pressed ? 0.52 : 1, - })} - > - - - ); -}); diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx deleted file mode 100644 index 1e9394bd6eca..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React, { type Ref } from "react"; -import { - findNodeHandle, - Platform, - processColor, - StyleSheet, - Text as RNText, - type ColorValue, - type TextProps, - type ViewStyle, -} from "react-native"; -import { setMarkdownSelectionHandleColor } from "./T3MarkdownTextSelectionModule"; -import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; -import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; -import { flattenStyles } from "./util"; - -const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ - false, - StyleSheet.create({}), -]); - -const textDefaults = { - allowFontScaling: true, - selectable: true, -} satisfies TextProps; - -const useTextAncestorContext = () => React.useContext(TextAncestorContext); - -/** - * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices - * into the rendered string. `start === end` means the selection was cleared. - */ -export type SelectionChangeEvent = { - nativeEvent: { target: number; start: number; end: number }; -}; - -export type ContextMenuActionEvent = { - nativeEvent: { target: number; actionIdentifier: string }; -}; - -/** - * `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; - selectionHandleColor?: ColorValue; - uiTextView?: boolean; - contextMenuConfig?: string; - contextClipboardConfig?: string; - onContextMenuAction?: (event: ContextMenuActionEvent) => void; - /** - * Fired when the native text selection changes. Only fires on iOS when - * `uiTextView` is true. Note: fires on every selection-edge adjustment - * (e.g. dragging a selection handle), so consumers driving expensive work - * off this event should debounce. - */ - onSelectionChange?: (event: SelectionChangeEvent) => void; -}; - -function MarkdownTextPrimitiveChild({ - style, - children, - nativeTextRef: _nativeTextRef, - ...rest -}: MarkdownTextPrimitiveProps) { - const [isAncestor, rootStyle] = useTextAncestorContext(); - - // Flatten the styles, and apply the root styles when needed - const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); - const contextValue = React.useMemo<[boolean, ViewStyle]>( - () => [true, flattenedStyle], - [flattenedStyle], - ); - let childPosition = 0; - const nativeChildren = React.Children.toArray(children).map((child) => { - const position = childPosition; - childPosition += 1; - - if (React.isValidElement(child)) { - return child; - } - if (typeof child !== "string" && typeof child !== "number") { - return null; - } - - const text = child.toString(); - return ( - // @ts-expect-error The generated run props do not include inherited Text props. - - ); - }); - - if (!isAncestor) { - // Press handlers are delivered by the text runs; the container never sees them. - const { onPress: _onPress, onLongPress: _onLongPress, ...containerProps } = rest; - return ( - - - {nativeChildren} - - - ); - } - - return <>{nativeChildren}; -} - -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 ; -} - -function AndroidMarkdownText({ - nativeTextRef, - selectionHandleColor, - onLayout, - contextClipboardConfig: _contextClipboardConfig, - ...props -}: MarkdownTextPrimitiveProps) { - const textRef = React.useRef(null); - React.useImperativeHandle(nativeTextRef, () => textRef.current, []); - const color = processColor(selectionHandleColor); - const applyHandleColor = React.useCallback(() => { - if (!textRef.current || typeof color !== "number") return; - const reactTag = findNodeHandle(textRef.current); - if (reactTag !== null) setMarkdownSelectionHandleColor(reactTag, color); - }, [color]); - - // RN's selectionColor only sets the highlight. Retint mounted handles when - // the theme changes, and after layout when the native view first exists. - React.useEffect(applyHandleColor, [applyHandleColor]); - - return ( - { - applyHandleColor(); - onLayout?.(event); - }} - /> - ); -} - -export function MarkdownTextPrimitive({ - selectionHandleColor, - ...props -}: MarkdownTextPrimitiveProps) { - if (Platform.OS === "android" && selectionHandleColor !== undefined) { - return ; - } - if (Platform.OS !== "ios") { - const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props; - return ; - } - return ; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx deleted file mode 100644 index b57a182dbc56..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx +++ /dev/null @@ -1,665 +0,0 @@ -import { createContext, memo, useContext, useMemo } from "react"; -import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native"; -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; - -import { CopyTextButton } from "./CopyTextButton"; -import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { - nativeMarkdownDocumentRuns, - nativeMarkdownListItemBlocks, - nativeMarkdownNodePosition, -} from "./nativeMarkdownText"; -import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText"; -import type { - MarkdownCodeHighlighter, - MarkdownHighlightedToken, - MarkdownImageRenderer, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, -} from "./SelectableMarkdownText.types"; -import { useHighlightedCode, type HighlightedCode } from "./useHighlightedCode"; - -/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ -export const MarkdownImageRendererContext = createContext(null); - -const MONO_FONT_FAMILY = Platform.select({ - ios: "ui-monospace", - android: "monospace", - default: "monospace", -}); - -function nodeKey(node: MarkdownNode, index: number): string { - return `${node.type}:${nativeMarkdownNodePosition(node, index)}`; -} - -/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */ -function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number { - return Math.max(10, Math.round(textStyle.fontSize * 0.8)); -} - -function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number { - return codeBlockFontSize(textStyle) + 6; -} - -function nodeText(node: MarkdownNode): string { - if (node.content !== undefined) { - return node.content; - } - return (node.children ?? []).map(nodeText).join(""); -} - -function documentFor(node: MarkdownNode): MarkdownNode { - return node.type === "document" ? node : { type: "document", children: [node] }; -} - -function SelectableNode(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - return ( - - ); -} - -const HighlightedCodeLine = memo(function HighlightedCodeLine(props: { - readonly tokens: ReadonlyArray; - readonly color: string; - readonly newline: boolean; -}) { - let offset = 0; - const children = []; - for (const token of props.tokens) { - if (!token.content) continue; - children.push( - - {token.content} - , - ); - offset += token.content.length; - } - return ( - - {children} - {props.newline ? "\n" : ""} - - ); -}); - -function HighlightedCodeText(props: { - readonly content: string; - readonly highlighted: HighlightedCode | null; - readonly textStyle: NativeMarkdownTextStyle; -}) { - // The text root provides inherited styles through context. A new style object - // would rerender every token even when its completed line is unchanged. - const fontSize = codeBlockFontSize(props.textStyle); - const lineHeight = codeBlockLineHeight(props.textStyle); - const style = useMemo( - () => ({ - color: props.textStyle.codeColor, - fontFamily: MONO_FONT_FAMILY, - fontSize, - lineHeight, - }), - [props.textStyle.codeColor, fontSize, lineHeight], - ); - let offset = 0; - const lines = []; - if (props.highlighted) { - for (const tokens of props.highlighted) { - lines.push( - , - ); - offset += tokens.reduce((length, token) => length + token.content.length, 0) + 1; - } - } - return ( - - {props.highlighted ? lines : props.content} - - ); -} - -function NativeCodeBlock(props: { - readonly node: MarkdownNode; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly compact?: boolean; -}) { - const content = nodeText(props.node).replace(/\n$/, ""); - const colorScheme = useColorScheme(); - const theme = colorScheme === "dark" ? "dark" : "light"; - const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); - const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; - return ( - - - - {languageLabel} - - - - - - - - ); -} - -function collectTableRows(node: MarkdownNode): MarkdownNode[] { - const rows: MarkdownNode[] = []; - const visit = (child: MarkdownNode) => { - if (child.type === "table_row") { - rows.push(child); - return; - } - for (const nested of child.children ?? []) { - visit(nested); - } - }; - visit(node); - return rows; -} - -function NativeTable(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - const rows = collectTableRows(props.node); - return ( - - - {rows.map((row, rowIndex) => ( - - {(row.children ?? []).map((cell, cellIndex) => ( - - - rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, - )} - textStyle={props.textStyle} - onLinkPress={props.onLinkPress} - /> - - ))} - - ))} - - - ); -} - -function NativeMarkdownImage(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - const renderImage = useContext(MarkdownImageRendererContext); - const href = props.node.href; - if (!href) { - return ( - - ); - } - - if (renderImage) { - const rendered = renderImage({ - href, - alt: props.node.alt ?? null, - title: props.node.title ?? null, - }); - if (rendered != null) { - return <>{rendered}; - } - } - - return ( - - - {props.node.alt ? ( - - {props.node.alt} - - ) : null} - - ); -} - -function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { - const groups: MarkdownNode[] = []; - let inline: MarkdownNode[] = []; - const flush = () => { - if (inline.length === 0) { - return; - } - groups.push({ type: "paragraph", children: inline }); - inline = []; - }; - - for (const node of nodes) { - if (node.type === "image") { - flush(); - groups.push(node); - } else { - inline.push(node); - } - } - flush(); - return groups; -} - -function NativeMixedParagraph(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - return ( - - {inlineGroups(props.node.children ?? []).map((child, index) => - child.type === "image" ? ( - - ) : ( - - ), - )} - - ); -} - -function NativeList(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly onLinkPress?: (href: string) => void; - readonly depth: number; -}) { - const ordered = props.node.ordered ?? false; - const start = props.node.start ?? 1; - const nested = props.depth > 0; - return ( - - {(props.node.children ?? []).map((item, index) => { - const taskMarker = item.type === "task_list_item"; - const marker = taskMarker - ? item.checked - ? "☑︎" - : "☐︎" - : ordered - ? `${start + index}.` - : props.depth % 3 === 1 - ? "◦" - : props.depth % 3 === 2 - ? "▪︎" - : "•"; - const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; - const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; - return ( - - - - {marker} - - - - {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( - - ))} - - - ); - })} - - ); -} - -export function NativeMarkdownBlock(props: { - readonly node: MarkdownNode; - readonly skills: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; - readonly onLinkPress?: (href: string) => void; - readonly depth?: number; - readonly compact?: boolean; -}) { - const depth = props.depth ?? 0; - switch (props.node.type) { - case "document": - return ( - - {(props.node.children ?? []).map((child, index) => ( - - ))} - - ); - case "code_block": - return ( - - ); - case "table": - return ( - - ); - case "image": - return ( - - ); - case "horizontal_rule": - return ( - - ); - case "blockquote": - return ( - - {(props.node.children ?? []).map((child, index) => ( - - ))} - - ); - case "list": - return ( - - ); - case "paragraph": - return (props.node.children ?? []).some((child) => child.type === "image") ? ( - - ) : ( - - ); - case "html_block": - case "math_block": - return ( - - - - ); - case "table_head": - case "table_body": - case "table_row": - case "table_cell": - case "list_item": - case "task_list_item": - return ( - - {(props.node.children ?? []).map((child, index) => ( - - ))} - - ); - default: - return ( - - ); - } -} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx deleted file mode 100644 index 1d9c2b93ede8..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import { createContext, useCallback, useContext, useMemo } from "react"; -import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; -import { - findNodeHandle, - Image, - Linking, - Platform, - StyleSheet, - Text as RNText, - type TextStyle, - useColorScheme, - View, -} from "react-native"; - -import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { markdownFileIconSource } from "./markdownFileIcons"; -import { markdownLinkIconSource } from "./markdownLinkIcons"; -import { resolveMarkdownFileIcon, resolveMarkdownLinkIcon } from "./markdownLinks"; -import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; -import { nativeMarkdownContextCopyRanges } from "./nativeMarkdownText"; -import type { - MarkdownFileContextMenu, - NativeMarkdownTextStyle, -} from "./SelectableMarkdownText.types"; -import { - installMarkdownCopySanitizer, - renderAndroidContextChip, -} from "./T3MarkdownTextSelectionModule"; -import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; -import { contextChipPresentation } from "./nativeMarkdownText"; - -export const MarkdownContextClipboardContext = createContext(""); - -export interface MarkdownFileContextMenuHandlers { - readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; - readonly onFileContextMenuAction: (href: string, actionId: string) => void; -} - -/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */ -export const MarkdownFileContextMenuContext = createContext( - null, -); - -const EXTERNAL_LINK_PREFIX = "◉ "; -const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; -const SKILL_ICON_PLACEHOLDER = "\uFFFC"; -// React Native turns a run whose whole text is U+FFFC into a bare inline-view attachment -// with no font or paragraph style, so a chip opening a paragraph would drop its line -// height. Any other single character keeps the run's attributes; the native side swaps it -// for the chip attachment either way. -const IOS_CHIP_PLACEHOLDER = "\u200B"; -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 [ - run.text, - run.bold, - run.italic, - run.strikethrough, - run.code, - run.href, - run.externalHost, - run.fileIcon, - run.skillName, - run.skillLabel, - run.sourceText, - run.role, - run.headingLevel, - run.depth, - run.spacing, - run.firstLineHeadIndent, - run.headIndent, - run.paragraphSpacing, - ].join(":"); -} - -const DEFAULT_BODY_FONT_SIZE = 15; -const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const; - -function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number { - const index = Math.max(0, Math.min(5, headingLevel - 1)); - const configured = textStyle.headingFontSizes?.[index]; - if (typeof configured === "number" && Number.isFinite(configured)) { - return configured; - } - - const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE; - return Math.max(12, Math.round((DEFAULT_HEADING_FONT_SIZES[index] ?? 15) * scale)); -} - -function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { - const isFile = run.fileIcon != null; - const isSkill = run.skillName != null; - const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); - const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel); - const isHeading = run.role === "heading"; - const isCodeBlock = run.role === "code-block" || run.role === "code-language"; - const hasParagraphStyle = run.headIndent !== undefined; - const textDecorationLine = run.strikethrough - ? "line-through" - : run.href && !isFile - ? "underline" - : "none"; - - return { - color: isFile - ? textStyle.fileTextColor - : isSkill - ? textStyle.skillTextColor - : run.href - ? textStyle.linkColor - : isHeading - ? textStyle.strongColor - : run.role === "quote-marker" - ? textStyle.quoteMarkerColor - : run.role === "divider" - ? textStyle.dividerColor - : run.role === "code-language" - ? textStyle.mutedColor - : run.role === "list-marker" - ? textStyle.mutedColor - : isCodeBlock - ? textStyle.codeColor - : run.code - ? textStyle.inlineCodeColor - : run.bold - ? textStyle.strongColor - : textStyle.color, - fontFamily: - isFile || isSkill - ? textStyle.boldFontFamily - : run.code || isCodeBlock - ? MONO_FONT_FAMILY - : isHeading - ? textStyle.headingFontFamily - : run.bold - ? textStyle.boldFontFamily - : textStyle.fontFamily, - fontSize: - run.role === "spacer" - ? (run.spacing ?? 10) - : run.role === "list-break" - ? textStyle.fontSize - : isHeading - ? headingFontSize - : run.role === "code-language" - ? Math.max(10, Math.round(textStyle.fontSize * 0.73)) - : run.code || isCodeBlock - ? Math.max(12, textStyle.fontSize - 2) - : textStyle.fontSize, - lineHeight: - run.role === "spacer" - ? (run.spacing ?? 10) - : run.role === "list-break" - ? textStyle.lineHeight + (run.spacing ?? 0) - : isHeading - ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2) - : isCodeBlock - ? Math.max(16, textStyle.lineHeight - 2) - : textStyle.lineHeight, - fontStyle: run.italic ? "italic" : "normal", - fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", - textDecorationLine, - backgroundColor: isCodeBlock - ? textStyle.codeBlockBackgroundColor - : parseComposerContextHref(run.href ?? "") - ? textStyle.codeBackgroundColor - : undefined, - ...(hasParagraphStyle - ? { - shadowColor: "transparent", - shadowOffset: { - width: run.firstLineHeadIndent ?? 0, - height: run.headIndent, - }, - shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), - } - : {}), - }; -} - -export function NativeMarkdownSelectableText(props: { - readonly runs: ReadonlyArray; - readonly textStyle: NativeMarkdownTextStyle; - readonly onLinkPress?: (href: string) => void; -}) { - const colorScheme = useColorScheme(); - const menu = useContext(MarkdownFileContextMenuContext); - const contextClipboardFragment = useContext(MarkdownContextClipboardContext); - const contextRecords = useMemo( - () => decodeComposerContextFragment(contextClipboardFragment)?.records ?? [], - [contextClipboardFragment], - ); - const containsInlineIcon = props.runs.some( - (run) => - run.fileIcon != null || - run.skillName != null || - parseComposerContextHref(run.href ?? "") !== null || - (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null), - ); - const keyedRuns = useMemo(() => { - const occurrences = new Map(); - const prefixedExternalLinks = new Set(); - return props.runs.map((run) => { - const signature = runKeySignature(run); - const occurrence = occurrences.get(signature) ?? 0; - occurrences.set(signature, occurrence + 1); - - let text = run.text; - let linkIcon = null; - const contextReference = parseComposerContextHref(run.href ?? ""); - const contextRecord = contextReference - ? contextRecords.find((record) => record.contextId === contextReference.contextId) - : undefined; - const chip = - contextReference || run.skillName || run.fileIcon - ? { - ...contextChipPresentation( - contextReference?.kind ?? (run.skillName ? "skill" : "mention"), - contextRecord, - ), - label: run.skillLabel ?? run.text, - interactive: Boolean(run.href), - iconUri: - !contextReference && run.fileIcon - ? Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri - : contextRecord?.kind === "mention" && "path" in contextRecord - ? Image.resolveAssetSource( - markdownFileIconSource(resolveMarkdownFileIcon(contextRecord.path)), - ).uri - : undefined, - fontSize: props.textStyle.fontSize * 0.8, - foreground: props.textStyle.color, - border: props.textStyle.contextChipBorderColor ?? props.textStyle.dividerColor, - } - : null; - // Android sizes the chip's inline box from the paragraph font so the line box stays - // the height of a plain text line; see renderContextChip. - const androidChip = - Platform.OS === "android" && chip - ? renderAndroidContextChip( - JSON.stringify({ - ...chip, - text: { - fontFamily: props.textStyle.fontFamily, - fontSize: props.textStyle.fontSize, - }, - }), - ) - : null; - if (androidChip) { - text = ""; - } else if (chip && Platform.OS === "ios") { - text = IOS_CHIP_PLACEHOLDER; - } else if (run.fileIcon && Platform.OS === "ios") { - text = `${INLINE_ATTACHMENT_PREFIX}${text}`; - } else if (run.skillName && 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); - linkIcon = resolveMarkdownLinkIcon(run.externalHost); - if (linkIcon === null) { - text = `${EXTERNAL_LINK_PREFIX}${text}`; - } else if (Platform.OS === "ios") { - text = `${INLINE_ATTACHMENT_PREFIX}${text}`; - } - } - - return { key: `${signature}:${occurrence}`, run, text, linkIcon, chip, androidChip }; - }); - }, [props.runs, props.textStyle, contextRecords]); - const ranges = nativeMarkdownContextCopyRanges( - keyedRuns.map(({ run, text, linkIcon, androidChip }) => ({ - run, - text, - inlineImageLength: - Platform.OS === "android" && (androidChip || run.fileIcon || linkIcon) ? 1 : 0, - })), - ); - const contextClipboardConfig = ranges.length - ? JSON.stringify({ fragment: contextClipboardFragment, ranges }) - : ""; - const attachAndroidText = useCallback( - (textView: RNText | null) => { - if (Platform.OS !== "android" || !containsInlineIcon || !textView) return; - const reactTag = findNodeHandle(textView); - if (reactTag !== null) installMarkdownCopySanitizer(reactTag, contextClipboardConfig); - }, - [containsInlineIcon, contextClipboardConfig], - ); - // T3MarkdownText only rebuilds its attributed string during native layout. A - // color-only child update can otherwise leave the previous appearance cached. - const appearanceKey = [ - colorScheme ?? "unspecified", - props.textStyle.fontSize, - props.textStyle.lineHeight, - props.textStyle.headingFontSizes?.join(","), - props.textStyle.color, - props.textStyle.strongColor, - props.textStyle.mutedColor, - props.textStyle.linkColor, - props.textStyle.inlineCodeColor, - props.textStyle.codeColor, - props.textStyle.codeBackgroundColor, - props.textStyle.codeBlockBackgroundColor, - props.textStyle.fileTextColor, - props.textStyle.skillTextColor, - props.textStyle.quoteMarkerColor, - props.textStyle.dividerColor, - props.textStyle.contextChipBorderColor, - ].join(":"); - - return ( - run.skillLabel ?? run.text).join("") - : undefined - } - uiTextView - selectable - selectionColor={props.textStyle.selectionColor} - selectionHandleColor={props.textStyle.selectionHandleColor} - style={{ - flexShrink: 1, - minWidth: 0, - color: props.textStyle.color, - fontFamily: props.textStyle.fontFamily, - fontSize: props.textStyle.fontSize, - lineHeight: props.textStyle.lineHeight, - }} - > - {keyedRuns.map(({ key, run, text, linkIcon, chip, androidChip }) => { - const href = run.href; - const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; - const onPress = href - ? () => { - if (props.onLinkPress) props.onLinkPress(href); - else void Linking.openURL(href); - } - : undefined; - return ( - menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier) - : undefined - } - > - {androidChip ? ( - // The inline box sits on the baseline and is only as tall as the font's - // ascent, so it never changes the line's height. The bitmap hangs off that - // box (views in text are not clipped) to centre the chip on the text. - { - if (event.nativeEvent.actionName === "activate") onPress(); - } - : undefined - } - style={{ width: androidChip.width, height: androidChip.boxHeight }} - > - - - ) : Platform.OS === "android" && run.fileIcon ? ( - - ) : Platform.OS === "android" && linkIcon ? ( - - ) : null} - {text} - - ); - })} - - ); -} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx deleted file mode 100644 index 8e59256b12b8..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useMemo } from "react"; -import { View } from "react-native"; -import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; - -import { - nativeMarkdownChunkSpacing, - nativeMarkdownDocumentChunks, - nativeMarkdownDocumentRuns, - nativeMarkdownWithPreservedSoftBreaks, -} from "./nativeMarkdownText"; -import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock"; -import { - MarkdownContextClipboardContext, - MarkdownFileContextMenuContext, - NativeMarkdownSelectableText, - type MarkdownFileContextMenuHandlers, -} from "./NativeMarkdownSelectableText"; -import type { - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./SelectableMarkdownText.types"; - -const EMPTY_SKILLS: ReadonlyArray = []; - -export type { - MarkdownCodeHighlighter, - MarkdownHighlightedToken, - MarkdownImageRenderer, - MarkdownImageRequest, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./SelectableMarkdownText.types"; - -export function hasNativeSelectableMarkdownText(): boolean { - return true; -} - -export function SelectableMarkdownText({ - markdown, - contextClipboardFragment, - skills = EMPTY_SKILLS, - textStyle, - highlightCode, - preserveSoftBreaks = false, - onLinkPress, - fileContextMenu, - onFileContextMenuAction, - renderImage, - marginTop = 0, - marginBottom = 0, -}: SelectableMarkdownTextProps) { - const chunks = useMemo(() => { - const parsedDocument = parseMarkdownWithOptions(markdown, { - gfm: true, - html: true, - math: false, - }); - const document = preserveSoftBreaks - ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) - : parsedDocument; - return nativeMarkdownDocumentChunks(document).map((chunk) => - chunk.kind === "selectable" - ? { - ...chunk, - runs: nativeMarkdownDocumentRuns(chunk.node, skills), - } - : chunk, - ); - }, [markdown, preserveSoftBreaks, skills]); - - const fileContextMenuHandlers = useMemo( - () => - fileContextMenu && onFileContextMenuAction - ? { fileContextMenu, onFileContextMenuAction } - : null, - [fileContextMenu, onFileContextMenuAction], - ); - - return ( - - - - {/* A percentage width here creates a cyclic intrinsic measurement inside - shrink-to-fit containers such as user-message bubbles. Yoga then gives - the native text node an unbounded second pass and the parent only clips - the resulting single-line width instead of reflowing it. */} - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); - - return ( - - {content} - - ); - })} - - - - - ); -} diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts deleted file mode 100644 index 56d8f6d4e0f8..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { requireOptionalNativeModule } from "expo"; - -interface T3MarkdownTextSelectionNativeModule { - readonly setSelectionHandleColor?: (reactTag: number, color: number) => void; - readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void; - readonly renderContextChip?: (payloadJson: string) => { - readonly uri: string; - readonly width: number; - readonly height: number; - /** Inline box height: the paragraph font's ascent, so the line box never grows. */ - readonly boxHeight: number; - /** Bitmap top relative to the box top; negative when the chip overhangs the box. */ - readonly offsetY: number; - } | null; -} - -const nativeModule = - requireOptionalNativeModule("T3MarkdownTextSelection"); - -export function installMarkdownCopySanitizer(reactTag: number, contextClipboardConfig = ""): void { - nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig); -} - -export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void { - nativeModule?.setSelectionHandleColor?.(reactTag, color); -} - -export function renderAndroidContextChip(payloadJson: string) { - return nativeModule?.renderContextChip?.(payloadJson) ?? null; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts deleted file mode 100644 index fb0209b6a990..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ImageSourcePropType } from "react-native"; - -export const MARKDOWN_FILE_ICON_SOURCES = { - agents: require("../assets/file-icons/pierre_agents.png"), - astro: require("../assets/file-icons/pierre_astro.png"), - babel: require("../assets/file-icons/pierre_babel.png"), - bash: require("../assets/file-icons/pierre_bash.png"), - biome: require("../assets/file-icons/pierre_biome.png"), - bootstrap: require("../assets/file-icons/pierre_bootstrap.png"), - browserslist: require("../assets/file-icons/pierre_browserslist.png"), - bun: require("../assets/file-icons/pierre_bun.png"), - c: require("../assets/file-icons/pierre_c.png"), - claude: require("../assets/file-icons/pierre_claude.png"), - cpp: require("../assets/file-icons/pierre_cpp.png"), - css: require("../assets/file-icons/pierre_css.png"), - database: require("../assets/file-icons/pierre_database.png"), - default: require("../assets/file-icons/pierre_default.png"), - docker: require("../assets/file-icons/pierre_docker.png"), - eslint: require("../assets/file-icons/pierre_eslint.png"), - font: require("../assets/file-icons/pierre_font.png"), - git: require("../assets/file-icons/pierre_git.png"), - go: require("../assets/file-icons/pierre_go.png"), - graphql: require("../assets/file-icons/pierre_graphql.png"), - html: require("../assets/file-icons/pierre_html.png"), - image: require("../assets/file-icons/pierre_image.png"), - javascript: require("../assets/file-icons/pierre_javascript.png"), - json: require("../assets/file-icons/pierre_json.png"), - markdown: require("../assets/file-icons/pierre_markdown.png"), - mcp: require("../assets/file-icons/pierre_mcp.png"), - nextjs: require("../assets/file-icons/pierre_nextjs.png"), - npm: require("../assets/file-icons/pierre_npm.png"), - oxc: require("../assets/file-icons/pierre_oxc.png"), - pnpm: require("../assets/file-icons/pierre_pnpm.png"), - postcss: require("../assets/file-icons/pierre_postcss.png"), - prettier: require("../assets/file-icons/pierre_prettier.png"), - python: require("../assets/file-icons/pierre_python.png"), - react: require("../assets/file-icons/pierre_react.png"), - ruby: require("../assets/file-icons/pierre_ruby.png"), - rust: require("../assets/file-icons/pierre_rust.png"), - sass: require("../assets/file-icons/pierre_sass.png"), - stylelint: require("../assets/file-icons/pierre_stylelint.png"), - svelte: require("../assets/file-icons/pierre_svelte.png"), - svg: require("../assets/file-icons/pierre_svg.png"), - svgo: require("../assets/file-icons/pierre_svgo.png"), - swift: require("../assets/file-icons/pierre_swift.png"), - table: require("../assets/file-icons/pierre_table.png"), - tailwind: require("../assets/file-icons/pierre_tailwind.png"), - terraform: require("../assets/file-icons/pierre_terraform.png"), - text: require("../assets/file-icons/pierre_text.png"), - typescript: require("../assets/file-icons/pierre_typescript.png"), - video: require("../assets/file-icons/pierre_video.png"), - vite: require("../assets/file-icons/pierre_vite.png"), - vscode: require("../assets/file-icons/pierre_vscode.png"), - vue: require("../assets/file-icons/pierre_vue.png"), - wasm: require("../assets/file-icons/pierre_wasm.png"), - webpack: require("../assets/file-icons/pierre_webpack.png"), - yml: require("../assets/file-icons/pierre_yml.png"), - zig: require("../assets/file-icons/pierre_zig.png"), - zip: require("../assets/file-icons/pierre_zip.png"), -} as const satisfies Readonly>; diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts deleted file mode 100644 index 04682874db0c..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ /dev/null @@ -1,964 +0,0 @@ -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; -import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; -import { imageMimeType } from "@t3tools/shared/image"; -import { videoMimeType } from "@t3tools/shared/video"; -/** - * Every accent shares a lightness so no kind reads heavier than another; only hue carries - * identity. These are the sRGB form of the same OKLCH set web uses, so a chip looks the - * same on every surface. See `composerInlineChip.ts`. - */ -const CONTEXT_CHIP_PRESENTATIONS = { - image: { accent: "#d55665", symbol: "photo" }, - video: { accent: "#d06217", symbol: "play.rectangle" }, - file: { accent: "#0090cd", symbol: "doc" }, - mention: { accent: "#0096af", symbol: "doc" }, - terminal: { accent: "#009f6e", symbol: "terminal" }, - element: { accent: "#b87501", symbol: "cursorarrow.click" }, - "preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" }, - "review-comment": { accent: "#8a70dd", symbol: "text.bubble" }, - "pull-request": { accent: "#7079e4", symbol: "git-pull-request" }, - skill: { accent: "#b261be", symbol: "cube" }, -} as const; - -/** - * The size an attachment chip reports beside its name, matching web. Rendered as its own - * smaller run, so it carries no separator. Only attachment-backed records have bytes. - */ -export function composerChipSizeSuffix(record?: { - readonly kind?: string; - readonly sizeBytes?: number; -}): string { - if (record?.kind !== "file" && record?.kind !== "image") return ""; - return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : ""; -} - -/** - * A pull request chip is coloured by what the pull request *is*, the way web colours it and - * the way the forge itself does: green open, grey draft, purple merged, red closed. The glyph - * stays the same across all four, as it does on web — state is carried by colour alone. - */ -const PULL_REQUEST_CHIP_PRESENTATIONS = { - open: { accent: "#009f6e", symbol: "git-pull-request" }, - draft: { accent: "#7f8793", symbol: "git-pull-request" }, - merged: { accent: "#8a70dd", symbol: "git-pull-request" }, - closed: { accent: "#d55665", symbol: "git-pull-request" }, -} as const; - -export function contextChipPresentation( - kind: string, - record?: { - readonly kind?: string; - readonly name?: string; - readonly mimeType?: string; - readonly sectionId?: string; - readonly pullRequest?: { - readonly state?: string; - readonly isDraft?: boolean; - }; - }, -) { - const presentationKind = - kind === "file" && - videoMimeType({ - name: record?.name ?? "", - mimeType: record?.mimeType ?? "", - }) - ? "video" - : // A picture chosen through the file picker is typed `file`, but it is still a - // picture: it reads as one to the user and should not wear the generic file chip. - kind === "file" && - imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null - ? "image" - : kind === "review-comment" && record?.sectionId?.startsWith("pull-request:") - ? "pull-request" - : kind; - if (presentationKind === "pull-request") { - const pullRequest = record?.pullRequest; - const state = - pullRequest?.state === "open" && pullRequest.isDraft === true - ? "draft" - : (pullRequest?.state ?? ""); - if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) { - return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS]; - } - } - return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind) - ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS] - : CONTEXT_CHIP_PRESENTATIONS.file; -} -import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { - formatComposerContextReference, - parseComposerContextHref, -} from "@t3tools/shared/composerContextReferences"; - -/** Native selections count UTF-16 display units, including each inline image placeholder. */ -export function nativeMarkdownContextCopyRanges( - runs: ReadonlyArray<{ - readonly run: { - readonly href?: string; - readonly text: string; - readonly skillName?: string; - readonly fileIcon?: string; - readonly sourceText?: string; - }; - readonly text: string; - readonly inlineImageLength: number; - }>, -) { - let offset = 0; - return runs.flatMap(({ run, text, inlineImageLength }) => { - const start = offset; - offset += text.length + inlineImageLength; - const reference = parseComposerContextHref(run.href ?? ""); - const source = reference - ? formatComposerContextReference({ ...reference, label: run.text }) - : run.skillName - ? `$${run.skillName}` - : run.fileIcon && run.href - ? (run.sourceText ?? `[${run.text}](<${run.href}>)`) - : null; - return source === null ? [] : [{ start, end: offset, text: source }]; - }); -} - -import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; -import { - resolveMarkdownInlineCodePresentation, - resolveMarkdownLinkPresentation, - type MarkdownFileIcon, -} from "./markdownLinks"; - -export interface NativeMarkdownTextRun { - readonly text: string; - readonly bold?: boolean; - readonly italic?: boolean; - readonly strikethrough?: boolean; - readonly code?: boolean; - readonly href?: string; - readonly externalHost?: string; - readonly fileIcon?: MarkdownFileIcon; - readonly skillName?: string; - readonly skillLabel?: string; - readonly sourceText?: string; - readonly role?: - | "body" - | "heading" - | "list-marker" - | "list-break" - | "quote-marker" - | "code-block" - | "code-language" - | "divider" - | "spacer"; - readonly headingLevel?: number; - readonly depth?: number; - readonly spacing?: number; - readonly firstLineHeadIndent?: number; - readonly headIndent?: number; - readonly paragraphSpacing?: number; -} - -export type NativeMarkdownDocumentChunk = - | { - readonly kind: "selectable"; - readonly key: string; - readonly node: MarkdownNode; - } - | { - readonly kind: "rich"; - readonly key: string; - readonly node: MarkdownNode; - }; - -interface RunContext { - readonly bold: boolean; - readonly italic: boolean; - readonly strikethrough: boolean; - readonly code: boolean; - readonly href?: string; - readonly externalHost?: string; - readonly fileIcon?: MarkdownFileIcon; - readonly role?: NativeMarkdownTextRun["role"]; - readonly headingLevel?: number; - readonly depth?: number; - readonly spacing?: number; - readonly firstLineHeadIndent?: number; - readonly headIndent?: number; - readonly paragraphSpacing?: number; -} - -const EMPTY_CONTEXT: RunContext = { - bold: false, - italic: false, - strikethrough: false, - code: false, -}; - -const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; - -function decodeCodePoint(codePoint: number, entity: string): string { - if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { - return entity; - } - return String.fromCodePoint(codePoint); -} - -function decodeHtmlEntitiesOnce(value: string): string { - return value.replace( - /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, - (entity, decimal: string | undefined, hexadecimal: string | undefined) => { - if (decimal) { - return decodeCodePoint(Number.parseInt(decimal, 10), entity); - } - if (hexadecimal) { - return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity); - } - switch (entity.toLowerCase()) { - case "&": - return "&"; - case "'": - return "'"; - case ">": - return ">"; - case "<": - return "<"; - case " ": - return "\u00a0"; - case """: - return '"'; - default: - return entity; - } - }, - ); -} - -function decodeHtmlEntities(value: string): string { - let decoded = value; - for (let pass = 0; pass < 2; pass += 1) { - const next = decodeHtmlEntitiesOnce(decoded); - if (next === decoded) { - break; - } - decoded = next; - } - return decoded; -} - -function textNodeContent(value: string): string { - return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); -} - -function inlineHtmlText(value: string): string { - if (/^$/i.test(value.trim())) { - return "\n"; - } - return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); -} - -function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { - return ( - left.bold === right.bold && - left.italic === right.italic && - left.strikethrough === right.strikethrough && - left.code === right.code && - left.href === right.href && - left.externalHost === right.externalHost && - left.fileIcon === right.fileIcon && - left.skillName === right.skillName && - left.skillLabel === right.skillLabel && - left.role === right.role && - left.headingLevel === right.headingLevel && - left.depth === right.depth && - left.spacing === right.spacing && - left.firstLineHeadIndent === right.firstLineHeadIndent && - left.headIndent === right.headIndent && - left.paragraphSpacing === right.paragraphSpacing - ); -} - -function appendRun( - runs: NativeMarkdownTextRun[], - text: string, - context: RunContext, -): NativeMarkdownTextRun[] { - if (text.length === 0) { - return runs; - } - - const run: NativeMarkdownTextRun = { - text, - ...(context.bold ? { bold: true } : {}), - ...(context.italic ? { italic: true } : {}), - ...(context.strikethrough ? { strikethrough: true } : {}), - ...(context.code ? { code: true } : {}), - ...(context.href ? { href: context.href } : {}), - ...(context.externalHost ? { externalHost: context.externalHost } : {}), - ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), - ...(context.role ? { role: context.role } : {}), - ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), - ...(context.depth ? { depth: context.depth } : {}), - ...(context.spacing ? { spacing: context.spacing } : {}), - ...(context.firstLineHeadIndent !== undefined - ? { firstLineHeadIndent: context.firstLineHeadIndent } - : {}), - ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}), - ...(context.paragraphSpacing !== undefined - ? { paragraphSpacing: context.paragraphSpacing } - : {}), - }; - const previous = runs.at(-1); - if (previous && sameRunStyle(previous, run)) { - runs[runs.length - 1] = { ...previous, text: previous.text + run.text }; - return runs; - } - - runs.push(run); - return runs; -} - -const SKILL_TOKEN_REGEX = - /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; - -function formatSkillLabel(skill: SelectableMarkdownSkill): string { - const displayName = skill.displayName?.trim(); - if (displayName) { - return displayName; - } - return skill.name - .split(/[\s:_-]+/) - .filter(Boolean) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(" "); -} - -function decorateSkillRuns( - runs: ReadonlyArray, - skills: ReadonlyArray, -): ReadonlyArray { - if (skills.length === 0) { - return runs; - } - const skillByName = new Map(skills.map((skill) => [skill.name, skill])); - const decorated: NativeMarkdownTextRun[] = []; - - for (const run of runs) { - if (run.code || run.href || run.fileIcon || run.role === "code-block") { - decorated.push(run); - continue; - } - - let cursor = 0; - let matched = false; - for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { - const prefix = match[1] ?? ""; - const name = match[2] ?? ""; - const skill = skillByName.get(name); - if (!skill) { - continue; - } - const start = (match.index ?? 0) + prefix.length; - const end = (match.index ?? 0) + match[0].length; - if (start > cursor) { - decorated.push({ ...run, text: run.text.slice(cursor, start) }); - } - decorated.push({ - ...run, - text: run.text.slice(start, end), - skillName: name, - skillLabel: formatSkillLabel(skill), - }); - cursor = end; - matched = true; - } - if (!matched) { - decorated.push(run); - } else if (cursor < run.text.length) { - decorated.push({ ...run, text: run.text.slice(cursor) }); - } - } - - return decorated; -} - -function decorateMentionRuns(runs: ReadonlyArray) { - return runs.flatMap((run) => { - if (run.code || run.href || run.skillName || run.role === "code-block") return [run]; - const decorated: NativeMarkdownTextRun[] = []; - let cursor = 0; - for (const token of collectComposerInlineTokens(`${run.text} `)) { - if (token.type !== "mention" || !token.source.startsWith("@")) continue; - // Sentence punctuation is not part of an unquoted file reference. - const path = token.source.startsWith('@"') - ? token.value - : token.value.replace(/[.,;!?]+$/, ""); - const presentation = resolveMarkdownLinkPresentation(path); - if (presentation.kind !== "file") continue; - const end = token.end - (token.value.length - path.length); - if (token.start > cursor) - decorated.push({ ...run, text: run.text.slice(cursor, token.start) }); - decorated.push({ - ...run, - text: presentation.label, - href: presentation.href, - fileIcon: presentation.icon, - sourceText: run.text.slice(token.start, end), - }); - cursor = end; - } - if (cursor < run.text.length) decorated.push({ ...run, text: run.text.slice(cursor) }); - return decorated; - }); -} - -function appendChildren( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - context: RunContext, -): NativeMarkdownTextRun[] { - for (const child of node.children ?? []) { - appendNode(runs, child, context); - } - return runs; -} - -function nodeTextContent(node: MarkdownNode): string { - if (node.content !== undefined) { - return node.content; - } - return (node.children ?? []).map(nodeTextContent).join(""); -} - -function appendNode( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - context: RunContext, -): NativeMarkdownTextRun[] { - switch (node.type) { - case "text": - case "math_inline": - return appendRun(runs, textNodeContent(nodeTextContent(node)), context); - case "html_inline": - return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); - case "code_inline": { - const content = nodeTextContent(node); - const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content); - return presentation - ? appendRun(runs, presentation.label, { - ...context, - href: presentation.href, - fileIcon: presentation.icon, - }) - : appendRun(runs, content, { ...context, code: true }); - } - case "soft_break": - return appendRun(runs, " ", context); - case "line_break": - return appendRun(runs, "\n", context); - case "bold": - return appendChildren(runs, node, { ...context, bold: true }); - case "italic": - return appendChildren(runs, node, { ...context, italic: true }); - case "strikethrough": - return appendChildren(runs, node, { ...context, strikethrough: true }); - case "link": { - const reference = parseComposerContextHref(node.href ?? ""); - if (reference) { - // Build the link in isolation: adjacent links with the same href and - // style would otherwise merge into one run, collapsing two chips and - // their copy ranges into a single reference with a combined label. - const referenceRuns: NativeMarkdownTextRun[] = []; - appendChildren(referenceRuns, node, { - ...context, - href: node.href, - fileIcon: - reference.kind === "image" ? "image" : reference.kind === "terminal" ? "bash" : "text", - }); - runs.push(...referenceRuns); - return runs; - } - const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); - if (presentation.kind === "file") { - return appendRun(runs, presentation.label, { - ...context, - href: presentation.href, - fileIcon: presentation.icon, - }); - } - if (presentation.kind === "external") { - return appendChildren(runs, node, { - ...context, - href: presentation.href, - externalHost: presentation.host, - }); - } - return appendChildren(runs, node, { - ...context, - ...(presentation.href ? { href: presentation.href } : {}), - }); - } - case "image": - return appendRun(runs, node.alt ?? node.title ?? "", context); - default: - return appendChildren(runs, node, context); - } -} - -export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray { - return appendChildren([], node, EMPTY_CONTEXT); -} - -export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): MarkdownNode { - const children = node.children?.map(nativeMarkdownWithPreservedSoftBreaks); - return { - ...node, - ...(node.type === "soft_break" ? { type: "line_break" as const } : {}), - ...(children ? { children } : {}), - }; -} - -function appendBlockTerminator( - runs: NativeMarkdownTextRun[], - context: RunContext, -): NativeMarkdownTextRun[] { - return appendRun(runs, "\n", context); -} - -function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] { - return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing }); -} - -function appendInlineChildren( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - context: RunContext, -): NativeMarkdownTextRun[] { - for (const child of node.children ?? []) { - appendNode(runs, child, context); - } - return runs; -} - -function isInlineNode(node: MarkdownNode): boolean { - return ( - node.type === "text" || - node.type === "bold" || - node.type === "italic" || - node.type === "strikethrough" || - node.type === "link" || - node.type === "image" || - node.type === "code_inline" || - node.type === "math_inline" || - node.type === "html_inline" || - node.type === "soft_break" || - node.type === "line_break" - ); -} - -export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray { - const blocks: MarkdownNode[] = []; - let inlineNodes: MarkdownNode[] = []; - const flushInlineNodes = () => { - if (inlineNodes.length === 0) { - return; - } - blocks.push({ type: "paragraph", children: inlineNodes }); - inlineNodes = []; - }; - - for (const child of node.children ?? []) { - if (isInlineNode(child)) { - inlineNodes.push(child); - continue; - } - - flushInlineNodes(); - blocks.push(child); - } - flushInlineNodes(); - return blocks; -} - -function appendListItem( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - marker: string, - depth: number, - markerColumnWidth: number, -): NativeMarkdownTextRun[] { - const firstLineHeadIndent = Math.max(0, depth - 1) * 20; - appendRun(runs, `${marker}\t`, { - ...EMPTY_CONTEXT, - role: "list-marker", - depth, - firstLineHeadIndent, - headIndent: firstLineHeadIndent + markerColumnWidth, - paragraphSpacing: 2, - }); - - const children = node.children ?? []; - let wroteInlineContent = false; - for (const child of children) { - if (child.type === "paragraph") { - appendInlineChildren(runs, child, { - ...EMPTY_CONTEXT, - role: "body", - depth, - }); - wroteInlineContent = true; - continue; - } - if (child.type === "list") { - if (wroteInlineContent) { - appendBlockTerminator(runs, { - ...EMPTY_CONTEXT, - role: "list-break", - depth, - spacing: 1, - }); - } - appendList(runs, child, depth + 1); - wroteInlineContent = false; - continue; - } - if (isInlineNode(child)) { - appendNode(runs, child, { - ...EMPTY_CONTEXT, - role: "body", - depth, - }); - wroteInlineContent = true; - continue; - } - appendDocumentBlock(runs, child, depth); - wroteInlineContent = true; - } - - if (wroteInlineContent) { - appendBlockTerminator(runs, { - ...EMPTY_CONTEXT, - role: "list-break", - depth, - spacing: depth === 1 ? 4 : 2, - }); - } - return runs; -} - -function appendList( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth: number, -): NativeMarkdownTextRun[] { - const ordered = node.ordered ?? false; - const start = node.start ?? 1; - const children = node.children ?? []; - const markers = children.map((child, index) => - child.type === "task_list_item" - ? child.checked - ? "☑︎" - : "☐︎" - : ordered - ? `${start + index}.` - : depth % 3 === 2 - ? "◦" - : depth % 3 === 0 - ? "▪︎" - : "•", - ); - const markerWidth = ordered - ? Math.max(0, ...markers.map((marker) => Array.from(marker).length)) - : 0; - - for (const [index, child] of children.entries()) { - const marker = markers[index] ?? "•"; - const alignedMarker = - child.type === "task_list_item" - ? marker - : ordered - ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}` - : marker; - const markerColumnWidth = - child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; - appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); - } - return runs; -} - -function appendQuoteBlock( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth: number, -): NativeMarkdownTextRun[] { - for (const [index, child] of (node.children ?? []).entries()) { - if (index > 0) { - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - } - appendRun(runs, "│\u00a0", { - ...EMPTY_CONTEXT, - role: "quote-marker", - depth, - }); - if (child.type === "paragraph") { - appendInlineChildren(runs, child, { - ...EMPTY_CONTEXT, - role: "body", - depth, - }); - } else { - appendDocumentBlock(runs, child, depth); - } - } - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - return runs; -} - -function appendTableRow( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth: number, -): NativeMarkdownTextRun[] { - const cells = node.children ?? []; - for (const [index, cell] of cells.entries()) { - if (index > 0) { - appendRun(runs, "\u00a0│\u00a0", { - ...EMPTY_CONTEXT, - role: "divider", - depth, - }); - } - appendInlineChildren(runs, cell, { - ...EMPTY_CONTEXT, - role: "body", - bold: cell.isHeader ?? false, - depth, - }); - } - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - return runs; -} - -function appendTable( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth: number, -): NativeMarkdownTextRun[] { - const visit = (child: MarkdownNode) => { - if (child.type === "table_row") { - appendTableRow(runs, child, depth); - return; - } - for (const nested of child.children ?? []) { - visit(nested); - } - }; - visit(node); - return runs; -} - -function appendDocumentBlock( - runs: NativeMarkdownTextRun[], - node: MarkdownNode, - depth = 0, -): NativeMarkdownTextRun[] { - switch (node.type) { - case "document": { - const children = node.children ?? []; - for (const [index, child] of children.entries()) { - if (index > 0) { - const previous = children[index - 1]; - appendSpacer( - runs, - child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, - ); - } - appendDocumentBlock(runs, child, depth); - } - return runs; - } - case "heading": { - const context: RunContext = { - ...EMPTY_CONTEXT, - role: "heading", - headingLevel: node.level ?? 1, - depth, - }; - appendInlineChildren(runs, node, context); - return appendBlockTerminator(runs, context); - } - case "paragraph": { - const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; - appendInlineChildren(runs, node, context); - return appendBlockTerminator(runs, context); - } - case "list": - return appendList(runs, node, depth + 1); - case "blockquote": - return appendQuoteBlock(runs, node, depth); - case "code_block": { - if (node.language) { - appendRun(runs, `${node.language.toUpperCase()}\n`, { - ...EMPTY_CONTEXT, - role: "code-language", - code: true, - depth, - }); - } - const content = nodeTextContent(node); - appendRun(runs, content, { - ...EMPTY_CONTEXT, - role: "code-block", - code: true, - depth, - }); - if (!content.endsWith("\n")) { - appendBlockTerminator(runs, { - ...EMPTY_CONTEXT, - role: "code-block", - code: true, - depth, - }); - } - return runs; - } - case "horizontal_rule": - appendRun(runs, "────────────────────────\n", { - ...EMPTY_CONTEXT, - role: "divider", - depth, - }); - return runs; - case "table": - return appendTable(runs, node, depth); - case "html_block": - appendRun(runs, inlineHtmlText(nodeTextContent(node)), { - ...EMPTY_CONTEXT, - role: "body", - depth, - }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - case "math_block": - appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - default: - appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - } -} - -function containsRichBlock(node: MarkdownNode): boolean { - if ( - node.type === "code_block" || - node.type === "blockquote" || - node.type === "table" || - node.type === "image" || - node.type === "horizontal_rule" || - node.type === "html_block" || - node.type === "math_block" - ) { - return true; - } - return (node.children ?? []).some(containsRichBlock); -} - -/** - * Sibling identity for React keys. A source offset survives appends while the - * document streams; the child index is the fallback for offset-free nodes. The - * two never share a namespace, so a positioned node cannot collide with an - * offset-free sibling whose index happens to equal its offset. - */ -export function nativeMarkdownNodePosition(node: MarkdownNode, index: number): string { - return node.beg === undefined ? `index:${index}` : `offset:${node.beg}`; -} - -export function nativeMarkdownDocumentChunks( - document: MarkdownNode, -): ReadonlyArray { - const chunks: NativeMarkdownDocumentChunk[] = []; - let selectableNodes: MarkdownNode[] = []; - let selectableStart = 0; - - const flushSelectable = () => { - const first = selectableNodes[0]; - if (!first) { - return; - } - chunks.push({ - kind: "selectable", - key: `selectable:${nativeMarkdownNodePosition(first, selectableStart)}`, - node: { - type: "document", - children: selectableNodes, - }, - }); - selectableNodes = []; - }; - - for (const [index, child] of (document.children ?? []).entries()) { - if (!containsRichBlock(child)) { - if (selectableNodes.length === 0) { - selectableStart = index; - } - selectableNodes.push(child); - continue; - } - - flushSelectable(); - chunks.push({ - kind: "rich", - key: `rich:${child.type}:${nativeMarkdownNodePosition(child, index)}`, - node: child, - }); - } - flushSelectable(); - return chunks; -} - -function topLevelNodes(node: MarkdownNode): ReadonlyArray { - return node.type === "document" ? (node.children ?? []) : [node]; -} - -export function nativeMarkdownChunkSpacing( - previous: NativeMarkdownDocumentChunk | undefined, - current: NativeMarkdownDocumentChunk, -): number { - if (!previous) { - return 0; - } - - const previousLast = topLevelNodes(previous.node).at(-1); - const currentFirst = topLevelNodes(current.node)[0]; - - if (currentFirst?.type === "heading") { - return 20; - } - if (previousLast?.type === "heading") { - return 10; - } - if (previousLast?.type === "list" && currentFirst?.type === "list") { - return 12; - } - return 14; -} - -export function nativeMarkdownDocumentRuns( - node: MarkdownNode, - skills: ReadonlyArray = [], -): ReadonlyArray { - const runs = appendDocumentBlock([], node); - while (runs.length > 0) { - const lastIndex = runs.length - 1; - const last = runs[lastIndex]; - if (!last?.text.endsWith("\n")) { - break; - } - const text = last.text.slice(0, -1); - if (text.length === 0) { - runs.pop(); - } else { - runs[lastIndex] = { ...last, text }; - } - } - return decorateMentionRuns(decorateSkillRuns(runs, skills)); -} diff --git a/apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.test.ts b/apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.test.ts deleted file mode 100644 index 19516e782524..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect, it } from "vite-plus/test"; -import { pendingCodeHighlight } from "./pendingCodeHighlight"; - -it("keeps completed colors and exact current text without retaining an edited tail", () => { - const colored = [ - [{ content: "const n = 1;", color: "red", fontStyle: 0 }], - [{ content: "partial", color: "blue", fontStyle: 0 }], - ]; - const result = pendingCodeHighlight( - "const n = 1;\npartial", - "const n = 1;\nchanged\nnext", - colored, - )!; - expect(result[0]).toBe(colored[0]); - expect(result.map((line) => line.map((token) => token.content).join("")).join("\n")).toBe( - "const n = 1;\nchanged\nnext", - ); - expect( - result - .slice(1) - .flat() - .every((token) => token.color === null), - ).toBe(true); - expect( - pendingCodeHighlight("const n = 1;\npartial", "const n = 2;\npartial", colored), - ).toBeNull(); - expect(pendingCodeHighlight("partial", "other", colored)).toBeNull(); -}); diff --git a/apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.ts b/apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.ts deleted file mode 100644 index 308997fc83a8..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { MarkdownHighlightedToken } from "./SelectableMarkdownText.types"; - -/** Keep finished lines colored while the current line awaits highlighting. */ -export function pendingCodeHighlight( - previousCode: string, - code: string, - tokens: ReadonlyArray>, -): ReadonlyArray> | null { - const end = previousCode.lastIndexOf("\n") + 1; - if (!end || !code.startsWith(previousCode.slice(0, end))) return null; - const completedLines = previousCode.slice(0, end).split("\n").length - 1; - return [ - ...tokens.slice(0, completedLines), - ...code - .slice(end) - .split("\n") - .map((content) => [{ content, color: null, fontStyle: null }]), - ]; -} diff --git a/apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.test.tsx b/apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.test.tsx deleted file mode 100644 index c4fcaa597db5..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { act, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import type { MarkdownCodeHighlighter } from "./SelectableMarkdownText.types"; -import { useHighlightedCode, type HighlightedCode } from "./useHighlightedCode"; - -type Deferred = { - readonly code: string; - readonly resolve: (tokens: HighlightedCode) => void; -}; - -/** One colored line per newline-separated line, so a line's color proves where it came from. */ -function tokensFor(code: string, color: string): HighlightedCode { - return code.split("\n").map((content) => [{ content, color, fontStyle: 0 }]); -} - -function lineText(tokens: HighlightedCode | null): string | null { - return tokens?.map((line) => line.map((token) => token.content).join("")).join("\n") ?? null; -} - -function lineColors(tokens: HighlightedCode | null): ReadonlyArray { - return tokens?.map((line) => line[0]?.color ?? null) ?? []; -} - -const deferred: Deferred[] = []; -const readResults = new Map(); -let renders = 0; -let result: HighlightedCode | null = null; -let root: Root; - -const highlightCode: MarkdownCodeHighlighter = Object.assign( - vi.fn( - (input: { code: string }) => - new Promise((resolve) => { - deferred.push({ code: input.code, resolve }); - }), - ), - { - read: vi.fn((input: { code: string }) => readResults.get(input.code)), - }, -); - -function Probe(props: { - readonly code: string; - readonly language?: string; - readonly theme?: "light" | "dark"; -}) { - const tokens = useHighlightedCode( - props.code, - props.language ?? "ts", - props.theme ?? "dark", - highlightCode, - ); - useLayoutEffect(() => { - renders += 1; - result = tokens; - }); - return null; -} - -async function render(props: Parameters[0]) { - await act(() => root.render()); -} - -async function resolveAsync(index: number, color: string) { - const pending = deferred[index]; - if (!pending) throw new Error(`no pending highlight at ${index}`); - await act(async () => { - pending.resolve(tokensFor(pending.code, color)); - }); -} - -beforeEach(async () => { - // The probe has no DOM output, but ReactDOM needs an event target. - const document = { - nodeType: 9, - addEventListener() {}, - removeEventListener() {}, - }; - const container = { - nodeType: 1, - tagName: "DIV", - namespaceURI: "http://www.w3.org/1999/xhtml", - ownerDocument: document, - addEventListener() {}, - removeEventListener() {}, - }; - vi.stubGlobal("document", document); - vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - deferred.length = 0; - readResults.clear(); - renders = 0; - result = null; - vi.mocked(highlightCode).mockClear(); - vi.mocked(highlightCode.read!).mockClear(); - root = createRoot(container as unknown as HTMLElement); -}); - -afterEach(async () => { - await act(() => root.unmount()); - vi.unstubAllGlobals(); -}); - -// Every test uses a distinct language so the module-level token cache from an -// earlier test can never satisfy a later one. -describe("useHighlightedCode", () => { - it("keeps colors from the latest synchronous read when a later read misses", async () => { - const language = "sync-baseline"; - await render({ code: "a", language }); - expect(result).toBeNull(); - await resolveAsync(0, "async"); - expect(lineColors(result)).toEqual(["async"]); - - readResults.set("a\nb", tokensFor("a\nb", "sync-1")); - readResults.set("a\nb\nc", tokensFor("a\nb\nc", "sync-2")); - const rendersBeforeAppends = renders; - await render({ code: "a\nb", language }); - await render({ code: "a\nb\nc", language }); - expect(lineColors(result)).toEqual(["sync-2", "sync-2", "sync-2"]); - // A synchronous hit is one render per append: no state update, no highlighter call. - expect(renders - rendersBeforeAppends).toBe(2); - expect(highlightCode).toHaveBeenCalledTimes(1); - - await render({ code: "a\nb\nc\nd", language }); - expect(highlightCode).toHaveBeenCalledTimes(2); - expect(lineText(result)).toBe("a\nb\nc\nd"); - // Lines before the previous code's final newline are the completed ones. - expect(lineColors(result)).toEqual(["sync-2", "sync-2", null, null]); - - await resolveAsync(1, "async-2"); - expect(lineColors(result)).toEqual(["async-2", "async-2", "async-2", "async-2"]); - }); - - it("does not show obsolete text after a non-append edit", async () => { - const language = "non-append"; - await render({ code: "a\nb", language }); - await resolveAsync(0, "async"); - readResults.set("a\nb\nc", tokensFor("a\nb\nc", "sync")); - await render({ code: "a\nb\nc", language }); - expect(lineColors(result)).toEqual(["sync", "sync", "sync"]); - - await render({ code: "x\nb\nc", language }); - expect(result).toBeNull(); - await resolveAsync(1, "async-2"); - expect(lineText(result)).toBe("x\nb\nc"); - }); - - it("never reuses colors across a language or theme change", async () => { - const language = "theme-change"; - await render({ code: "a\nb", language }); - await resolveAsync(0, "async"); - readResults.set("a\nb\nc", tokensFor("a\nb\nc", "sync")); - await render({ code: "a\nb\nc", language }); - expect(lineColors(result)).toEqual(["sync", "sync", "sync"]); - - await render({ code: "a\nb\nc\nd", language, theme: "light" }); - expect(result).toBeNull(); - await render({ code: "a\nb\nc\nd", language: `${language}-other` }); - expect(result).toBeNull(); - }); - - it("ignores an asynchronous completion that is older than the current code", async () => { - const language = "stale-async"; - await render({ code: "a\nb", language }); - await resolveAsync(0, "async"); - - await render({ code: "a\nb\nc", language }); - expect(deferred).toHaveLength(2); - expect(lineColors(result)).toEqual(["async", null, null]); - - readResults.set("a\nb\nc\nd", tokensFor("a\nb\nc\nd", "sync")); - await render({ code: "a\nb\nc\nd", language }); - expect(lineColors(result)).toEqual(["sync", "sync", "sync", "sync"]); - - await render({ code: "a\nb\nc\nd\ne", language }); - expect(deferred).toHaveLength(3); - expect(lineColors(result)).toEqual(["sync", "sync", "sync", null, null]); - - // The older request finishes after newer synchronous results exist. - await resolveAsync(1, "stale"); - expect(lineText(result)).toBe("a\nb\nc\nd\ne"); - expect(lineColors(result)).toEqual(["sync", "sync", "sync", null, null]); - - await resolveAsync(2, "async-3"); - expect(lineColors(result)).toEqual(["async-3", "async-3", "async-3", "async-3", "async-3"]); - }); -}); diff --git a/apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.ts b/apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.ts deleted file mode 100644 index dd86dc3af9af..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; - -import { pendingCodeHighlight } from "./pendingCodeHighlight"; -import type { - MarkdownCodeHighlighter, - MarkdownHighlightedToken, -} from "./SelectableMarkdownText.types"; - -export type HighlightedCode = ReadonlyArray>; - -interface HighlightedCodeResult { - readonly key: string; - readonly code: string; - readonly language: string | undefined; - readonly theme: "light" | "dark"; - readonly tokens: HighlightedCode | null; -} - -const highlightedCodeCache = new Map(); -const highlightedCodePromiseCache = new Map>(); -const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; - -function codeHighlightCacheKey( - code: string, - language: string | undefined, - theme: "light" | "dark", -): string { - return `${theme}:${language ?? "text"}:${code}`; -} - -function cacheHighlightedCode(key: string, tokens: HighlightedCode): void { - highlightedCodeCache.delete(key); - highlightedCodeCache.set(key, tokens); - - while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) { - const oldestKey = highlightedCodeCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - highlightedCodeCache.delete(oldestKey); - } -} - -function loadHighlightedCode( - code: string, - language: string | undefined, - theme: "light" | "dark", - highlightCode: MarkdownCodeHighlighter, - session: object, -): Promise { - const key = codeHighlightCacheKey(code, language, theme); - const cached = highlightedCodeCache.get(key); - if (cached) { - return Promise.resolve(cached); - } - - const pending = highlightedCodePromiseCache.get(key); - if (pending) { - return pending; - } - - const promise = highlightCode({ code, language, theme, session }) - .then((tokens) => { - cacheHighlightedCode(key, tokens); - highlightedCodePromiseCache.delete(key); - return tokens; - }) - .catch((error) => { - highlightedCodePromiseCache.delete(key); - throw error; - }); - highlightedCodePromiseCache.set(key, promise); - return promise; -} - -/** - * Tokens for a code block, or null while nothing usable exists yet. A cached or - * synchronous result renders in the same pass. Otherwise the most recent - * completed result seeds `pendingCodeHighlight` so finished lines keep their - * colors while the asynchronous highlighter catches up. - */ -export function useHighlightedCode( - code: string, - language: string | undefined, - theme: "light" | "dark", - highlightCode: MarkdownCodeHighlighter, -): HighlightedCode | null { - const [session] = useState(() => ({})); - const key = codeHighlightCacheKey(code, language, theme); - const ready = useMemo( - () => highlightedCodeCache.get(key) ?? highlightCode.read?.({ code, language, theme, session }), - [code, language, theme, key, highlightCode, session], - ); - const [highlighted, setHighlighted] = useState(() => ({ - code, - language, - theme, - key, - tokens: highlightedCodeCache.get(key) ?? null, - })); - // Synchronous reads never touch state, so each append costs one render. The - // ref remembers the newest of them, and is cleared whenever an asynchronous - // result commits so `highlighted` is the baseline again from then on. - const latestRead = useRef(null); - - useEffect(() => { - if (ready) { - latestRead.current = { code, language, theme, key, tokens: ready }; - return; - } - let active = true; - const commit = (tokens: HighlightedCode | null) => { - latestRead.current = null; - setHighlighted({ code, language, theme, key, tokens }); - }; - const cached = highlightedCodeCache.get(key); - if (cached) { - cacheHighlightedCode(key, cached); - commit(cached); - return () => { - active = false; - }; - } - - void loadHighlightedCode(code, language, theme, highlightCode, session) - .then((tokens) => { - if (active) { - commit(tokens); - } - }) - .catch(() => { - if (active) { - commit(null); - } - }); - return () => { - active = false; - }; - }, [code, highlightCode, key, language, theme, ready, session]); - - if (ready) return ready; - // oxlint-disable-next-line react/refs -- Written only after commit; a discarded render never advances it. - const baseline = latestRead.current ?? highlighted; - if (baseline.key === key) return baseline.tokens; - if (baseline.tokens && baseline.language === language && baseline.theme === theme) { - return pendingCodeHighlight(baseline.code, code, baseline.tokens); - } - return null; -} diff --git a/apps/mobile/modules/t3-markdown-text/LICENSE b/apps/mobile/modules/t3-source-text/LICENSE similarity index 100% rename from apps/mobile/modules/t3-markdown-text/LICENSE rename to apps/mobile/modules/t3-source-text/LICENSE diff --git a/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec b/apps/mobile/modules/t3-source-text/T3SourceText.podspec similarity index 77% rename from apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec rename to apps/mobile/modules/t3-source-text/T3SourceText.podspec index 0ac471faf24d..c44f8c100f6b 100644 --- a/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec +++ b/apps/mobile/modules/t3-source-text/T3SourceText.podspec @@ -4,10 +4,10 @@ package = JSON.parse(File.read(File.join(__dir__, "package.json"))) new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" Pod::Spec.new do |s| - s.name = "T3MarkdownText" + s.name = "T3SourceText" s.version = package["version"] - s.summary = "Native selectable markdown renderer for T3 Code mobile." - s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code." + s.summary = "Native selectable source text for T3 Code mobile." + s.description = "Fabric-backed styled source text with whole-document selection." s.homepage = "https://t3tools.com" s.license = { :type => "MIT", :file => "LICENSE" } s.author = { "T3 Tools" => "hello@t3tools.com" } diff --git a/apps/mobile/modules/t3-markdown-text/UPSTREAM.md b/apps/mobile/modules/t3-source-text/UPSTREAM.md similarity index 83% rename from apps/mobile/modules/t3-markdown-text/UPSTREAM.md rename to apps/mobile/modules/t3-source-text/UPSTREAM.md index 0ddc7775a9ec..4b4b6e43cdb7 100644 --- a/apps/mobile/modules/t3-markdown-text/UPSTREAM.md +++ b/apps/mobile/modules/t3-source-text/UPSTREAM.md @@ -7,6 +7,5 @@ version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`. The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under the MIT License included in this directory. -T3 Code has substantially modified and renamed the implementation, integrated -its markdown renderer, and owns the resulting module going forward. This is not +T3 Code has substantially modified and renamed the implementation, limited it to selectable source-file text, and owns the resulting module going forward. This is not an upstream package dependency or a compatibility fork. diff --git a/apps/mobile/modules/t3-source-text/index.ts b/apps/mobile/modules/t3-source-text/index.ts new file mode 100644 index 000000000000..0d2823b10af7 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/index.ts @@ -0,0 +1 @@ +export { SourceText } from "./src/SourceText"; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h b/apps/mobile/modules/t3-source-text/ios/T3SourceText.h similarity index 55% rename from apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h rename to apps/mobile/modules/t3-source-text/ios/T3SourceText.h index f9c05a19819f..6fc6f2686d46 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceText.h @@ -1,11 +1,11 @@ #import #import -#ifndef T3MarkdownTextNativeComponent_h -#define T3MarkdownTextNativeComponent_h +#ifndef T3SourceTextNativeComponent_h +#define T3SourceTextNativeComponent_h NS_ASSUME_NONNULL_BEGIN -@interface T3MarkdownText : RCTViewComponentView +@interface T3SourceText : RCTViewComponentView @end NS_ASSUME_NONNULL_END diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceText.mm b/apps/mobile/modules/t3-source-text/ios/T3SourceText.mm new file mode 100644 index 000000000000..e0a5d1a2bd72 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceText.mm @@ -0,0 +1,293 @@ +#import "T3SourceText.h" +#import "RCTFabricComponentsPlugins.h" +#import "T3SourceTextComponentDescriptor.h" +#import "T3SourceTextShadowNode.h" +#import +#import +#import +#import +#import +#import + +using namespace facebook::react; + +@interface T3SourceTextView : UITextView +@end + +@implementation T3SourceTextView +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender { + // Read-only UITextViews must still allow selecting the whole file. + if (action == @selector(selectAll:)) { + return self.selectable && self.text.length > 0 && + self.selectedRange.length < self.text.length; + } + return [super canPerformAction:action withSender:sender]; +} +@end + +@protocol T3SourceTextOutsideTapTarget +- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; +@end + +@interface T3SourceTextOutsideTapCoordinator + : NSObject + +- (instancetype)initWithWindow:(UIWindow *)window; +- (void)addTarget:(id)target; +- (void)removeTarget:(id)target; + +@end + +static const void *T3SourceTextOutsideTapCoordinatorKey = + &T3SourceTextOutsideTapCoordinatorKey; + +@implementation T3SourceTextOutsideTapCoordinator { + __weak UIWindow *_window; + UITapGestureRecognizer *_recognizer; + NSHashTable> *_targets; +} + +- (instancetype)initWithWindow:(UIWindow *)window { + if (self = [super init]) { + _window = window; + _targets = [NSHashTable weakObjectsHashTable]; + _recognizer = + [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handleTap:)]; + _recognizer.cancelsTouchesInView = NO; + _recognizer.delegate = self; + [window addGestureRecognizer:_recognizer]; + } + return self; +} + +- (void)addTarget:(id)target { + [_targets addObject:target]; +} + +- (void)removeTarget:(id)target { + [_targets removeObject:target]; + if (_targets.count > 0) { + return; + } + + UIWindow *window = _window; + [window removeGestureRecognizer:_recognizer]; + if (objc_getAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey) == + self) { + objc_setAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey, nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } +} + +- (void)handleTap:(UITapGestureRecognizer *)sender { + UIWindow *window = _window; + if (window == nil) { + return; + } + + UIView *hitView = [window hitTest:[sender locationInView:window] + withEvent:nil]; + if (hitView == nil) { + return; + } + for (id target in _targets.allObjects) { + [target clearSelectionForOutsideTapWithHitView:hitView]; + } +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer + shouldRecognizeSimultaneouslyWithGestureRecognizer: + (UIGestureRecognizer *)otherGestureRecognizer { + return YES; +} + +@end + +static T3SourceTextOutsideTapCoordinator * +T3SourceTextOutsideTapCoordinatorForWindow(UIWindow *window) { + T3SourceTextOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey); + if (coordinator == nil) { + coordinator = + [[T3SourceTextOutsideTapCoordinator alloc] initWithWindow:window]; + objc_setAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey, + coordinator, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + return coordinator; +} + +@interface T3SourceText () +@end + +@implementation T3SourceText { + UIView *_view; + T3SourceTextView *_textView; + T3SourceTextShadowNode::ConcreteState::Shared _state; + __weak UIWindow *_outsideTapWindow; + BOOL _suppressSelectionChange; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider { + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = + std::make_shared(); + _props = defaultProps; + _view = [[UIView alloc] init]; + self.contentView = _view; + self.clipsToBounds = YES; + _textView = [[T3SourceTextView alloc] init]; + _textView.scrollEnabled = NO; + _textView.editable = NO; + _textView.textContainerInset = UIEdgeInsetsZero; + _textView.textContainer.lineFragmentPadding = 0; + _textView.delegate = self; + _textView.textDragInteraction.enabled = NO; + _textView.layoutManager.usesFontLeading = NO; + [self addSubview:_textView]; + } + return self; +} + +- (void)didMoveToWindow { + [super didMoveToWindow]; + if (_outsideTapWindow == self.window) + return; + T3SourceTextOutsideTapCoordinator *coordinator = objc_getAssociatedObject( + _outsideTapWindow, T3SourceTextOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; + _outsideTapWindow = self.window; + if (_outsideTapWindow != nil) { + [T3SourceTextOutsideTapCoordinatorForWindow(_outsideTapWindow) + addTarget:self]; + } +} + +- (void)dealloc { + T3SourceTextOutsideTapCoordinator *coordinator = objc_getAssociatedObject( + _outsideTapWindow, T3SourceTextOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; +} + +- (void)prepareForRecycle { + [super prepareForRecycle]; + T3SourceTextOutsideTapCoordinator *coordinator = objc_getAssociatedObject( + _outsideTapWindow, T3SourceTextOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; + _outsideTapWindow = nil; + _state.reset(); + _textView.frame = CGRectZero; + _textView.attributedText = nil; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + if (!CGRectEqualToRect(_textView.frame, _view.frame)) + [self setNeedsDisplay]; +} + +- (void)drawRect:(CGRect)rect { + if (!_state) + return; + NSMutableAttributedString *text = [RCTNSAttributedStringFromAttributedString( + _state->getData().attributedString) mutableCopy]; + RCTApplyBaselineOffset(text); + const BOOL textChanged = + ![_textView.attributedText isEqualToAttributedString:text]; + const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); + if (!textChanged && !frameChanged) + return; + if (textChanged) { + const NSRange savedRange = _textView.selectedRange; + _suppressSelectionChange = YES; + _textView.attributedText = text; + if (savedRange.location != NSNotFound && savedRange.length > 0 && + NSMaxRange(savedRange) <= text.length) { + _textView.selectedRange = savedRange; + } + _suppressSelectionChange = NO; + } + if (frameChanged) + _textView.frame = _view.frame; +} + +- (void)updateProps:(Props::Shared const &)props + oldProps:(Props::Shared const &)oldProps { + const auto &oldViewProps = + *std::static_pointer_cast(_props); + const auto &newViewProps = + *std::static_pointer_cast(props); + if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { + _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; + } + if (oldViewProps.selectable != newViewProps.selectable) + _textView.selectable = newViewProps.selectable; + if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { + _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; + } + if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { + switch (newViewProps.ellipsizeMode) { + case T3SourceTextEllipsizeMode::Head: + _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingHead; + break; + case T3SourceTextEllipsizeMode::Middle: + _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; + break; + case T3SourceTextEllipsizeMode::Tail: + _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + break; + case T3SourceTextEllipsizeMode::Clip: + _textView.textContainer.lineBreakMode = NSLineBreakByClipping; + break; + } + } + if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { + _textView.backgroundColor = + RCTUIColorFromSharedColor(newViewProps.backgroundColor); + } + [super updateProps:props oldProps:oldProps]; +} + +- (void)updateState:(const facebook::react::State::Shared &)state + oldState:(const facebook::react::State::Shared &)oldState { + _state = + std::static_pointer_cast( + state); + [self setNeedsDisplay]; +} + +- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView { + if ([hitView isDescendantOfView:self]) + return; + UITextView *textView = _textView; + // Allow a pending native Copy action to consume its selection first. + dispatch_async(dispatch_get_main_queue(), ^{ + UITextRange *range = textView.selectedTextRange; + if (range != nil && !range.isEmpty) + textView.selectedTextRange = nil; + }); +} + +- (void)textViewDidChangeSelection:(UITextView *)textView { + if (_suppressSelectionChange || _eventEmitter == nullptr) + return; + const NSRange range = textView.selectedRange; + if (range.location == NSNotFound) + return; + std::dynamic_pointer_cast( + _eventEmitter) + ->onSelectionChange( + facebook::react::T3SourceTextEventEmitter::OnSelectionChange{ + static_cast(self.tag), static_cast(range.location), + static_cast(NSMaxRange(range))}); +} + +Class T3SourceTextCls(void) { + return T3SourceText.class; +} +@end diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextComponentDescriptor.h b/apps/mobile/modules/t3-source-text/ios/T3SourceTextComponentDescriptor.h new file mode 100644 index 000000000000..1fb4728f7a0b --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextComponentDescriptor.h @@ -0,0 +1,14 @@ +#pragma once + +#include "T3SourceTextShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3SourceTextComponentDescriptor = + ConcreteComponentDescriptor; + +void T3SourceTextSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextManager.mm b/apps/mobile/modules/t3-source-text/ios/T3SourceTextManager.mm new file mode 100644 index 000000000000..33e86d04d301 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextManager.mm @@ -0,0 +1,29 @@ +#import "RCTBridge.h" +#import +#import + +@interface T3SourceTextManager : RCTViewManager +@end + +@implementation T3SourceTextManager + +RCT_EXPORT_MODULE(T3SourceText) + +- (UIView *)view { + return [[UIView alloc] init]; +} + +@end + +@interface T3SourceTextRunManager : RCTViewManager +@end + +@implementation T3SourceTextRunManager + +RCT_EXPORT_MODULE(T3SourceTextRun) + +- (UIView *)view { + return nil; +} + +@end diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.h b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.h new file mode 100644 index 000000000000..a01cce872959 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.h @@ -0,0 +1,20 @@ +// This guard prevent this file to be compiled in the old architecture. +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import +#import + +#ifndef T3SourceTextRunNativeComponent_h +#define T3SourceTextRunNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN + +@interface T3SourceTextRun : RCTViewComponentView + +@property(nonatomic, copy, nullable) NSString *text; +@end + +NS_ASSUME_NONNULL_END + +#endif /* UitextviewViewNativeComponent_h */ +#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.mm b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.mm new file mode 100644 index 000000000000..25234e8580fd --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.mm @@ -0,0 +1,54 @@ +#import "T3SourceTextRun.h" +#import "RCTFabricComponentsPlugins.h" +#import "T3SourceTextRunComponentDescriptor.h" +#import +#import + +using namespace facebook::react; + +@interface T3SourceTextRun () + +@end + +@implementation T3SourceTextRun { + NSString *_text; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider { + return concreteComponentDescriptorProvider< + T3SourceTextRunComponentDescriptor>(); +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = + std::make_shared(); + _props = defaultProps; + } + return self; +} + +- (void)updateProps:(Props::Shared const &)props + oldProps:(Props::Shared const &)oldProps { + const auto &oldViewProps = + *std::static_pointer_cast(_props); + const auto &newViewProps = + *std::static_pointer_cast(props); + + if (newViewProps.text != oldViewProps.text) { + NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; + _text = text; + } + + [super updateProps:props oldProps:oldProps]; +} + ++ (BOOL)shouldBeRecycled { + return NO; +} + +Class T3SourceTextRunCls(void) { + return T3SourceTextRun.class; +} + +@end diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextRunComponentDescriptor.h b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRunComponentDescriptor.h new file mode 100644 index 000000000000..854ad2c6f678 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRunComponentDescriptor.h @@ -0,0 +1,14 @@ +#pragma once + +#include "T3SourceTextRunShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3SourceTextRunComponentDescriptor = + ConcreteComponentDescriptor; + +void T3SourceTextRunSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.cpp b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.cpp new file mode 100644 index 000000000000..3e9348f5dbbb --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.cpp @@ -0,0 +1,6 @@ +#include "T3SourceTextRunShadowNode.h" + +namespace facebook::react { + +extern const char T3SourceTextRunComponentName[] = "T3SourceTextRun"; +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.h b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.h new file mode 100644 index 000000000000..e4f92ea130cf --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include +#include +#include + +namespace facebook::react { +extern const char T3SourceTextRunComponentName[]; + +using T3SourceTextRunShadowNode = + ConcreteViewShadowNode; +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.h b/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.h new file mode 100644 index 000000000000..2cf761f75713 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +extern const char T3SourceTextComponentName[]; + +class T3SourceTextStateReal final { +public: + AttributedString attributedString; +}; + +class T3SourceTextShadowNode final + : public ConcreteViewShadowNode { +public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + T3SourceTextShadowNode(const ShadowNode &sourceShadowNode, + const ShadowNodeFragment &fragment); + + static ShadowNodeTraits BaseTraits() { + auto traits = ConcreteViewShadowNode::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; + } + + void layout(LayoutContext layoutContext) override; + + Size + measureContent(const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const override; + +private: + mutable AttributedString _attributedString; +}; +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.mm b/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.mm new file mode 100644 index 000000000000..e0b82bb2dbb9 --- /dev/null +++ b/apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.mm @@ -0,0 +1,171 @@ +#include "T3SourceTextShadowNode.h" +#include "T3SourceTextRunShadowNode.h" +#include +#import + +#include +#include + +namespace facebook::react { + +T3SourceTextShadowNode::T3SourceTextShadowNode( + const ShadowNode &sourceShadowNode, const ShadowNodeFragment &fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment){}; + +Size T3SourceTextShadowNode::measureContent( + const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + + auto baseTextAttributes = TextAttributes::defaultTextAttributes(); + baseTextAttributes.backgroundColor = baseProps.backgroundColor; + baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; + + Float fontSizeMultiplier = 1.0; + if (baseTextAttributes.allowFontScaling) { + fontSizeMultiplier = layoutContext.fontSizeMultiplier; + } + + auto baseAttributedString = AttributedString{}; + const auto &children = getChildren(); + for (size_t i = 0; i < children.size(); i++) { + const auto child = children[i].get(); + if (auto textViewChild = + dynamic_cast(child)) { + auto &props = textViewChild->getConcreteProps(); + auto fragment = AttributedString::Fragment{}; + auto textAttributes = TextAttributes::defaultTextAttributes(); + + textAttributes.allowFontScaling = baseProps.allowFontScaling; + textAttributes.backgroundColor = props.backgroundColor; + textAttributes.fontSize = props.fontSize * fontSizeMultiplier; + textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; + textAttributes.foregroundColor = props.color; + textAttributes.textShadowColor = props.shadowColor; + textAttributes.textShadowOffset = props.shadowOffset; + textAttributes.textShadowRadius = props.shadowRadius; + textAttributes.letterSpacing = props.letterSpacing; + textAttributes.textDecorationColor = props.textDecorationColor; + textAttributes.fontFamily = props.fontFamily; + + if (props.fontStyle == T3SourceTextRunFontStyle::Italic) { + textAttributes.fontStyle = FontStyle::Italic; + } else { + textAttributes.fontStyle = FontStyle::Normal; + } + + if (props.fontWeight == T3SourceTextRunFontWeight::Bold) { + textAttributes.fontWeight = FontWeight::Bold; + } else if (props.fontWeight == T3SourceTextRunFontWeight::UltraLight) { + textAttributes.fontWeight = FontWeight::UltraLight; + } else if (props.fontWeight == T3SourceTextRunFontWeight::Light) { + textAttributes.fontWeight = FontWeight::Light; + } else if (props.fontWeight == T3SourceTextRunFontWeight::Medium) { + textAttributes.fontWeight = FontWeight::Medium; + } else if (props.fontWeight == T3SourceTextRunFontWeight::Semibold) { + textAttributes.fontWeight = FontWeight::Semibold; + } else if (props.fontWeight == T3SourceTextRunFontWeight::Heavy) { + textAttributes.fontWeight = FontWeight::Heavy; + } else { + textAttributes.fontWeight = FontWeight::Regular; + } + + if (props.textDecorationLine == + T3SourceTextRunTextDecorationLine::LineThrough) { + textAttributes.textDecorationLineType = + TextDecorationLineType::Strikethrough; + } else if (props.textDecorationLine == + T3SourceTextRunTextDecorationLine::Underline) { + textAttributes.textDecorationLineType = + TextDecorationLineType::Underline; + } else { + textAttributes.textDecorationLineType = TextDecorationLineType::None; + } + + if (props.textDecorationStyle == + T3SourceTextRunTextDecorationStyle::Solid) { + textAttributes.textDecorationStyle = TextDecorationStyle::Solid; + } else if (props.textDecorationStyle == + T3SourceTextRunTextDecorationStyle::Dotted) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; + } else if (props.textDecorationStyle == + T3SourceTextRunTextDecorationStyle::Dashed) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; + } else if (props.textDecorationStyle == + T3SourceTextRunTextDecorationStyle::Double) { + textAttributes.textDecorationStyle = TextDecorationStyle::Double; + } + + if (props.textAlign == T3SourceTextRunTextAlign::Left) { + textAttributes.alignment = TextAlignment::Left; + } else if (props.textAlign == T3SourceTextRunTextAlign::Right) { + textAttributes.alignment = TextAlignment::Right; + } else if (props.textAlign == T3SourceTextRunTextAlign::Center) { + textAttributes.alignment = TextAlignment::Center; + } else if (props.textAlign == T3SourceTextRunTextAlign::Justify) { + textAttributes.alignment = TextAlignment::Justified; + } else if (props.textAlign == T3SourceTextRunTextAlign::Auto) { + textAttributes.alignment = TextAlignment::Natural; + } + + textAttributes.backgroundColor = props.backgroundColor; + + fragment.string = props.text; + fragment.textAttributes = textAttributes; + + baseAttributedString.appendFragment(std::move(fragment)); + } + } + + _attributedString = baseAttributedString; + + NSMutableAttributedString *convertedAttributedString = + [RCTNSAttributedStringFromAttributedString(baseAttributedString) + mutableCopy]; + // Match React Native's baseline offset in both measurement and rendering. + RCTApplyBaselineOffset(convertedAttributedString); + + const CGFloat maximumWidth = + std::isfinite(layoutConstraints.maximumSize.width) + ? layoutConstraints.maximumSize.width + : CGFLOAT_MAX; + NSTextStorage *textStorage = [[NSTextStorage alloc] + initWithAttributedString:convertedAttributedString]; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + layoutManager.usesFontLeading = NO; + NSTextContainer *textContainer = [[NSTextContainer alloc] + initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; + textContainer.lineFragmentPadding = 0; + textContainer.maximumNumberOfLines = baseProps.numberOfLines; + if (baseProps.ellipsizeMode == T3SourceTextEllipsizeMode::Head) { + textContainer.lineBreakMode = NSLineBreakByTruncatingHead; + } else if (baseProps.ellipsizeMode == T3SourceTextEllipsizeMode::Middle) { + textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; + } else if (baseProps.ellipsizeMode == T3SourceTextEllipsizeMode::Tail) { + textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + } else { + textContainer.lineBreakMode = NSLineBreakByClipping; + } + [layoutManager addTextContainer:textContainer]; + [textStorage addLayoutManager:layoutManager]; + [layoutManager ensureLayoutForTextContainer:textContainer]; + const CGRect usedRect = + [layoutManager usedRectForTextContainer:textContainer]; + + return { + std::clamp(static_cast(std::ceil(usedRect.size.width)), + layoutConstraints.minimumSize.width, + layoutConstraints.maximumSize.width), + std::clamp(static_cast(std::ceil(usedRect.size.height)), + layoutConstraints.minimumSize.height, + layoutConstraints.maximumSize.height), + }; +} + +void T3SourceTextShadowNode::layout(LayoutContext layoutContext) { + ensureUnsealed(); + setStateData(T3SourceTextStateReal{ + _attributedString, + }); +} +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-source-text/package.json b/apps/mobile/modules/t3-source-text/package.json new file mode 100644 index 000000000000..3441386059ed --- /dev/null +++ b/apps/mobile/modules/t3-source-text/package.json @@ -0,0 +1,35 @@ +{ + "name": "@t3tools/mobile-source-text", + "version": "0.0.0", + "private": true, + "files": [ + "ios", + "src", + "index.ts", + "LICENSE", + "UPSTREAM.md", + "T3SourceText.podspec", + "react-native.config.js" + ], + "main": "./index.ts", + "types": "./index.ts", + "react-native": "./index.ts", + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "codegenConfig": { + "name": "T3SourceTextSpec", + "type": "components", + "jsSrcsDir": "src", + "ios": { + "componentProvider": { + "T3SourceText": "T3SourceText", + "T3SourceTextRun": "T3SourceTextRun" + } + }, + "outputDir": { + "ios": "ios/generated" + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/react-native.config.js b/apps/mobile/modules/t3-source-text/react-native.config.js similarity index 70% rename from apps/mobile/modules/t3-markdown-text/react-native.config.js rename to apps/mobile/modules/t3-source-text/react-native.config.js index 6b10ea26eec9..33e020928492 100644 --- a/apps/mobile/modules/t3-markdown-text/react-native.config.js +++ b/apps/mobile/modules/t3-source-text/react-native.config.js @@ -2,7 +2,7 @@ module.exports = { dependency: { platforms: { ios: { - podspecPath: "T3MarkdownText.podspec", + podspecPath: "T3SourceText.podspec", }, android: null, }, diff --git a/apps/mobile/modules/t3-source-text/src/SourceText.tsx b/apps/mobile/modules/t3-source-text/src/SourceText.tsx new file mode 100644 index 000000000000..946c7553979e --- /dev/null +++ b/apps/mobile/modules/t3-source-text/src/SourceText.tsx @@ -0,0 +1,52 @@ +import React, { type Ref } from "react"; +import { Platform, StyleSheet, Text, type TextProps, type TextStyle } from "react-native"; +import T3SourceTextRunNativeComponent from "./T3SourceTextRunNativeComponent"; +import T3SourceTextNativeComponent from "./T3SourceTextNativeComponent"; +import { flattenStyles } from "./util"; + +const TextAncestorContext = React.createContext<[boolean, TextStyle]>([false, {}]); + +export type SourceTextProps = Omit & { + nativeTextRef?: Ref; + onSelectionChange?: (event: { + nativeEvent: { target: number; start: number; end: number }; + }) => void; +}; + +/** One selectable document on iOS, with nested runs carrying syntax colors. */ +export function SourceText({ style, children, nativeTextRef, ...props }: SourceTextProps) { + const [isAncestor, rootStyle] = React.useContext(TextAncestorContext); + const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); + const contextValue = React.useMemo<[boolean, TextStyle]>( + () => [true, StyleSheet.flatten([rootStyle, style])], + [rootStyle, style], + ); + if (Platform.OS !== "ios") { + const { onSelectionChange: _onSelectionChange, ...textProps } = props; + return ( + + {children} + + ); + } + const runs = React.Children.toArray(children).map((child, index) => { + if (React.isValidElement(child)) return child; + if (typeof child !== "string" && typeof child !== "number") return null; + return ( + + ); + }); + if (isAncestor) return <>{runs}; + return ( + + + {runs} + + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts b/apps/mobile/modules/t3-source-text/src/T3SourceTextNativeComponent.ts similarity index 76% rename from apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts rename to apps/mobile/modules/t3-source-text/src/T3SourceTextNativeComponent.ts index f6a78ea541eb..fc18bcf18a3a 100644 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts +++ b/apps/mobile/modules/t3-source-text/src/T3SourceTextNativeComponent.ts @@ -10,12 +10,8 @@ interface TargetedEvent { target: Int32; } -interface TextLayoutEvent extends TargetedEvent { - lines: string[]; -} - /** - * Event fired when text selection changes in the MarkdownTextPrimitive. + * Event fired when text selection changes in the SourceText. * @property target - The view tag identifier * @property start - The start index of the selected range (0-based) * @property end - The end index of the selected range (0-based, exclusive) @@ -28,29 +24,27 @@ interface SelectionChangeEvent extends TargetedEvent { type EllipsizeMode = "head" | "middle" | "tail" | "clip"; interface NativeProps extends ViewProps { - contextClipboardConfig?: string; numberOfLines?: Int32; allowFontScaling?: WithDefault; ellipsizeMode?: WithDefault; selectable?: boolean; - onTextLayout?: BubblingEventHandler; /** * Callback fired when the text selection changes. * * @example * ```tsx - * { * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end); * }} * > * Selectable text - * + * * ``` */ onSelectionChange?: BubblingEventHandler; } -export default codegenNativeComponent("T3MarkdownText", { +export default codegenNativeComponent("T3SourceText", { excludedPlatforms: ["android"], }); diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-source-text/src/T3SourceTextRunNativeComponent.ts similarity index 67% rename from apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts rename to apps/mobile/modules/t3-source-text/src/T3SourceTextRunNativeComponent.ts index 040e44bc18b5..cf0d2a6ea031 100644 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts +++ b/apps/mobile/modules/t3-source-text/src/T3SourceTextRunNativeComponent.ts @@ -1,20 +1,7 @@ import type { ColorValue, ViewProps } from "react-native"; -import type { - BubblingEventHandler, - Float, - Int32, - WithDefault, -} from "react-native/Libraries/Types/CodegenTypes"; +import type { Float, WithDefault } from "react-native/Libraries/Types/CodegenTypes"; import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; -interface TargetedEvent { - target: Int32; -} - -interface ContextMenuActionEvent extends TargetedEvent { - actionIdentifier: string; -} - type TextDecorationLine = "none" | "underline" | "line-through"; type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; @@ -46,12 +33,8 @@ interface NativeProps extends ViewProps { textDecorationColor?: ColorValue; textAlign?: WithDefault; shadowRadius?: WithDefault; - contextMenuConfig?: string; - onPress?: BubblingEventHandler; - onLongPress?: BubblingEventHandler; - onContextMenuAction?: BubblingEventHandler; } -export default codegenNativeComponent("T3MarkdownTextRun", { +export default codegenNativeComponent("T3SourceTextRun", { excludedPlatforms: ["android"], }); diff --git a/apps/mobile/modules/t3-markdown-text/src/util.ts b/apps/mobile/modules/t3-source-text/src/util.ts similarity index 83% rename from apps/mobile/modules/t3-markdown-text/src/util.ts rename to apps/mobile/modules/t3-source-text/src/util.ts index d9f33d3a2ef5..ccdd189edbea 100644 --- a/apps/mobile/modules/t3-markdown-text/src/util.ts +++ b/apps/mobile/modules/t3-source-text/src/util.ts @@ -1,10 +1,15 @@ import { type StyleProp, StyleSheet, type TextStyle } from "react-native"; -import type { NativeFontWeight } from "./T3MarkdownTextRunNativeComponent"; +import type { NativeFontWeight } from "./T3SourceTextRunNativeComponent"; export function flattenStyles(rootStyle: TextStyle, style: StyleProp) { const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle; + const textDecorationLine = flattenedStyle.textDecorationLine; return { ...flattenedStyle, + textDecorationLine: + textDecorationLine === "underline" || textDecorationLine === "line-through" + ? textDecorationLine + : ("none" as const), fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"), backgroundColor: flattenedStyle.backgroundColor ? flattenedStyle.backgroundColor diff --git a/apps/mobile/package.json b/apps/mobile/package.json index dba137f30472..91e77c74ff30 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -40,7 +40,7 @@ "config:preview": "APP_VARIANT=preview expo config", "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", - "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs", + "sync:pierre-icons": "node scripts/sync-pierre-file-icons.mjs", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", "test": "vp test run", "typecheck": "tsc --noEmit" @@ -66,8 +66,8 @@ "@shikijs/themes": "4.2.0", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", - "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", + "@t3tools/mobile-source-text": "file:./modules/t3-source-text", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/shared": "workspace:*", "@tabler/icons-react-native": "^3.44.0", @@ -112,11 +112,10 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.3", + "react-native-enriched-markdown": "file:./deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz", "react-native-gesture-handler": "~2.32.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.21.13", - "react-native-nitro-markdown": "^0.5.0", - "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.5.5", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", @@ -137,8 +136,9 @@ "tailwindcss": "^4.0.0", "typescript": "catalog:" }, - "overrides": { - "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz" + "enriched-markdown": { + "enableMath": false, + "enableVideo": false }, "expo": { "install": { diff --git a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs b/apps/mobile/scripts/sync-pierre-file-icons.mjs similarity index 90% rename from apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs rename to apps/mobile/scripts/sync-pierre-file-icons.mjs index fdbb03382eef..edb0cb467592 100644 --- a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs +++ b/apps/mobile/scripts/sync-pierre-file-icons.mjs @@ -6,10 +6,10 @@ import * as NodeURL from "node:url"; import { getBuiltInSpriteSheet } from "@pierre/trees"; const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); -const moduleDirectory = NodePath.resolve(scriptDirectory, ".."); -const repositoryRoot = NodePath.resolve(moduleDirectory, "../../../.."); -const outputDirectory = NodePath.join(moduleDirectory, "assets/file-icons"); -const generatedModulePath = NodePath.join(moduleDirectory, "src/markdownFileIcons.generated.ts"); +const appDirectory = NodePath.resolve(scriptDirectory, ".."); +const repositoryRoot = NodePath.resolve(appDirectory, "../.."); +const outputDirectory = NodePath.join(appDirectory, "assets/file-icons"); +const generatedModulePath = NodePath.join(appDirectory, "src/lib/markdownFileIcons.generated.ts"); const webIconSource = NodeFS.readFileSync( NodePath.join(repositoryRoot, "apps/web/src/pierre-icons.ts"), "utf8", @@ -129,6 +129,6 @@ for (const [token, symbolId] of Object.entries(customIcons)) { const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort(); const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens - .map((token) => ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`) + .map((token) => ` ${token}: require("../../assets/file-icons/pierre_${token}.png"),`) .join("\n")}\n} as const satisfies Readonly>;\n`; NodeFS.writeFileSync(generatedModulePath, generatedSource); diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx index cb73f5b7b180..7ae7a4b70090 100644 --- a/apps/mobile/src/components/PierreEntryIcon.tsx +++ b/apps/mobile/src/components/PierreEntryIcon.tsx @@ -1,8 +1,8 @@ import { SymbolView } from "../components/AppSymbol"; import { Image, type ImageStyle, type StyleProp } from "react-native"; -import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; -import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { markdownFileIconSource } from "../lib/markdownFileIcons"; +import { resolveMarkdownFileIcon } from "../lib/markdownLinks"; export function PierreEntryIcon(props: { readonly path: string; diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index dcd29d71d5c9..0aa4e9d72356 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -1,29 +1,25 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; +import { resolveMobileMarkdownMediaSource } from "../../lib/markdownMediaSource"; import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects"; import { useCallback, useMemo, useState } from "react"; -import { - Markdown, - type CustomRenderers, - type NodeStyleOverrides, - type PartialMarkdownTheme, -} from "react-native-nitro-markdown"; -import { RefreshControl, ScrollView, Text as NativeText, View } from "react-native"; +import { RefreshControl, ScrollView, View } from "react-native"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useFontFamily } from "../../lib/useFontFamily"; -import { - resolveMarkdownFontSizes, - resolveNativeMarkdownTypography, -} from "../../lib/appearancePreferences"; +import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { ThreadMarkdownImage, + ThreadMarkdownImageView, ThreadMarkdownImageUnavailable, } from "../threads/ThreadMarkdownImage"; +import { ThreadMarkdownVideo } from "../threads/ThreadMarkdownVideo"; +import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; +import { normalizeNativeMarkdownUrl } from "../../lib/markdownLinks"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { - hasNativeSelectableMarkdownText, SelectableMarkdownText, type MarkdownImageRenderer, type NativeMarkdownTextStyle, @@ -31,18 +27,11 @@ import { import { resolveWorkspaceFilePath } from "./filePath"; interface MarkdownPreviewStyles { - readonly theme: PartialMarkdownTheme; - readonly styles: NodeStyleOverrides; - readonly renderers: CustomRenderers; readonly nativeTextStyle: NativeMarkdownTextStyle; } -function useMarkdownPreviewStyles(renderImage?: MarkdownImageRenderer): MarkdownPreviewStyles { +function useMarkdownPreviewStyles(): MarkdownPreviewStyles { const { appearance } = useAppearancePreferences(); - const markdownFontSizes = useMemo( - () => resolveMarkdownFontSizes(appearance.baseFontSize), - [appearance.baseFontSize], - ); const nativeMarkdownTypography = useMemo( () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], @@ -52,104 +41,14 @@ function useMarkdownPreviewStyles(renderImage?: MarkdownImageRenderer): Markdown const strong = theme["--color-md-strong"]; const link = theme["--color-md-link"]; const blockquoteBorder = theme["--color-md-blockquote-border"]; - const blockquoteBackground = theme["--color-md-blockquote-bg"]; const codeBackground = theme["--color-md-code-bg"]; const codeText = theme["--color-md-code-text"]; const horizontalRule = theme["--color-md-hr"]; const regularFontFamily = useFontFamily("regular"); - const mediumFontFamily = useFontFamily("medium"); const boldFontFamily = useFontFamily("bold"); return useMemo(() => { - const renderers: CustomRenderers = { - link: ({ href, children }) => ( - { - if (href) { - void tryOpenExternalUrl(href, "markdown-link"); - } - }} - style={{ - color: link, - textDecorationLine: "none", - }} - > - {children} - - ), - image: ({ node }) => - node.href && renderImage - ? (renderImage({ - href: node.href, - alt: node.alt ?? null, - title: node.title ?? null, - }) ?? undefined) - : undefined, - }; - return { - theme: { - colors: { - text: body, - heading: strong, - link, - blockquote: blockquoteBorder, - border: horizontalRule, - surface: "transparent", - surfaceLight: blockquoteBackground, - accent: link, - tableBorder: horizontalRule, - tableHeader: blockquoteBackground, - tableHeaderText: strong, - tableRowOdd: blockquoteBackground, - tableRowEven: "transparent", - code: codeText, - codeBackground, - }, - }, - styles: { - text: { - color: body, - fontFamily: regularFontFamily, - fontSize: markdownFontSizes.m, - lineHeight: markdownFontSizes.bodyLineHeight, - }, - heading: { - color: strong, - fontFamily: boldFontFamily, - }, - strong: { - color: strong, - fontFamily: boldFontFamily, - }, - link: { - color: link, - fontFamily: mediumFontFamily, - }, - blockquote: { - backgroundColor: blockquoteBackground, - borderLeftColor: blockquoteBorder, - borderLeftWidth: 3, - paddingLeft: 12, - }, - code: { - backgroundColor: codeBackground, - color: codeText, - fontFamily: "ui-monospace", - }, - codeBlock: { - backgroundColor: codeBackground, - borderRadius: 12, - color: codeText, - fontFamily: "ui-monospace", - padding: 12, - }, - hr: { - backgroundColor: horizontalRule, - }, - }, - renderers, nativeTextStyle: { color: body, strongColor: strong, @@ -172,18 +71,14 @@ function useMarkdownPreviewStyles(renderImage?: MarkdownImageRenderer): Markdown }, }; }, [ - blockquoteBackground, blockquoteBorder, body, codeBackground, codeText, horizontalRule, link, - markdownFontSizes, - mediumFontFamily, nativeMarkdownTypography, regularFontFamily, - renderImage, strong, boldFontFamily, ]); @@ -200,6 +95,7 @@ export function FileMarkdownPreview(props: { readonly onRefresh?: () => Promise | void; }) { const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const [expandedFile, setExpandedFile] = useState(null); const handlePullToRefresh = useCallback(async () => { if (!props.onRefresh) { return; @@ -217,35 +113,122 @@ export function FileMarkdownPreview(props: { ); const renderImage = useCallback( (image) => { - const media = resolveMediaSource(image.href, { + const media = resolveMobileMarkdownMediaSource(image.href, { threadId: props.threadId ?? undefined, workspaceRoot: markdownDirectory, imageEmbed: true, }); if (media?.access === "direct") { - return null; + if (media.kind === "video") { + return ( + + ); + } + return ( + + ); } - if ( - props.captured || - media === null || - media.kind !== "image" || - media.access === "unavailable" - ) { + if (props.captured || media === null || media.access === "unavailable") { return ; } + if (media.kind === "video") { + if (!props.threadId) { + return ( + + ); + } + const preview = resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: markdownDirectory, + }); + return preview?.kind === "video" ? ( + + ) : ( + + ); + } return ( undefined} + onPressPreview={setExpandedFile} /> ); }, [markdownDirectory, props.environmentId, props.threadId, props.captured], ); - const styles = useMarkdownPreviewStyles(renderImage); + const styles = useMarkdownPreviewStyles(); + const resolveImageSource = useMarkdownImageSource({ + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: markdownDirectory, + captured: props.captured, + }); + const onImagePress = useCallback( + (href: string) => { + const direct = resolveMobileMarkdownMediaSource(href, { + threadId: props.threadId ?? undefined, + workspaceRoot: markdownDirectory, + imageEmbed: true, + }); + if (direct?.access === "direct" && direct.kind === "image") { + setExpandedFile({ + kind: "image", + uri: normalizeNativeMarkdownUrl(direct.uri), + name: direct.name, + }); + return; + } + if (props.captured) return; + if (!props.threadId) { + if (direct?.access === "environment" && direct.kind === "image") { + setExpandedFile({ + kind: "image", + name: direct.name, + environmentId: props.environmentId, + resource: direct.resource, + srcFragment: direct.srcFragment, + }); + } + return; + } + const media = resolveMarkdownMediaPreview(href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: markdownDirectory, + }); + if (media?.kind === "image") setExpandedFile(media.source); + }, + [markdownDirectory, props.environmentId, props.threadId, props.captured], + ); const onLinkPress = useCallback((href: string) => { void tryOpenExternalUrl(href, "markdown-link"); }, []); @@ -264,23 +247,15 @@ export function FileMarkdownPreview(props: { } > - {hasNativeSelectableMarkdownText() ? ( - - ) : ( - - {props.markdown} - - )} + + setExpandedFile(null)} /> ); diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index c7c2310ba936..8d52dfa593f9 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -31,7 +31,7 @@ import { NATIVE_SOURCE_CONTENT_WIDTH, nativeSourceRowId, } from "./nativeSourceFileAdapter"; -import { MarkdownTextPrimitive } from "@t3tools/mobile-markdown-text/primitive"; +import { SourceText } from "@t3tools/mobile-source-text"; import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; @@ -270,14 +270,13 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { [codeSurface, codeWordBreak, targetIndex, tokens], ); - // One selectable text for the whole file. On iOS `uiTextView` renders a real `UITextView`, + // One selectable text for the whole file. On iOS the primitive renders a real `UITextView`, // which selects across every line, wraps, and lays out long documents through TextKit; on // Android the primitive is an RN `Text`, which selects across its nested children. Either // way "select all" takes the file rather than a line, which a `FlatList` row can never do // because each row is its own selection scope. const selectableBlock = props.selectable ? ( - 0 ? lineTokens.map((token, tokenIndex) => ( - {token.content} - + )) : line; return ( - + {body} {index < lines.length - 1 ? "\n" : ""} - + ); }) : normalizedContents} - + ) : null; const list = ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 20848cd2ad10..5f11c8e3e211 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -17,11 +17,11 @@ import type { TurnId, } from "@t3tools/contracts"; import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; -import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { parseComposerContextHref, collectComposerContextReferences, replaceComposerContextReferences, + formatComposerContextReference, } from "@t3tools/shared/composerContextReferences"; import { ComposerContextSheet } from "../../components/ComposerContextSheet"; import { writeComposerContextClipboard } from "../../lib/composerContextClipboard"; @@ -48,7 +48,6 @@ import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { - createContext, memo, useCallback, useContext, @@ -61,12 +60,6 @@ import { type ReactNode, type RefObject, } from "react"; -import { - Markdown, - type CustomRenderers, - type NodeStyleOverrides, - type PartialMarkdownTheme, -} from "react-native-nitro-markdown"; import { ActivityIndicator, Alert, @@ -76,10 +69,7 @@ import { type NativeScrollEvent, type NativeSyntheticEvent, Pressable, - ScrollView, StyleSheet, - Text as NativeText, - type ColorValue, useWindowDimensions, View, } from "react-native"; @@ -97,12 +87,12 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; -import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { - hasNativeSelectableMarkdownText, SelectableMarkdownText, type MarkdownFileContextMenu, type MarkdownImageRenderer, + type MarkdownLinkCustomization, + type MarkdownImageSourceResolver, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; @@ -110,17 +100,11 @@ import { import { AppText as Text } from "../../components/AppText"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; -import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; -import { - attachmentVideoPreviewSource, - mediaVideoPreviewUri, - mediaVideoThumbnailKey, - type MediaVideoPreviewSource, -} from "../../lib/videoPreviewSource"; +import { attachmentVideoPreviewSource } from "../../lib/videoPreviewSource"; import { CopyTextButton } from "../../components/CopyTextButton"; import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection"; -import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; import { ReviewCommentCard, useReviewCommentColors, @@ -133,20 +117,16 @@ import { deriveThreadWorkLogSizing, type LayoutVariant, } from "../../lib/layout"; -import { - resolveMarkdownFontSizes, - resolveNativeMarkdownTypography, -} from "../../lib/appearancePreferences"; +import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; -import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons"; +import { markdownFileIconSource } from "../../lib/markdownFileIcons"; +import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation"; +import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; import { normalizeNativeMarkdownUrl, - resolveMarkdownInlineCodePresentation, - resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation, -} from "@t3tools/mobile-markdown-text/links"; +} from "../../lib/markdownLinks"; import { deriveThreadFeedPresentation, deriveUnsettledTurnId, @@ -173,13 +153,7 @@ import { } from "./thread-work-log"; import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; -import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { - assetEnvironment, - useAssetUrl, - useAssetUrlState, - useRefreshAssetUrl, -} from "../../state/assets"; +import { assetEnvironment, useAssetUrl, useRefreshAssetUrl } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import { useThreadSelection } from "../../state/use-thread-selection"; @@ -558,7 +532,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) { ); } -const ThreadMediaVisibleContext = createContext(false); // LegendList only computes hook visibility when the list has a viewability config. const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; @@ -570,148 +543,15 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) { return {props.children}; } -function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) { - const { source } = props; - const visible = useContext(ThreadMediaVisibleContext); - const thumbnailKey = mediaVideoThumbnailKey(source); - const asset = useAssetUrlState( - "environmentId" in source ? source.environmentId : null, - "resource" in source ? source.resource : null, - ); - const refreshAssetUrl = useRefreshAssetUrl( - "environmentId" in source ? source.environmentId : null, - "resource" in source ? source.resource : null, - ); - const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); - return ( - mediaVideoPreviewUri(source, await refreshAssetUrl()) - : undefined - } - name={source.name} - thumbnailKey={thumbnailKey} - thumbnailVisible={visible} - unavailable={"resource" in source && asset._tag === "Failure"} - actionsSource={source.actionsSource} - /> - ); -} - -const MARKDOWN_MONO_FONT = Platform.select({ - ios: "ui-monospace", - android: "monospace", - default: "monospace", -}); - interface MarkdownStyleSets { readonly user: MarkdownStyleSet; readonly assistant: MarkdownStyleSet; } interface MarkdownStyleSet { - readonly theme: PartialMarkdownTheme; - readonly styles: NodeStyleOverrides; - readonly renderers: CustomRenderers; readonly nativeTextStyle: NativeMarkdownTextStyle; } -const failedMarkdownFaviconHosts = new Set(); -const MarkdownLinkLabelContext = createContext(false); -const markdownLinkStyles = StyleSheet.create({ - inlineIcon: { - width: 14, - height: 14, - marginHorizontal: 3, - transform: [{ translateY: 2 }], - flexShrink: 0, - }, - favicon: { - borderRadius: 3, - }, -}); - -const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { - readonly children: ReactNode; - readonly color: string; - readonly host: string; - readonly href: string; - readonly onPress: (href: string) => void; -}) { - const [failedHost, setFailedHost] = useState(null); - const linkIcon = resolveMarkdownLinkIcon(props.host); - const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`); - - return ( - props.onPress(props.href)} - style={{ - color: props.color, - textDecorationLine: "none", - }} - > - {linkIcon ? ( - - ) : faviconUrl !== null && - failedHost !== props.host && - !failedMarkdownFaviconHosts.has(props.host) ? ( - { - failedMarkdownFaviconHosts.add(props.host); - setFailedHost(props.host); - }} - /> - ) : ( - {" ◉ "} - )} - {props.children} - - ); -}); - -function MarkdownInlineCode(props: { - readonly content: string; - readonly textColor: string; - readonly codeColor: string; - readonly fontSize: number; - readonly lineHeight: number; - readonly onLinkPress: (href: string) => void; -}) { - const insideLink = useContext(MarkdownLinkLabelContext); - const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content); - return ( - props.onLinkPress(presentation.href) : undefined} - style={{ - color: presentation ? props.textColor : props.codeColor, - fontSize: props.fontSize, - lineHeight: props.lineHeight, - }} - > - {presentation ? ( - - ) : null} - {presentation?.label ?? props.content} - - ); -} - const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< CodexArtifactTemplate["artifactKind"], AppSymbolName @@ -774,9 +614,12 @@ function ArtifactTemplateCard(props: { /** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ interface MarkdownLinkHandlers { + readonly resolveImageSource?: MarkdownImageSourceResolver; + readonly onImagePress?: (href: string) => void; readonly onLinkPress: (href: string) => void; readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; readonly onFileContextMenuAction: (href: string, actionId: string) => void; + readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined; } const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { @@ -805,7 +648,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { if (segment.markdown.trim().length === 0) return null; const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); - return hasNativeSelectableMarkdownText() ? ( + return ( - ) : ( - - {markdown} - ); }); }); -function MarkdownCodeBlock(props: { - readonly backgroundColor: string; - readonly borderColor: string; - readonly content: string; - readonly copyTintColor: ColorValue; - readonly headerTextColor: string; - readonly fontSize: number; - readonly highlightCode: boolean; - readonly language?: string | null; - readonly lineHeight: number; - readonly textColor: string; - readonly theme: ReviewDiffTheme; -}) { - const content = props.content.replace(/\n$/, ""); - const languageLabel = props.language?.trim() || "text"; - const highlighted = useMarkdownCodeHighlight({ - code: content, - enabled: props.highlightCode && Boolean(props.language?.trim()), - language: props.language, - theme: props.theme, - }); - let tokenOffset = 0; - - return ( - - - - {languageLabel} - - - - - - {highlighted - ? highlighted.map((line, lineIndex) => { - const lineStartOffset = tokenOffset; - const lineText = line.map((token) => token.content).join(""); - const renderedLine = ( - - {line.map((token) => { - const startOffset = tokenOffset; - tokenOffset += token.content.length; - const fontStyle = - token.fontStyle !== null && (token.fontStyle & 1) === 1 - ? ("italic" as const) - : ("normal" as const); - const fontWeight = - token.fontStyle !== null && (token.fontStyle & 2) === 2 - ? ("700" as const) - : ("400" as const); - - return ( - - {token.content} - - ); - })} - {lineIndex + 1 < highlighted.length ? "\n" : ""} - - ); - if (lineIndex + 1 < highlighted.length) { - tokenOffset += 1; - } - return renderedLine; - }) - : content} - - - - ); -} - -function useMarkdownStyles( - onLinkPress: (href: string) => void, - renderImage: MarkdownImageRenderer, -): MarkdownStyleSets { - const { appearance, themeAppearance } = useAppearancePreferences(); - const markdownFontSizes = useMemo( - () => resolveMarkdownFontSizes(appearance.baseFontSize), - [appearance.baseFontSize], - ); +function useMarkdownStyles(): MarkdownStyleSets { + const { appearance } = useAppearancePreferences(); const nativeMarkdownTypography = useMemo( () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); - const themeMode = themeAppearance; const theme = useUniwindTheme(); const markdownBodyColor = theme["--color-md-body"]; const markdownStrongColor = theme["--color-md-strong"]; const markdownLinkColor = theme["--color-md-link"]; - const markdownBlockquoteBg = theme["--color-md-blockquote-bg"]; const markdownBlockquoteBorder = theme["--color-md-blockquote-border"]; const markdownCodeBg = theme["--color-md-code-bg"]; const markdownCodeText = theme["--color-md-code-text"]; @@ -979,289 +686,14 @@ function useMarkdownStyles( const markdownUserCodeText = theme["--color-md-user-code-text"]; const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; - const markdownUserFenceText = theme["--color-md-user-fence-text"]; - const iconSubtleColor = theme["--color-icon-subtle"]; const inlineSkillForeground = theme["--color-inline-skill-foreground"]; const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"]; - const userBubbleForegroundMuted = theme["--color-user-bubble-foreground-muted"]; const regularFontFamily = useFontFamily("regular"); const boldFontFamily = useFontFamily("bold"); return useMemo(() => { - const baseTheme: PartialMarkdownTheme = { - colors: { - text: markdownBodyColor, - heading: markdownStrongColor, - link: markdownLinkColor, - blockquote: markdownBlockquoteBorder, - border: markdownHrColor, - surface: "transparent", - surfaceLight: markdownBlockquoteBg, - accent: markdownLinkColor, - tableBorder: markdownHrColor, - tableHeader: markdownBlockquoteBg, - tableHeaderText: markdownStrongColor, - tableRowOdd: "transparent", - tableRowEven: "transparent", - }, - spacing: { - xs: 4, - s: 4, - m: 8, - l: 8, - xl: 16, - }, - fontSizes: { - s: markdownFontSizes.s, - m: markdownFontSizes.m, - h1: markdownFontSizes.h1, - h2: markdownFontSizes.h2, - h3: markdownFontSizes.h3, - h4: markdownFontSizes.h4, - h5: markdownFontSizes.h5, - h6: markdownFontSizes.h6, - }, - fontFamilies: { - regular: regularFontFamily, - heading: boldFontFamily, - mono: MARKDOWN_MONO_FONT, - }, - headingWeight: "700", - borderRadius: { - s: 4, - m: 8, - l: 12, - }, - showCodeLanguage: false, - }; - - const baseStyles: NodeStyleOverrides = { - document: { flexShrink: 1 }, - paragraph: { marginTop: 0, marginBottom: 10 }, - list: { marginTop: 4, marginBottom: 8 }, - list_item: { marginTop: 0, marginBottom: 4 }, - task_list_item: { marginTop: 0, marginBottom: 4 }, - text: { lineHeight: markdownFontSizes.bodyLineHeight }, - bold: { - fontWeight: "700", - color: markdownStrongColor, - fontFamily: boldFontFamily, - }, - italic: { fontStyle: "italic" }, - link: { - color: markdownLinkColor, - textDecorationLine: "underline" as const, - }, - blockquote: { - borderLeftWidth: 2, - borderLeftColor: markdownBlockquoteBorder, - paddingLeft: 11, - paddingVertical: 2, - marginLeft: 0, - marginVertical: 10, - }, - heading: { - fontFamily: boldFontFamily, - color: markdownStrongColor, - marginTop: 18, - marginBottom: 8, - }, - horizontal_rule: { - backgroundColor: markdownHrColor, - height: 1, - marginVertical: 12, - }, - }; - - const createMarkdownRenderers = ( - inlineTextColor: string, - inlineCodeTextColor: string, - blockBackgroundColor: string, - blockTextColor: string, - copyTintColor: ColorValue, - preserveSoftBreaks: boolean, - highlightCode: boolean, - ): CustomRenderers => ({ - link: ({ children, href = "" }) => { - const presentation = resolveMarkdownLinkPresentation(href); - if (presentation.kind === "file") { - return ( - onLinkPress(href)} - style={{ color: inlineTextColor }} - > - - {presentation.label} - - ); - } - if (presentation.kind === "external") { - return ( - - - {children} - - - ); - } - const linkHref = presentation.href; - return ( - - { - void tryOpenExternalUrl(linkHref, "markdown-link"); - } - : undefined - } - style={{ color: markdownLinkColor }} - > - {children} - - - ); - }, - list: ({ node, Renderer, ordered = false, start = 1 }) => ( - - {node.children?.map((child, index) => { - const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; - if (child.type === "task_list_item") { - return ( - - ); - } - return ( - - - {ordered ? `${start + index}.` : "•"} - - - - - - ); - })} - - ), - image: ({ node }) => - node.href - ? (renderImage({ - href: node.href, - alt: node.alt ?? null, - title: node.title ?? null, - }) ?? undefined) - : undefined, - code_inline: ({ content }) => ( - - ), - ...(preserveSoftBreaks - ? { - soft_break: () => {"\n"}, - } - : {}), - code_block: ({ content = "", language }) => ( - - ), - }); - - const userTheme: PartialMarkdownTheme = { - ...baseTheme, - colors: { - ...baseTheme.colors, - text: markdownUserBodyColor, - heading: markdownUserBodyColor, - link: markdownUserBodyColor, - code: markdownUserCodeText, - codeBackground: markdownUserCodeBg, - border: markdownUserFenceBg, - }, - }; - const userStyles: NodeStyleOverrides = { - ...baseStyles, - paragraph: { marginTop: 0, marginBottom: 0 }, - bold: { - fontWeight: "700", - color: markdownUserBodyColor, - fontFamily: boldFontFamily, - }, - heading: { - ...baseStyles.heading, - color: markdownUserBodyColor, - marginTop: 8, - marginBottom: 4, - }, - link: { - color: markdownUserBodyColor, - textDecorationLine: "underline" as const, - }, - }; - - const assistantTheme: PartialMarkdownTheme = { - ...baseTheme, - colors: { - ...baseTheme.colors, - code: markdownCodeText, - codeBackground: markdownCodeBg, - border: markdownCodeBg, - }, - }; - const assistantStyles: NodeStyleOverrides = { - ...baseStyles, - }; - return { user: { - theme: userTheme, - styles: userStyles, - renderers: createMarkdownRenderers( - markdownUserCodeText, - markdownUserInlineCodeText, - markdownUserFenceBg, - markdownUserFenceText, - userBubbleForegroundMuted, - true, - false, - ), nativeTextStyle: { color: markdownUserBodyColor, strongColor: markdownUserBodyColor, @@ -1285,17 +717,6 @@ function useMarkdownStyles( }, }, assistant: { - theme: assistantTheme, - styles: assistantStyles, - renderers: createMarkdownRenderers( - markdownCodeText, - markdownInlineCodeText, - markdownCodeBg, - markdownCodeText, - iconSubtleColor, - false, - true, - ), nativeTextStyle: { color: markdownBodyColor, strongColor: markdownStrongColor, @@ -1322,14 +743,11 @@ function useMarkdownStyles( }, [ boldFontFamily, contextChipBorderColor, - iconSubtleColor, inlineSkillForeground, - markdownBlockquoteBg, markdownBlockquoteBorder, markdownBodyColor, markdownCodeBg, markdownCodeText, - markdownFontSizes, markdownHrColor, markdownInlineCodeText, markdownLinkColor, @@ -1338,14 +756,9 @@ function useMarkdownStyles( markdownUserCodeBg, markdownUserCodeText, markdownUserFenceBg, - markdownUserFenceText, markdownUserInlineCodeText, nativeMarkdownTypography, - onLinkPress, regularFontFamily, - renderImage, - themeMode, - userBubbleForegroundMuted, userBubbleSkillForeground, ]); } @@ -1816,10 +1229,58 @@ function UserMessageContent(props: UserMessageContentProps) { }; return ( <> - { + const reference = parseComposerContextHref(href); + if (!reference) return props.linkHandlers.fileContextMenu(href); + const record = props.context?.records.find( + (item) => item.contextId === reference.contextId, + ); + return { + title: record?.label ?? "Context unavailable", + actions: [ + { id: "open-context", title: "Open context" }, + { id: "copy-context", title: "Copy context", disabled: !record }, + ], + }; + }, + onFileContextMenuAction: (href, actionId) => { + const reference = parseComposerContextHref(href); + if (!reference) return props.linkHandlers.onFileContextMenuAction(href, actionId); + if (actionId === "open-context") return onLinkPress(href); + const record = props.context?.records.find( + (item) => item.contextId === reference.contextId, + ); + if (actionId === "copy-context" && record) { + void writeComposerContextClipboard( + formatComposerContextReference({ + ...reference, + label: record.label, + }), + { + version: 1, + source: { environmentId: props.environmentId }, + records: [record], + }, + ) + .then(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)) + .catch(() => { + Alert.alert("Could not copy context", "Try copying this context again."); + }); + } + }, + linkCustomization: (href) => { + const presentation = enrichedContextLinkPresentation(href, props.context?.records); + return presentation + ? { color: presentation.color, icon: markdownFileIconSource(presentation.icon) } + : props.linkHandlers.linkCustomization?.(href); + }, + }} /> {selected ? ( segment.kind === "review-comment"); - // A message can hold both a review comment and context chips. The fragment travels with every - // text run, so copying from the segmented branch carries the same context as the plain one. - const contextClipboardFragment = props.context - ? (encodeComposerContextFragment({ - version: 1, - source: { environmentId: props.environmentId }, - records: props.context.records, - }) ?? undefined) - : undefined; if (!hasReviewComment) { - if (hasNativeSelectableMarkdownText()) { - return ( - - ); - } return ( - - {text} - + ); } @@ -1891,27 +1330,16 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { return null; } - return hasNativeSelectableMarkdownText() ? ( + return ( - ) : ( - - {text} - ); })} @@ -2166,9 +1594,29 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], ); + const resolveImageSource = useMarkdownImageSource({ + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot ?? null, + }); + const onMarkdownImagePress = useCallback( + (href: string) => { + const media = resolveMarkdownMediaPreview(href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + }); + if (media?.kind === "image") setExpandedFile(media.source); + else if (media?.kind === "video") setExpandedVideo(media.source); + else onMarkdownLinkPress(href); + }, + [onMarkdownLinkPress, props.environmentId, props.threadId, props.workspaceRoot], + ); const markdownLinkHandlers = useMemo( () => ({ onLinkPress: onMarkdownLinkPress, + onImagePress: onMarkdownImagePress, + resolveImageSource, fileContextMenu: (href) => { const target = resolveFileChipTarget(href, props.workspaceRoot); return target ? fileChipMenu(target) : undefined; @@ -2192,7 +1640,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, }), - [onMarkdownLinkPress, props.workspaceRoot, shareFileChip], + [ + onMarkdownLinkPress, + onMarkdownImagePress, + props.workspaceRoot, + resolveImageSource, + shareFileChip, + ], ); const renderMarkdownImage = useCallback( (image) => { @@ -2215,6 +1669,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( void; }) { @@ -126,6 +130,7 @@ export function ThreadMarkdownImageView(props: { setFailedUri(props.uri)} /> @@ -147,22 +152,34 @@ function ThreadMarkdownImageRequest(props: { readonly uri: string; readonly onLoad: (sourceSize: { width: number; height: number }) => void; readonly onError: () => void; + readonly format?: "svg"; }) { const [loaded, setLoaded] = useState(false); return ( <> - { - setLoaded(true); - props.onLoad(event.nativeEvent.source); - }} - onError={props.onError} - style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} - /> + {props.format === "svg" ? ( + { + setLoaded(true); + if (size) props.onLoad(size); + }} + onError={props.onError} + /> + ) : ( + { + setLoaded(true); + props.onLoad(event.nativeEvent.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + )} {loaded ? null : ( void; + readonly onError: () => void; +}) { + const [ast, setAst] = useState(null); + const onLoad = useEffectEvent(props.onLoad); + const onError = useEffectEvent(props.onError); + useEffect(() => { + const controller = new AbortController(); + const load = async () => { + if (props.uri.startsWith("data:image/svg+xml")) { + const comma = props.uri.indexOf(","); + if (comma < 0) throw new Error("SVG data URI has no content"); + const content = decodeURIComponent(props.uri.slice(comma + 1)); + return props.uri.slice(0, comma).includes(";base64") + ? new TextDecoder().decode( + Uint8Array.from(atob(content), (character) => character.charCodeAt(0)), + ) + : content; + } + const response = await fetch(props.uri, { signal: controller.signal }); + if (!response.ok && !(response.status === 0 && props.uri.startsWith("file://"))) + throw new Error(`SVG request failed: ${response.status}`); + return response.text(); + }; + void load() + .then((xml) => { + const parsed = parse(xml); + if (!parsed) throw new Error("SVG has no root element"); + if (controller.signal.aborted) return; + setAst(parsed); + onLoad(resolveSvgImageSize(parsed.props)); + }) + .catch(() => { + if (!controller.signal.aborted) onError(); + }); + return () => controller.abort(); + }, [props.uri]); + return ; +} + /** Environment-hosted image that loads through a signed asset URL. */ export function ThreadMarkdownImage(props: { readonly environmentId: EnvironmentId; - readonly resource: Extract; + readonly resource: Extract< + AssetResource, + { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" } + >; readonly alt: string | null; readonly srcFragment?: string; readonly actionsSource?: MediaActionsSource; @@ -196,6 +258,12 @@ export function ThreadMarkdownImage(props: { } unavailable={assetUrl._tag === "Failure"} knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} + format={ + (props.resource._tag === "media-file" || props.resource._tag === "draft-workspace-file") && + /\.svg$/i.test(props.resource.path) + ? "svg" + : undefined + } alt={props.alt} actionsSource={props.actionsSource} onPressPreview={props.onPressPreview} diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx new file mode 100644 index 000000000000..e01f6ae3b2aa --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -0,0 +1,45 @@ +import { createContext, useContext } from "react"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../../lib/videoPreviewSource"; + +export const ThreadMediaVisibleContext = createContext(false); + +export function ThreadMarkdownVideo(props: { + readonly source: MediaVideoPreviewSource; + readonly thumbnailVisible?: boolean; +}) { + const { source } = props; + const feedVisible = useContext(ThreadMediaVisibleContext); + const visible = props.thumbnailVisible ?? feedVisible; + const thumbnailKey = mediaVideoThumbnailKey(source); + const asset = useAssetUrlState( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const refreshAssetUrl = useRefreshAssetUrl( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + return ( + mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined + } + name={source.name} + thumbnailKey={thumbnailKey} + thumbnailVisible={visible} + unavailable={"resource" in source && asset._tag === "Failure"} + actionsSource={source.actionsSource} + /> + ); +} diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index 9f82e089444d..b0c8f378dfbc 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -1,7 +1,7 @@ import { fileBasename } from "@t3tools/client-runtime/markdown-links"; import type { ThreadId } from "@t3tools/contracts"; -import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; -import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; +import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks"; +import type { MarkdownFileContextMenu } from "../../native/SelectableMarkdownText.types"; import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import { diff --git a/apps/mobile/src/features/threads/markdownImageSize.test.ts b/apps/mobile/src/features/threads/markdownImageSize.test.ts index 76170890d519..614640cc3f8a 100644 --- a/apps/mobile/src/features/threads/markdownImageSize.test.ts +++ b/apps/mobile/src/features/threads/markdownImageSize.test.ts @@ -4,6 +4,7 @@ import { MARKDOWN_IMAGE_MAX_HEIGHT, MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize, + resolveSvgImageSize, } from "./markdownImageSize"; describe("resolveMarkdownImageDisplaySize", () => { @@ -60,3 +61,20 @@ describe("resolveMarkdownImageDisplaySize", () => { ).toBeNull(); }); }); + +describe("resolveSvgImageSize", () => { + it("uses pixel dimensions before the viewBox", () => { + expect(resolveSvgImageSize({ width: "32px", height: "48", viewBox: "0 0 100 100" })).toEqual({ + width: 32, + height: 48, + }); + }); + + it("uses the viewBox for responsive SVGs", () => { + expect( + resolveSvgImageSize({ width: "100%", height: "100%", viewBox: "-10, -20, 120, 240" }), + ).toEqual({ width: 120, height: 240 }); + expect(resolveSvgImageSize({ viewBox: "0 0 100 NaN" })).toBeNull(); + expect(resolveSvgImageSize({ viewBox: "0 0 0 100" })).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/markdownImageSize.ts b/apps/mobile/src/features/threads/markdownImageSize.ts index 0fb6f8fbcc6d..b5b3c8402b25 100644 --- a/apps/mobile/src/features/threads/markdownImageSize.ts +++ b/apps/mobile/src/features/threads/markdownImageSize.ts @@ -6,6 +6,34 @@ export interface MarkdownImageDisplaySize { readonly height: number; } +/** SVG percentages need a viewport; use the intrinsic viewBox in that case. */ +export function resolveSvgImageSize(props: Readonly>) { + const dimension = (value: unknown) => { + if (typeof value === "number") return value; + if (typeof value !== "string" || !/^\s*\d+(?:\.\d+)?(?:px)?\s*$/.test(value)) return NaN; + return Number.parseFloat(value); + }; + const width = dimension(props.width); + const height = dimension(props.height); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) + return { width, height }; + const viewBox = + typeof props.viewBox === "string" + ? props.viewBox + .trim() + .split(/[\s,]+/) + .map(Number) + : []; + if ( + viewBox.length !== 4 || + !viewBox.every(Number.isFinite) || + viewBox[2]! <= 0 || + viewBox[3]! <= 0 + ) + return null; + return { width: viewBox[2]!, height: viewBox[3]! }; +} + /** Keeps small images intrinsic while fitting larger images inside the chat viewport. */ export function resolveMarkdownImageDisplaySize(input: { readonly sourceWidth: number; diff --git a/apps/mobile/src/lib/composerChipPresentation.test.ts b/apps/mobile/src/lib/composerChipPresentation.test.ts new file mode 100644 index 000000000000..3e0af9834522 --- /dev/null +++ b/apps/mobile/src/lib/composerChipPresentation.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vite-plus/test"; + +describe("composerChipSizeSuffix", () => { + it("labels attachment records with a human size, matching web's chip", async () => { + const { composerChipSizeSuffix } = await import("./composerChipPresentation"); + expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 1024 })).toBe("1 KB"); + expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 3_700_000 })).toBe("3.5 MB"); + expect(composerChipSizeSuffix({ kind: "image", sizeBytes: 2048 })).toBe("2 KB"); + }); + + it("adds nothing for records that carry no bytes", async () => { + const { composerChipSizeSuffix } = await import("./composerChipPresentation"); + // Terminal/review/PR chips have no size to show. + expect(composerChipSizeSuffix({ kind: "terminal" })).toBe(""); + expect(composerChipSizeSuffix({ kind: "file" })).toBe(""); + expect(composerChipSizeSuffix(undefined)).toBe(""); + }); +}); + +describe("contextChipPresentation image detection", () => { + it("treats a picture attached through the file picker as an image", async () => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + // The document picker types every pick as `file`, so the name has to carry the intent. + expect( + contextChipPresentation("file", { kind: "file", name: "IMG_4997.PNG", mimeType: "" }), + ).toEqual({ accent: "#d55665", symbol: "photo" }); + expect( + contextChipPresentation("file", { + kind: "file", + name: "shot", + mimeType: "image/jpeg", + }), + ).toEqual({ accent: "#d55665", symbol: "photo" }); + }); + + it("leaves genuine documents and videos alone", async () => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + expect( + contextChipPresentation("file", { kind: "file", name: "notes.txt", mimeType: "text/plain" }), + ).toEqual({ accent: "#0090cd", symbol: "doc" }); + expect( + contextChipPresentation("file", { kind: "file", name: "clip.mp4", mimeType: "video/mp4" }), + ).toEqual({ accent: "#d06217", symbol: "play.rectangle" }); + }); +}); + +describe("pull request chip status", () => { + const chip = async (state: string, isDraft = false) => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + return contextChipPresentation("review-comment", { + kind: "review-comment", + sectionId: "pull-request:10978", + pullRequest: { state, isDraft }, + }); + }; + + it("colours a pull request by its state, the way web and the forge do", async () => { + expect((await chip("open")).accent).toBe("#009f6e"); + expect((await chip("open", true)).accent).toBe("#7f8793"); + expect((await chip("merged")).accent).toBe("#8a70dd"); + expect((await chip("closed")).accent).toBe("#d55665"); + }); + + it("keeps one glyph across every state, so only colour carries the status", async () => { + // Web draws a fixed `git-pull-request` and encodes state in colour alone. A per-state glyph + // here would put mobile out of step with it. + const symbols = await Promise.all( + [chip("open"), chip("open", true), chip("merged"), chip("closed")].map( + async (pending) => (await pending).symbol, + ), + ); + expect(new Set(symbols)).toEqual(new Set(["git-pull-request"])); + }); + + it("falls back to the generic pull request chip when the state is unknown", async () => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + // An older server may send no metadata at all; the chip still has to render. + expect( + contextChipPresentation("review-comment", { + kind: "review-comment", + sectionId: "pull-request:1", + }).accent, + ).toBe("#7079e4"); + }); +}); diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts new file mode 100644 index 000000000000..853ae43f8791 --- /dev/null +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -0,0 +1,87 @@ +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { imageMimeType } from "@t3tools/shared/image"; +import { videoMimeType } from "@t3tools/shared/video"; +/** + * Every accent shares a lightness so no kind reads heavier than another; only hue carries + * identity. These are the sRGB form of the same OKLCH set web uses, so a chip looks the + * same on every surface. See `composerInlineChip.ts`. + */ +const CONTEXT_CHIP_PRESENTATIONS = { + image: { accent: "#d55665", symbol: "photo" }, + video: { accent: "#d06217", symbol: "play.rectangle" }, + file: { accent: "#0090cd", symbol: "doc" }, + mention: { accent: "#0096af", symbol: "doc" }, + terminal: { accent: "#009f6e", symbol: "terminal" }, + element: { accent: "#b87501", symbol: "cursorarrow.click" }, + "preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" }, + "review-comment": { accent: "#8a70dd", symbol: "text.bubble" }, + "pull-request": { accent: "#7079e4", symbol: "git-pull-request" }, + skill: { accent: "#b261be", symbol: "cube" }, +} as const; + +/** + * The size an attachment chip reports beside its name, matching web. Rendered as its own + * smaller run, so it carries no separator. Only attachment-backed records have bytes. + */ +export function composerChipSizeSuffix(record?: { + readonly kind?: string; + readonly sizeBytes?: number; +}): string { + if (record?.kind !== "file" && record?.kind !== "image") return ""; + return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : ""; +} + +/** + * A pull request chip is coloured by what the pull request *is*, the way web colours it and + * the way the forge itself does: green open, grey draft, purple merged, red closed. The glyph + * stays the same across all four, as it does on web — state is carried by colour alone. + */ +const PULL_REQUEST_CHIP_PRESENTATIONS = { + open: { accent: "#009f6e", symbol: "git-pull-request" }, + draft: { accent: "#7f8793", symbol: "git-pull-request" }, + merged: { accent: "#8a70dd", symbol: "git-pull-request" }, + closed: { accent: "#d55665", symbol: "git-pull-request" }, +} as const; + +export function contextChipPresentation( + kind: string, + record?: { + readonly kind?: string; + readonly name?: string; + readonly mimeType?: string; + readonly sectionId?: string; + readonly pullRequest?: { + readonly state?: string; + readonly isDraft?: boolean; + }; + }, +) { + const presentationKind = + kind === "file" && + videoMimeType({ + name: record?.name ?? "", + mimeType: record?.mimeType ?? "", + }) + ? "video" + : // A picture chosen through the file picker is typed `file`, but it is still a + // picture: it reads as one to the user and should not wear the generic file chip. + kind === "file" && + imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null + ? "image" + : kind === "review-comment" && record?.sectionId?.startsWith("pull-request:") + ? "pull-request" + : kind; + if (presentationKind === "pull-request") { + const pullRequest = record?.pullRequest; + const state = + pullRequest?.state === "open" && pullRequest.isDraft === true + ? "draft" + : (pullRequest?.state ?? ""); + if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) { + return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS]; + } + } + return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind) + ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS] + : CONTEXT_CHIP_PRESENTATIONS.file; +} diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts new file mode 100644 index 000000000000..b067365dd3ec --- /dev/null +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ComposerContextId, ReviewCommentContextRecord } from "@t3tools/contracts"; +import { + enrichedContextLinkPresentation, + enrichedLinkVariantPattern, + enrichedSkillLinkRegex, + ENRICHED_INLINE_FILE_LINK_REGEX, +} from "./enrichedLinkPresentation"; + +describe("Enriched link presentation", () => { + it("recognizes file-shaped code without turning commands or URLs into links", () => { + for (const path of [ + "src/app.tsx", + "./README.md", + "/tmp/report.md", + "C:\\repo\\app.tsx", + "app.tsx:12:3", + ]) { + expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(path), path).toBe(true); + } + for (const text of [ + "npm run dev", + "port:3000", + "TODO:12", + "example.com", + "https://example.com/app.tsx", + "x + y", + "README.md", + "/app/settings", + ]) { + expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(text), text).toBe(false); + } + }); + + it("only recognizes complete known skill names and escapes their punctuation", () => { + const regex = enrichedSkillLinkRegex([{ name: "check" }, { name: "check.mobile" }]); + expect(regex?.exec("Try $check.mobile next")?.[0]).toBe("$check.mobile"); + expect(regex?.exec("Try $check-mobile next")).toBeNull(); + expect(regex?.exec("Try $checkXmobile next")).toBeNull(); + expect(regex?.exec("prefix$check next")).toBeNull(); + expect(enrichedSkillLinkRegex([])).toBeNull(); + }); + it("matches only the complete URL, including literal regex metacharacters", () => { + const url = "file:///workspace/app[1].tsx?line=3+4"; + const pattern = new RegExp(enrichedLinkVariantPattern(url)); + expect(pattern.test(url)).toBe(true); + expect(pattern.test(`${url}/child`)).toBe(false); + expect(pattern.test("file:///workspace/app1Xtsx?line=34")).toBe(false); + }); + + it("uses context identity for PR status even when labels are identical", () => { + const record: ReviewCommentContextRecord = { + version: 1, + contextId: "review_1" as ComposerContextId, + kind: "review-comment", + label: "Review", + sectionId: "pull-request:7", + sectionTitle: "Review", + filePath: "app.tsx", + startIndex: 0, + endIndex: 1, + rangeLabel: "line 1", + text: "Comment", + diff: "", + pullRequest: { + number: 7, + title: "Review", + url: "https://github.com/example/repo/pull/7", + headBranch: "feature", + baseBranch: "main", + state: "merged", + isDraft: false, + }, + }; + expect( + enrichedContextLinkPresentation("t3-context://v1/review-comment/review_1", [record]), + ).toEqual({ color: "#8a70dd", icon: "git" }); + expect( + enrichedContextLinkPresentation("t3-context://v1/review-comment/review_2", [record]), + ).toEqual({ color: "#8a70dd", icon: "markdown" }); + }); + + it("retains recognizable presentation for unavailable and future context kinds", () => { + expect(enrichedContextLinkPresentation("t3-context://v1/terminal/missing")).toEqual({ + color: "#009f6e", + icon: "bash", + }); + expect(enrichedContextLinkPresentation("t3-context://v1/skill/missing")).toEqual({ + color: "#b261be", + icon: "mcp", + }); + expect(enrichedContextLinkPresentation("t3-context://v1/future/missing")).toEqual({ + color: "#0090cd", + icon: "default", + }); + expect(enrichedContextLinkPresentation("https://example.com/terminal/missing")).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts new file mode 100644 index 000000000000..5ea8fd1afad1 --- /dev/null +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -0,0 +1,58 @@ +import type { ComposerContextRecord } from "@t3tools/contracts"; +import { contextChipPresentation } from "./composerChipPresentation"; +import type { MarkdownFileIcon } from "./markdownLinks"; +import type { SelectableMarkdownSkill } from "../native/SelectableMarkdownText.types"; +import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; + +const CONTEXT_ICONS: Readonly> = { + photo: "image", + "play.rectangle": "video", + terminal: "bash", + "cursorarrow.click": "react", + "text.bubble": "markdown", + "git-pull-request": "git", + cube: "mcp", + doc: "default", +}; + +/** Context identity comes from the URL, even when different chips share a label. */ +export function enrichedContextLinkPresentation( + href: string, + records?: ReadonlyArray, +) { + const reference = parseComposerContextHref(href); + if (!reference) return undefined; + const record = records?.find((candidate) => candidate.contextId === reference.contextId); + const presentation = contextChipPresentation(reference.kind, record); + return { + color: presentation.accent, + icon: CONTEXT_ICONS[presentation.symbol] ?? "default", + }; +} + +export function enrichedLinkVariantPattern(url: string) { + return `^${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`; +} + +/** Conservative recognition for Enriched's whole-span inline-code link hook. */ +export const ENRICHED_INLINE_FILE_LINK_REGEX = + /^(?!\S*:\/\/)(?:(?:\.{1,2}[\\/]|~[\\/]|\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes|private|root|usr|bin|sbin|lib|lib64|srv|dev|proc|sys|run|boot|media|workspace|workspaces)\/|[A-Za-z]:[\\/])[^\s`]+|(?:[A-Za-z0-9._-]+[\\/])+[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}|[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+:\d+(?::\d+)?)$/; + +export function enrichedSkillLinkRegex(skills?: ReadonlyArray) { + const names = [...new Set(skills?.map((skill) => skill.name) ?? [])].filter(Boolean); + if (names.length === 0) return null; + names.sort((a, b) => b.length - a.length); + const alternatives = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + return new RegExp(`(? word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + ); +} diff --git a/apps/mobile/src/lib/markdownFileIcons.generated.ts b/apps/mobile/src/lib/markdownFileIcons.generated.ts new file mode 100644 index 000000000000..e097b51a87a3 --- /dev/null +++ b/apps/mobile/src/lib/markdownFileIcons.generated.ts @@ -0,0 +1,60 @@ +import type { ImageSourcePropType } from "react-native"; + +export const MARKDOWN_FILE_ICON_SOURCES = { + agents: require("../../assets/file-icons/pierre_agents.png"), + astro: require("../../assets/file-icons/pierre_astro.png"), + babel: require("../../assets/file-icons/pierre_babel.png"), + bash: require("../../assets/file-icons/pierre_bash.png"), + biome: require("../../assets/file-icons/pierre_biome.png"), + bootstrap: require("../../assets/file-icons/pierre_bootstrap.png"), + browserslist: require("../../assets/file-icons/pierre_browserslist.png"), + bun: require("../../assets/file-icons/pierre_bun.png"), + c: require("../../assets/file-icons/pierre_c.png"), + claude: require("../../assets/file-icons/pierre_claude.png"), + cpp: require("../../assets/file-icons/pierre_cpp.png"), + css: require("../../assets/file-icons/pierre_css.png"), + database: require("../../assets/file-icons/pierre_database.png"), + default: require("../../assets/file-icons/pierre_default.png"), + docker: require("../../assets/file-icons/pierre_docker.png"), + eslint: require("../../assets/file-icons/pierre_eslint.png"), + font: require("../../assets/file-icons/pierre_font.png"), + git: require("../../assets/file-icons/pierre_git.png"), + go: require("../../assets/file-icons/pierre_go.png"), + graphql: require("../../assets/file-icons/pierre_graphql.png"), + html: require("../../assets/file-icons/pierre_html.png"), + image: require("../../assets/file-icons/pierre_image.png"), + javascript: require("../../assets/file-icons/pierre_javascript.png"), + json: require("../../assets/file-icons/pierre_json.png"), + markdown: require("../../assets/file-icons/pierre_markdown.png"), + mcp: require("../../assets/file-icons/pierre_mcp.png"), + nextjs: require("../../assets/file-icons/pierre_nextjs.png"), + npm: require("../../assets/file-icons/pierre_npm.png"), + oxc: require("../../assets/file-icons/pierre_oxc.png"), + pnpm: require("../../assets/file-icons/pierre_pnpm.png"), + postcss: require("../../assets/file-icons/pierre_postcss.png"), + prettier: require("../../assets/file-icons/pierre_prettier.png"), + python: require("../../assets/file-icons/pierre_python.png"), + react: require("../../assets/file-icons/pierre_react.png"), + ruby: require("../../assets/file-icons/pierre_ruby.png"), + rust: require("../../assets/file-icons/pierre_rust.png"), + sass: require("../../assets/file-icons/pierre_sass.png"), + stylelint: require("../../assets/file-icons/pierre_stylelint.png"), + svelte: require("../../assets/file-icons/pierre_svelte.png"), + svg: require("../../assets/file-icons/pierre_svg.png"), + svgo: require("../../assets/file-icons/pierre_svgo.png"), + swift: require("../../assets/file-icons/pierre_swift.png"), + table: require("../../assets/file-icons/pierre_table.png"), + tailwind: require("../../assets/file-icons/pierre_tailwind.png"), + terraform: require("../../assets/file-icons/pierre_terraform.png"), + text: require("../../assets/file-icons/pierre_text.png"), + typescript: require("../../assets/file-icons/pierre_typescript.png"), + video: require("../../assets/file-icons/pierre_video.png"), + vite: require("../../assets/file-icons/pierre_vite.png"), + vscode: require("../../assets/file-icons/pierre_vscode.png"), + vue: require("../../assets/file-icons/pierre_vue.png"), + wasm: require("../../assets/file-icons/pierre_wasm.png"), + webpack: require("../../assets/file-icons/pierre_webpack.png"), + yml: require("../../assets/file-icons/pierre_yml.png"), + zig: require("../../assets/file-icons/pierre_zig.png"), + zip: require("../../assets/file-icons/pierre_zip.png"), +} as const satisfies Readonly>; diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts b/apps/mobile/src/lib/markdownFileIcons.ts similarity index 100% rename from apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts rename to apps/mobile/src/lib/markdownFileIcons.ts diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts b/apps/mobile/src/lib/markdownLinkIcons.ts similarity index 88% rename from apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts rename to apps/mobile/src/lib/markdownLinkIcons.ts index 568a51005798..5ff492f6fb3c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts +++ b/apps/mobile/src/lib/markdownLinkIcons.ts @@ -4,7 +4,7 @@ import type { MarkdownLinkIcon } from "./markdownLinks"; // Black-on-transparent marks; callers tint them with the link color. const MARKDOWN_LINK_ICON_SOURCES = { - github: require("../assets/link-icons/github.png"), + github: require("../../assets/link-icons/github.png"), } as const satisfies Readonly>; export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index 38f1f5211421..b04ab738afcd 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - resolveMarkdownLinkIcon, - resolveMarkdownLinkPresentation, -} from "@t3tools/mobile-markdown-text/links"; +import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "./markdownLinks"; describe("resolveMarkdownLinkIcon", () => { it("gives GitHub hosts the brand mark and everything else the generic glyph", () => { diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/src/lib/markdownLinks.ts similarity index 100% rename from apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts rename to apps/mobile/src/lib/markdownLinks.ts diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts index b1b616f0e442..223c2f9faec4 100644 --- a/apps/mobile/src/lib/markdownMedia.ts +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -1,6 +1,6 @@ import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { normalizeNativeMarkdownUrl } from "./markdownLinks"; import type { FilePreviewSource } from "../components/FilePreviewModal"; import type { MediaVideoPreviewSource } from "./videoPreviewSource"; diff --git a/apps/mobile/src/lib/markdownMediaSource.test.ts b/apps/mobile/src/lib/markdownMediaSource.test.ts new file mode 100644 index 000000000000..faa44e76af50 --- /dev/null +++ b/apps/mobile/src/lib/markdownMediaSource.test.ts @@ -0,0 +1,41 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { resolveMobileMarkdownMediaSource } from "./markdownMediaSource"; + +describe("mobile Markdown draft media", () => { + it("authorizes relative images and videos against the draft directory", () => { + for (const name of ["diagram.svg", "clip.mp4"]) { + expect( + resolveMobileMarkdownMediaSource(`./${name}`, { + threadId: undefined, + workspaceRoot: "/project/docs", + imageEmbed: true, + }), + ).toMatchObject({ + access: "environment", + resource: { + _tag: "draft-workspace-file", + cwd: "/project/docs", + path: `/project/docs/./${name}`, + }, + }); + } + }); + + it("keeps thread authorization when a thread exists", () => { + const threadId = ThreadId.make("markdown-thread"); + expect( + resolveMobileMarkdownMediaSource("image.png", { threadId, workspaceRoot: "/project" }), + ).toMatchObject({ resource: { _tag: "media-file", threadId, path: "/project/image.png" } }); + }); + + it("keeps direct URLs direct and rejects unresolvable or blocked references", () => { + const input = { threadId: undefined, workspaceRoot: "/project" }; + expect(resolveMobileMarkdownMediaSource("https://example.com/image.png", input)).toMatchObject({ + access: "direct", + uri: "https://example.com/image.png", + }); + expect(resolveMobileMarkdownMediaSource("javascript:alert(1)", input)).toBeNull(); + expect(resolveMobileMarkdownMediaSource("image.png", { threadId: undefined })).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/markdownMediaSource.ts b/apps/mobile/src/lib/markdownMediaSource.ts new file mode 100644 index 000000000000..f2538f495b20 --- /dev/null +++ b/apps/mobile/src/lib/markdownMediaSource.ts @@ -0,0 +1,22 @@ +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; +import { + resolveMediaSource, + type ResolveMediaSourceInput, +} from "@t3tools/client-runtime/media-source"; + +/** A project draft names its workspace explicitly because it has no thread. */ +export function resolveMobileMarkdownMediaSource(href: string, input: ResolveMediaSourceInput) { + const media = resolveMediaSource(href, input); + if (media?.access !== "unavailable" || !input.workspaceRoot) return media; + const source = classifyMarkdownImageSource(href, input.workspaceRoot); + if (source._tag !== "WorkspaceFile") return media; + return { + ...media, + access: "environment" as const, + resource: { + _tag: "draft-workspace-file" as const, + cwd: input.workspaceRoot, + path: source.path, + }, + }; +} diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts index d2fef103a3a9..661706791865 100644 --- a/apps/mobile/src/lib/mediaActions.ts +++ b/apps/mobile/src/lib/mediaActions.ts @@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native"; import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; import type { MediaReference } from "@t3tools/client-runtime/media-reference"; import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { normalizeNativeMarkdownUrl } from "./markdownLinks"; import { useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts deleted file mode 100644 index d934a306aa01..000000000000 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ /dev/null @@ -1,1176 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { MarkdownNode } from "react-native-nitro-markdown/headless"; - -import { - nativeMarkdownChunkSpacing, - nativeMarkdownDocumentChunks, - nativeMarkdownDocumentRuns, - nativeMarkdownListItemBlocks, - nativeMarkdownTextRuns, - nativeMarkdownWithPreservedSoftBreaks, - nativeMarkdownContextCopyRanges, - contextChipPresentation, -} from "@t3tools/mobile-markdown-text/markdown"; - -describe("nativeMarkdownTextRuns", () => { - it("distinguishes video and pull-request context from generic file and review chips", () => { - expect( - contextChipPresentation("file", { - name: "recording.webm", - mimeType: "application/octet-stream", - }), - ).toEqual(contextChipPresentation("video")); - expect(contextChipPresentation("review-comment", { sectionId: "pull-request:284" })).toEqual( - contextChipPresentation("pull-request"), - ); - expect(contextChipPresentation("review-comment", { sectionId: "git:working-tree" })).toEqual( - contextChipPresentation("review-comment"), - ); - }); - - it("renders a video-named file with a declared document MIME type as a file chip", () => { - expect( - contextChipPresentation("file", { - name: "recording.mp4", - mimeType: "application/pdf", - }), - ).toEqual(contextChipPresentation("file")); - }); - - it("maps rendered selection offsets back to canonical references without losing repeated chips", () => { - const href = "t3-context://v1/image/screenshot"; - expect( - nativeMarkdownContextCopyRanges([ - { run: { text: "😀 " }, text: "😀 ", inlineImageLength: 0 }, - { run: { href, text: "Checkout" }, text: "Checkout", inlineImageLength: 1 }, - { run: { text: " then " }, text: " then ", inlineImageLength: 0 }, - { run: { href, text: "Checkout" }, text: "\uFFFC\u00A0Checkout", inlineImageLength: 0 }, - ]), - ).toEqual([ - { start: 3, end: 12, text: "![Checkout](t3-context://v1/image/screenshot)" }, - { start: 18, end: 28, text: "![Checkout](t3-context://v1/image/screenshot)" }, - ]); - }); - it("restores canonical skill and context text from Android's single-image chips", () => { - expect( - nativeMarkdownContextCopyRanges([ - { run: { text: "Use " }, text: "Use ", inlineImageLength: 0 }, - { run: { text: "Playwright", skillName: "playwright" }, text: "", inlineImageLength: 1 }, - { run: { text: " on " }, text: " on ", inlineImageLength: 0 }, - { - run: { text: "Screenshot", href: "t3-context://v1/image/screenshot" }, - text: "", - inlineImageLength: 1, - }, - ]), - ).toEqual([ - { start: 4, end: 5, text: "$playwright" }, - { start: 9, end: 10, text: "![Screenshot](t3-context://v1/image/screenshot)" }, - ]); - }); - it("links a path-shaped code span without changing the same path in prose", () => { - expect( - nativeMarkdownTextRuns({ - type: "paragraph", - children: [ - { type: "text", content: "/tmp/frame.png " }, - { type: "code_inline", content: "/tmp/frame.png" }, - ], - }), - ).toEqual([ - { text: "/tmp/frame.png " }, - { text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" }, - ]); - }); - - it("preserves the destination of a link with a code-formatted label", () => { - expect( - nativeMarkdownTextRuns({ - type: "paragraph", - children: [ - { - type: "link", - href: "https://example.com/docs", - children: [{ type: "code_inline", content: "src/main.ts" }], - }, - ], - }), - ).toEqual([ - { - text: "src/main.ts", - code: true, - href: "https://example.com/docs", - externalHost: "example.com", - }, - ]); - }); - - it("preserves inline emphasis and code styles", () => { - const node: MarkdownNode = { - type: "paragraph", - children: [ - { type: "text", content: "plain " }, - { type: "bold", children: [{ type: "text", content: "bold" }] }, - { type: "text", content: " " }, - { type: "code_inline", content: "const value = 1" }, - ], - }; - - expect(nativeMarkdownTextRuns(node)).toEqual([ - { text: "plain " }, - { text: "bold", bold: true }, - { text: " " }, - { text: "const value = 1", code: true }, - ]); - }); - - it("normalizes external and file links for native presentation", () => { - const node: MarkdownNode = { - type: "paragraph", - children: [ - { - type: "link", - href: "https://example.com/docs", - children: [{ type: "text", content: "Docs" }], - }, - { type: "text", content: " " }, - { - type: "link", - href: "file:///repo/README.md#L12", - children: [{ type: "text", content: "ignored label" }], - }, - ], - }; - - expect(nativeMarkdownTextRuns(node)).toEqual([ - { - text: "Docs", - href: "https://example.com/docs", - externalHost: "example.com", - }, - { text: " " }, - { - text: "README.md:12", - href: "file:///repo/README.md#L12", - fileIcon: "markdown", - }, - ]); - }); - - it("keeps hard breaks and collapses soft breaks", () => { - const node: MarkdownNode = { - type: "paragraph", - children: [ - { type: "text", content: "first" }, - { type: "soft_break" }, - { type: "text", content: "second" }, - { type: "line_break" }, - { type: "text", content: "third" }, - ], - }; - - expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]); - }); - - it("can preserve soft breaks for authored user messages", () => { - const node: MarkdownNode = { - type: "paragraph", - children: [ - { type: "text", content: "first" }, - { type: "soft_break" }, - { type: "text", content: "second" }, - ], - }; - - expect(nativeMarkdownTextRuns(nativeMarkdownWithPreservedSoftBreaks(node))).toEqual([ - { text: "first\nsecond" }, - ]); - }); - - it("normalizes common inline HTML and entities", () => { - const node: MarkdownNode = { - type: "paragraph", - children: [ - { type: "text", content: "Less than: < " }, - { type: "html_inline", content: "" }, - { type: "text", content: "⌘" }, - { type: "html_inline", content: "" }, - { type: "html_inline", content: "
" }, - { type: "html_inline", content: "highlighted" }, - ], - }; - - expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "Less than: < ⌘\nhighlighted" }]); - }); - - it("normalizes double-encoded entities and inline tags emitted as text", () => { - const node: MarkdownNode = { - type: "paragraph", - children: [ - { - type: "text", - content: - "Keyboard: ⌘ + K; Less than: &lt;; Greater than: &gt;", - }, - ], - }; - - expect(nativeMarkdownTextRuns(node)).toEqual([ - { text: "Keyboard: ⌘ + K; Less than: <; Greater than: >" }, - ]); - }); - - it.each([ - ["😀", "😀"], - ["🚀", "🚀"], - ["�", "�"], - ["�", "�"], - ["&#9999999999;", "�"], - ["&#x110000;", "�"], - ])("normalizes numeric entity %s without throwing", (content, expected) => { - const node: MarkdownNode = { - type: "paragraph", - children: [{ type: "text", content }], - }; - - expect(nativeMarkdownTextRuns(node)).toEqual([{ text: expected }]); - }); - - it("reads inline content from nested text nodes", () => { - const node: MarkdownNode = { - type: "paragraph", - children: [ - { - type: "text", - children: [{ type: "text", content: "Plain text" }], - }, - { type: "text", content: " and " }, - { - type: "code_inline", - children: [{ type: "text", content: "inline code" }], - }, - ], - }; - - expect(nativeMarkdownTextRuns(node)).toEqual([ - { text: "Plain text and " }, - { text: "inline code", code: true }, - ]); - }); -}); - -describe("nativeMarkdownDocumentRuns", () => { - it("renders a file mention without swallowing sentence punctuation or changing package references", () => { - const runs = nativeMarkdownDocumentRuns({ - type: "document", - children: [ - { - type: "paragraph", - children: [ - { type: "text", content: "Inspect @src/Checkout.tsx. Use @t3tools/contracts." }, - ], - }, - ], - }); - expect(runs).toEqual([ - { text: "Inspect ", role: "body" }, - { - text: "Checkout.tsx", - role: "body", - href: "src/Checkout.tsx", - fileIcon: "react", - sourceText: "@src/Checkout.tsx", - }, - { text: ". Use @t3tools/contracts.", role: "body" }, - ]); - }); - - it("copies collapsed skill and file chips back to their original references", () => { - expect( - nativeMarkdownContextCopyRanges([ - { run: { text: "$ui", skillName: "ui" }, text: "\uFFFC", inlineImageLength: 0 }, - { run: { text: " and " }, text: " and ", inlineImageLength: 0 }, - { - run: { - text: "Checkout.tsx", - href: "src/Checkout.tsx", - fileIcon: "react", - sourceText: "@src/Checkout.tsx", - }, - text: "\uFFFC", - inlineImageLength: 0, - }, - ]), - ).toEqual([ - { start: 0, end: 1, text: "$ui" }, - { start: 6, end: 7, text: "@src/Checkout.tsx" }, - ]); - }); - - it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( - "decorates %s skill references as selectable skill links", - (prefix) => { - const node: MarkdownNode = { - type: "document", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: `Use ${prefix}ui for this.` }], - }, - ], - }; - - expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ - { text: "Use ", role: "body" }, - { - text: `${prefix}ui`, - role: "body", - skillName: "ui", - skillLabel: "UI", - }, - { text: " for this.", role: "body" }, - ]); - }, - ); - - it("decorates known skill references that begin with a digit", () => { - const node: MarkdownNode = { - type: "document", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Use $2spec for this." }], - }, - ], - }; - - expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([ - { text: "Use ", role: "body" }, - { - text: "$2spec", - role: "body", - skillName: "2spec", - skillLabel: "2Spec", - }, - { text: " for this.", role: "body" }, - ]); - }); - - it("decorates known skill references inside blockquotes", () => { - const node: MarkdownNode = { - type: "blockquote", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Use $ui for this." }], - }, - ], - }; - - expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toContainEqual({ - text: "$ui", - role: "body", - skillName: "ui", - skillLabel: "UI", - }); - }); - - it("leaves unknown skill-like text unchanged", () => { - const node: MarkdownNode = { - type: "document", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Use $unknown for this." }], - }, - ], - }; - - expect(nativeMarkdownDocumentRuns(node, [])).toEqual([ - { text: "Use $unknown for this.", role: "body" }, - ]); - }); - - it("keeps headings, paragraphs, and lists in one continuous document", () => { - const node: MarkdownNode = { - type: "document", - children: [ - { - type: "heading", - level: 1, - children: [{ type: "text", content: "Header One" }], - }, - { - type: "paragraph", - children: [ - { type: "text", content: "A paragraph with " }, - { type: "bold", children: [{ type: "text", content: "bold text" }] }, - { type: "text", content: "." }, - ], - }, - { - type: "list", - ordered: false, - children: [ - { - type: "list_item", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "First item" }], - }, - ], - }, - { - type: "list_item", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Second item" }], - }, - ], - }, - ], - }, - ], - }; - - const runs = nativeMarkdownDocumentRuns(node); - expect(runs.map((run) => run.text).join("")).toBe( - "Header One\n\nA paragraph with bold text.\n\n•\tFirst item\n•\tSecond item", - ); - expect(runs).toContainEqual({ - text: "Header One\n", - role: "heading", - headingLevel: 1, - }); - expect(runs).toContainEqual({ - text: "bold text", - bold: true, - role: "body", - }); - expect(runs).toContainEqual({ - text: "•\t", - role: "list-marker", - depth: 1, - firstLineHeadIndent: 0, - headIndent: 24, - paragraphSpacing: 2, - }); - }); - - it("uses distinct section, heading-content, and body spacing", () => { - const node: MarkdownNode = { - type: "document", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Intro" }], - }, - { - type: "heading", - level: 2, - children: [{ type: "text", content: "Section" }], - }, - { - type: "paragraph", - children: [{ type: "text", content: "First paragraph" }], - }, - { - type: "paragraph", - children: [{ type: "text", content: "Second paragraph" }], - }, - ], - }; - - expect( - nativeMarkdownDocumentRuns(node) - .filter((run) => run.role === "spacer") - .map((run) => run.spacing), - ).toEqual([20, 10, 12]); - }); - - it("renders tight list items whose inline nodes are direct children", () => { - const node: MarkdownNode = { - type: "document", - children: [ - { - type: "list", - children: [ - { - type: "list_item", - children: [ - { - type: "bold", - children: [{ type: "text", content: "Finding:" }], - }, - { type: "text", content: " details with " }, - { type: "code_inline", content: "inline code" }, - { type: "text", content: "." }, - ], - }, - ], - }, - ], - }; - - expect(nativeMarkdownDocumentRuns(node)).toEqual([ - { - text: "•\t", - role: "list-marker", - depth: 1, - firstLineHeadIndent: 0, - headIndent: 24, - paragraphSpacing: 2, - }, - { text: "Finding:", bold: true, role: "body", depth: 1 }, - { text: " details with ", role: "body", depth: 1 }, - { text: "inline code", code: true, role: "body", depth: 1 }, - { text: ".", role: "body", depth: 1 }, - ]); - }); - - it("preserves quotes and fenced code in document runs", () => { - const node: MarkdownNode = { - type: "document", - children: [ - { - type: "blockquote", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Read this" }], - }, - ], - }, - { - type: "code_block", - language: "ts", - content: "const answer = 42;", - }, - ], - }; - - const runs = nativeMarkdownDocumentRuns(node); - expect(runs.map((run) => run.text).join("")).toBe("│\u00a0Read this\n\nTS\nconst answer = 42;"); - expect(runs).toContainEqual({ - text: "const answer = 42;", - code: true, - role: "code-block", - }); - }); - - it("reads fenced code content from child text nodes", () => { - const node: MarkdownNode = { - type: "document", - children: [ - { - type: "code_block", - language: "bash", - children: [{ type: "text", content: "pnpm install\n" }], - }, - ], - }; - - expect( - nativeMarkdownDocumentRuns(node) - .map((run) => run.text) - .join(""), - ).toBe("BASH\npnpm install"); - }); - - it("keeps adjacent context links with the same href in separate runs", () => { - const href = "t3-context://v1/terminal/ctx-1"; - const link = (content: string): MarkdownNode => ({ - type: "link", - href, - children: [{ type: "text", content }], - }); - const runs = nativeMarkdownDocumentRuns({ - type: "document", - children: [{ type: "paragraph", children: [link("First"), link("Second")] }], - }); - - // Merging these would render one chip and emit one copy range with a - // combined label for two distinct references. - expect(runs).toEqual([ - { text: "First", role: "body", href, fileIcon: "bash" }, - { text: "Second", role: "body", href, fileIcon: "bash" }, - ]); - }); -}); - -describe("nativeMarkdownListItemBlocks", () => { - it("groups consecutive inline nodes into one paragraph block", () => { - const item: MarkdownNode = { - type: "list_item", - children: [ - { type: "text", content: "Finding: " }, - { type: "bold", children: [{ type: "text", content: "important" }] }, - { type: "text", content: " details." }, - { - type: "list", - children: [ - { - type: "list_item", - children: [{ type: "text", content: "Nested" }], - }, - ], - }, - { type: "text", content: "Trailing prose." }, - ], - }; - - expect(nativeMarkdownListItemBlocks(item)).toEqual([ - { - type: "paragraph", - children: item.children?.slice(0, 3), - }, - item.children?.[3], - { - type: "paragraph", - children: [item.children?.[4]], - }, - ]); - }); -}); - -describe("nativeMarkdownDocumentChunks", () => { - it("renders plain blockquotes as rich blocks so their marker spans wrapped lines", () => { - const blockquote: MarkdownNode = { - type: "blockquote", - beg: 0, - end: 120, - children: [ - { - type: "paragraph", - children: [ - { - type: "text", - content: - "Persistent random per-result keys are the strongest design, even when this text wraps.", - }, - ], - }, - ], - }; - - expect( - nativeMarkdownDocumentChunks({ - type: "document", - children: [blockquote], - }), - ).toEqual([ - { - kind: "rich", - key: "rich:blockquote:offset:0", - node: blockquote, - }, - ]); - }); - - it("keeps headings and plain lists in one selectable document", () => { - const document: MarkdownNode = { - type: "document", - children: [ - { - type: "heading", - level: 2, - children: [{ type: "text", content: "Tasks" }], - }, - { - type: "list", - children: [ - { - type: "task_list_item", - checked: true, - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Completed" }], - }, - ], - }, - { - type: "list_item", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Parent" }], - }, - { - type: "list", - children: [ - { - type: "list_item", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Nested" }], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }; - - const chunks = nativeMarkdownDocumentChunks(document); - expect(chunks).toHaveLength(1); - expect(chunks[0]).toMatchObject({ kind: "selectable" }); - expect( - nativeMarkdownDocumentRuns(chunks[0]?.node ?? document) - .map((run) => run.text) - .join(""), - ).toBe("Tasks\n\n☑︎\tCompleted\n•\tParent\n◦\tNested"); - }); - - it("aligns ordered markers while keeping the list in one selectable string", () => { - const document: MarkdownNode = { - type: "document", - children: [ - { - type: "list", - ordered: true, - start: 9, - children: [ - { - type: "list_item", - children: [{ type: "text", content: "Ninth" }], - }, - { - type: "list_item", - children: [{ type: "text", content: "Tenth" }], - }, - ], - }, - ], - }; - - expect( - nativeMarkdownDocumentRuns(document) - .map((run) => run.text) - .join(""), - ).toBe("\u20079.\tNinth\n10.\tTenth"); - }); - - it("keeps prose selectable while exposing rich AST blocks", () => { - const document: MarkdownNode = { - type: "document", - children: [ - { - type: "heading", - level: 1, - beg: 0, - end: 9, - children: [{ type: "text", content: "Install" }], - }, - { - type: "code_block", - language: "bash", - beg: 11, - end: 35, - children: [{ type: "text", content: "pnpm install\n" }], - }, - { - type: "paragraph", - beg: 37, - end: 42, - children: [{ type: "text", content: "Done." }], - }, - ], - }; - - const chunks = nativeMarkdownDocumentChunks(document); - expect(chunks).toHaveLength(3); - expect(chunks[0]).toMatchObject({ kind: "selectable" }); - expect(chunks[1]).toEqual({ - kind: "rich", - key: "rich:code_block:offset:11", - node: document.children?.[1], - }); - expect(chunks[2]).toMatchObject({ kind: "selectable" }); - }); - - it("keeps a list containing fenced code as one rich AST container", () => { - const document: MarkdownNode = { - type: "document", - children: [ - { - type: "list", - beg: 0, - end: 45, - children: [ - { - type: "list_item", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Install" }], - }, - { - type: "code_block", - language: "bash", - children: [{ type: "text", content: "pnpm install\n" }], - }, - ], - }, - ], - }, - ], - }; - - expect(nativeMarkdownDocumentChunks(document)).toEqual([ - { - kind: "rich", - key: "rich:list:offset:0", - node: document.children?.[0], - }, - ]); - }); - - it("keeps surrounding prose selectable when rich nodes have no source offsets", () => { - const document: MarkdownNode = { - type: "document", - children: [ - { - type: "heading", - level: 1, - children: [{ type: "text", content: "Before" }], - }, - { type: "horizontal_rule" }, - { - type: "paragraph", - children: [{ type: "text", content: "After." }], - }, - ], - }; - - const chunks = nativeMarkdownDocumentChunks(document); - expect(chunks).toHaveLength(3); - expect(chunks[0]).toMatchObject({ kind: "selectable" }); - expect(chunks[1]).toEqual({ - kind: "rich", - key: "rich:horizontal_rule:index:1", - node: document.children?.[1], - }); - expect(chunks[2]).toMatchObject({ kind: "selectable" }); - }); - - it("keeps offset-free structural lists isolated without promoting the whole document", () => { - const document: MarkdownNode = { - type: "document", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Before." }], - }, - { - type: "list", - ordered: true, - children: [ - { - type: "list_item", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Install" }], - }, - { - type: "code_block", - language: "bash", - children: [{ type: "text", content: "pnpm install\n" }], - }, - ], - }, - ], - }, - { - type: "paragraph", - children: [{ type: "text", content: "After." }], - }, - ], - }; - - const chunks = nativeMarkdownDocumentChunks(document); - expect(chunks).toHaveLength(3); - expect(chunks[0]).toMatchObject({ kind: "selectable" }); - expect(chunks[1]).toEqual({ - kind: "rich", - key: "rich:list:index:1", - node: document.children?.[1], - }); - expect(chunks[2]).toMatchObject({ kind: "selectable" }); - }); - - it("never collapses a rich subtree into a second markdown parsing pass", () => { - const document: MarkdownNode = { - type: "document", - children: [ - { - type: "paragraph", - children: [{ type: "text", content: "Before." }], - }, - { - type: "blockquote", - children: [ - { - type: "list", - children: [ - { - type: "list_item", - children: [ - { type: "text", content: "Run this" }, - { - type: "code_block", - language: "sh", - children: [{ type: "text", content: "vp check\n" }], - }, - ], - }, - ], - }, - ], - }, - { - type: "paragraph", - children: [{ type: "text", content: "After." }], - }, - ], - }; - - const chunks = nativeMarkdownDocumentChunks(document); - expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich", "selectable"]); - expect(chunks[1]).toMatchObject({ - kind: "rich", - node: { type: "blockquote" }, - }); - }); - - it("keys positioned and offset-free siblings in separate namespaces", () => { - const positioned: MarkdownNode = { - type: "blockquote", - beg: 1, - end: 10, - children: [{ type: "paragraph", children: [{ type: "text", content: "Positioned" }] }], - }; - const offsetFree: MarkdownNode = { - type: "blockquote", - children: [{ type: "paragraph", children: [{ type: "text", content: "Generated" }] }], - }; - const chunks = nativeMarkdownDocumentChunks({ - type: "document", - children: [ - { type: "paragraph", beg: 0, end: 0, children: [] }, - offsetFree, - positioned, - { type: "paragraph", children: [{ type: "text", content: "Tail" }] }, - ], - }); - - expect(chunks.map((chunk) => chunk.key)).toEqual([ - "selectable:offset:0", - "rich:blockquote:index:1", - "rich:blockquote:offset:1", - "selectable:index:3", - ]); - }); - - it("gives every offset-free selectable group its own key", () => { - const chunks = nativeMarkdownDocumentChunks({ - type: "document", - children: [ - { type: "paragraph", children: [{ type: "text", content: "One" }] }, - { type: "horizontal_rule" }, - { type: "paragraph", children: [{ type: "text", content: "Two" }] }, - { type: "horizontal_rule" }, - { type: "paragraph", children: [{ type: "text", content: "Three" }] }, - ], - }); - - expect(chunks.map((chunk) => chunk.key)).toEqual([ - "selectable:index:0", - "rich:horizontal_rule:index:1", - "selectable:index:2", - "rich:horizontal_rule:index:3", - "selectable:index:4", - ]); - }); - - it("keeps positioned chunk keys stable while text streams in", () => { - const before: MarkdownNode = { - type: "document", - children: [ - { type: "paragraph", beg: 0, end: 5, children: [{ type: "text", content: "Intro" }] }, - { - type: "code_block", - language: "ts", - beg: 7, - end: 20, - children: [{ type: "text", content: "const a" }], - }, - ], - }; - const after: MarkdownNode = { - type: "document", - children: [ - { type: "paragraph", beg: 0, end: 5, children: [{ type: "text", content: "Intro" }] }, - { - type: "code_block", - language: "ts", - beg: 7, - end: 40, - children: [{ type: "text", content: "const a = 1;\nconst b" }], - }, - ], - }; - - expect(nativeMarkdownDocumentChunks(after).map((chunk) => chunk.key)).toEqual( - nativeMarkdownDocumentChunks(before).map((chunk) => chunk.key), - ); - }); - - it("keeps a plain list in one selectable native text container", () => { - const list: MarkdownNode = { - type: "list", - ordered: false, - children: [ - { - type: "list_item", - children: [{ type: "text", content: "First" }], - }, - ], - }; - - const chunks = nativeMarkdownDocumentChunks({ - type: "document", - children: [list], - }); - - expect(chunks).toHaveLength(1); - expect(chunks[0]).toMatchObject({ - kind: "selectable", - node: { type: "document", children: [list] }, - }); - }); - - it("separates sections more than related rich blocks", () => { - const headingChunk = { - kind: "selectable" as const, - key: "heading", - node: { - type: "document", - children: [ - { - type: "heading", - level: 2, - children: [{ type: "text", content: "Section" }], - }, - ], - } satisfies MarkdownNode, - }; - const firstList = { - kind: "rich" as const, - key: "list-1", - node: { type: "list", children: [] } satisfies MarkdownNode, - }; - const secondList = { - kind: "rich" as const, - key: "list-2", - node: { type: "list", children: [] } satisfies MarkdownNode, - }; - - expect(nativeMarkdownChunkSpacing(undefined, headingChunk)).toBe(0); - expect(nativeMarkdownChunkSpacing(headingChunk, firstList)).toBe(10); - expect(nativeMarkdownChunkSpacing(firstList, secondList)).toBe(12); - expect(nativeMarkdownChunkSpacing(firstList, headingChunk)).toBe(20); - }); -}); - -describe("composerChipSizeSuffix", () => { - it("labels attachment records with a human size, matching web's chip", async () => { - const { composerChipSizeSuffix } = await import("@t3tools/mobile-markdown-text/markdown"); - expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 1024 })).toBe("1 KB"); - expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 3_700_000 })).toBe("3.5 MB"); - expect(composerChipSizeSuffix({ kind: "image", sizeBytes: 2048 })).toBe("2 KB"); - }); - - it("adds nothing for records that carry no bytes", async () => { - const { composerChipSizeSuffix } = await import("@t3tools/mobile-markdown-text/markdown"); - // Terminal/review/PR chips have no size to show. - expect(composerChipSizeSuffix({ kind: "terminal" })).toBe(""); - expect(composerChipSizeSuffix({ kind: "file" })).toBe(""); - expect(composerChipSizeSuffix(undefined)).toBe(""); - }); -}); - -describe("contextChipPresentation image detection", () => { - it("treats a picture attached through the file picker as an image", async () => { - const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown"); - // The document picker types every pick as `file`, so the name has to carry the intent. - expect( - contextChipPresentation("file", { kind: "file", name: "IMG_4997.PNG", mimeType: "" }), - ).toEqual({ accent: "#d55665", symbol: "photo" }); - expect( - contextChipPresentation("file", { - kind: "file", - name: "shot", - mimeType: "image/jpeg", - }), - ).toEqual({ accent: "#d55665", symbol: "photo" }); - }); - - it("leaves genuine documents and videos alone", async () => { - const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown"); - expect( - contextChipPresentation("file", { kind: "file", name: "notes.txt", mimeType: "text/plain" }), - ).toEqual({ accent: "#0090cd", symbol: "doc" }); - expect( - contextChipPresentation("file", { kind: "file", name: "clip.mp4", mimeType: "video/mp4" }), - ).toEqual({ accent: "#d06217", symbol: "play.rectangle" }); - }); -}); - -describe("pull request chip status", () => { - const chip = async (state: string, isDraft = false) => { - const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown"); - return contextChipPresentation("review-comment", { - kind: "review-comment", - sectionId: "pull-request:10978", - pullRequest: { state, isDraft }, - }); - }; - - it("colours a pull request by its state, the way web and the forge do", async () => { - expect((await chip("open")).accent).toBe("#009f6e"); - expect((await chip("open", true)).accent).toBe("#7f8793"); - expect((await chip("merged")).accent).toBe("#8a70dd"); - expect((await chip("closed")).accent).toBe("#d55665"); - }); - - it("keeps one glyph across every state, so only colour carries the status", async () => { - // Web draws a fixed `git-pull-request` and encodes state in colour alone. A per-state glyph - // here would put mobile out of step with it. - const symbols = await Promise.all( - [chip("open"), chip("open", true), chip("merged"), chip("closed")].map( - async (pending) => (await pending).symbol, - ), - ); - expect(new Set(symbols)).toEqual(new Set(["git-pull-request"])); - }); - - it("falls back to the generic pull request chip when the state is unknown", async () => { - const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown"); - // An older server may send no metadata at all; the chip still has to render. - expect( - contextChipPresentation("review-comment", { - kind: "review-comment", - sectionId: "pull-request:1", - }).accent, - ).toBe("#7079e4"); - }); -}); diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx new file mode 100644 index 000000000000..7625af73166d --- /dev/null +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -0,0 +1,314 @@ +import { useCallback, useMemo, useState } from "react"; +import { ActionSheetIOS, Platform, View } from "react-native"; +import { + EnrichedMarkdownText, + type DocumentAssetsEvent, + type MarkdownStyle, + type MarkdownMediaAsset, +} from "react-native-enriched-markdown"; +import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { AndroidAnchoredMenu } from "../components/AndroidAnchoredMenu"; +import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; +import { + ENRICHED_INLINE_FILE_LINK_REGEX, + enrichedSkillLinkRegex, +} from "../lib/enrichedLinkPresentation"; + +function enrichedStyle( + style: SelectableMarkdownTextProps["textStyle"], + dark: boolean, +): MarkdownStyle { + const monospace = Platform.OS === "ios" ? "Menlo" : "monospace"; + const body = { + color: style.color, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + marginTop: 0, + marginBottom: 8, + }; + const heading = (level: number) => { + const fontSize = style.headingFontSizes?.[level - 1] ?? style.fontSize * (1.6 - level * 0.1); + return { + ...body, + color: style.strongColor, + fontFamily: style.headingFontFamily, + fontSize, + lineHeight: fontSize * 1.35, + marginTop: 8, + }; + }; + return { + paragraph: body, + h1: heading(1), + h2: heading(2), + h3: heading(3), + h4: heading(4), + h5: heading(5), + h6: heading(6), + strong: { color: style.strongColor, fontFamily: style.boldFontFamily, fontWeight: "normal" }, + link: { color: style.linkColor, underline: false }, + code: { + fontFamily: monospace, + color: style.inlineCodeColor, + backgroundColor: style.codeBackgroundColor, + borderColor: "transparent", + fontSize: style.fontSize * 0.9, + }, + codeBlock: { + ...body, + color: style.codeColor, + backgroundColor: style.codeBlockBackgroundColor, + fontFamily: monospace, + fontSize: style.fontSize * 0.9, + borderRadius: 8, + padding: 12, + borderWidth: 1, + borderColor: style.dividerColor, + syntaxColors: dark + ? { + keyword: "#ff7b72", + string: "#a5d6ff", + number: "#79c0ff", + constant: "#79c0ff", + comment: "#8b949e", + function: "#d2a8ff", + type: "#ffa657", + property: "#79c0ff", + tag: "#7ee787", + attribute: "#79c0ff", + } + : { + keyword: "#cf222e", + string: "#0a3069", + number: "#0550ae", + constant: "#0550ae", + comment: "#6e7781", + function: "#8250df", + type: "#953800", + property: "#0550ae", + tag: "#116329", + attribute: "#0550ae", + }, + }, + blockquote: { + ...body, + color: style.mutedColor, + borderColor: style.quoteMarkerColor, + borderWidth: 2, + gapWidth: 12, + }, + list: { ...body, bulletColor: style.mutedColor, markerColor: style.mutedColor, gapWidth: 6 }, + thematicBreak: { color: style.dividerColor, height: 1, marginTop: 8, marginBottom: 8 }, + table: { + ...body, + borderColor: style.dividerColor, + borderWidth: 1, + headerFontFamily: style.boldFontFamily, + headerTextColor: style.strongColor, + headerBackgroundColor: style.codeBackgroundColor, + rowEvenBackgroundColor: "transparent", + rowOddBackgroundColor: "transparent", + cellPaddingHorizontal: 12, + cellPaddingVertical: 8, + }, + taskList: { + checkedColor: style.linkColor, + checkedTextColor: style.color, + borderColor: style.mutedColor, + checkedStrikethrough: false, + }, + }; +} + +export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { + const { renderImage, resolveImageSource } = props; + const { themeAppearance } = useAppearancePreferences(); + const [documentAssets, setDocumentAssets] = useState([]); + const onDocumentAssets = useCallback(({ assets }: DocumentAssetsEvent) => { + setDocumentAssets((previous) => + previous.length === assets.length && + previous.every((asset, index) => { + const next = assets[index]!; + return ( + asset.id === next.id && + asset.kind === next.kind && + asset.url === next.url && + asset.altText === next.altText && + asset.title === next.title && + asset.placement === next.placement && + asset.eligible === next.eligible + ); + }) + ? previous + : assets, + ); + }, []); + const linkVariants = useEnrichedLinkVariants(documentAssets, props); + const skillLinkRegex = useMemo(() => enrichedSkillLinkRegex(props.skills), [props.skills]); + const [activeMenu, setActiveMenu] = useState<{ + url: string; + menu: NonNullable>>; + } | null>(null); + const { fileContextMenu, onLinkPress, onFileContextMenuAction } = props; + const performMenuAction = useCallback( + (url: string, actionId: string) => { + if (actionId === "enriched-open-link") onLinkPress?.(url); + else if (actionId === "enriched-copy-link") copyTextWithHaptic(url); + else onFileContextMenuAction?.(url, actionId); + }, + [onLinkPress, onFileContextMenuAction], + ); + const linkContextMenus = useMemo( + () => + Platform.OS === "ios" && fileContextMenu + ? Object.fromEntries( + [ + ...new Set( + documentAssets.filter((asset) => asset.kind === "link").map((asset) => asset.url), + ), + ].map((url) => { + const menu = fileContextMenu(url) ?? { + title: url, + actions: [ + { id: "enriched-open-link", title: "Open link" }, + { id: "enriched-copy-link", title: "Copy link" }, + ], + }; + return [ + url, + { + title: menu.title, + items: menu.actions.map((action) => ({ + text: action.title, + disabled: "disabled" in action && action.disabled, + onPress: () => performMenuAction(url, action.id), + })), + }, + ]; + }), + ) + : undefined, + [documentAssets, fileContextMenu, performMenuAction], + ); + const markdownStyle = useMemo( + () => ({ ...enrichedStyle(props.textStyle, themeAppearance === "dark"), linkVariants }), + [props.textStyle, themeAppearance, linkVariants], + ); + const renderMedia = useCallback( + (asset: MarkdownMediaAsset) => + renderImage?.({ + href: asset.url, + alt: asset.altText || null, + title: asset.title || null, + }) ?? null, + [renderImage], + ); + const resolveNativeImageSource = useCallback( + (asset: MarkdownMediaAsset) => + resolveImageSource?.({ + href: asset.url, + alt: asset.altText || null, + title: asset.title || null, + }) ?? null, + [resolveImageSource], + ); + const renderMarkdown = (openAndroidMenu?: () => void) => { + const onLinkLongPress = props.fileContextMenu + ? ({ url }: { url: string }) => { + const menu = props.fileContextMenu?.(url) ?? { + title: url, + actions: [ + { id: "enriched-open-link", title: "Open link" }, + { id: "enriched-copy-link", title: "Copy link" }, + ], + }; + const actions = menu.actions; + if (Platform.OS === "ios") { + ActionSheetIOS.showActionSheetWithOptions( + { + title: menu.title, + options: [...actions.map((action) => action.title), "Cancel"], + cancelButtonIndex: actions.length, + disabledButtonIndices: actions.flatMap((action, index) => + action.disabled ? [index] : [], + ), + }, + (index) => { + const action = actions[index]; + if (action && !action.disabled) performMenuAction(url, action.id); + }, + ); + } else { + setActiveMenu({ url, menu }); + openAndroidMenu?.(); + } + } + : undefined; + + return ( + + (props.onImagePress ?? props.onLinkPress)?.(url) + : undefined + } + linkRegex={skillLinkRegex} + inlineCodeLinkRegex={ENRICHED_INLINE_FILE_LINK_REGEX} + containerStyle={{ flexShrink: 1, minWidth: 0 }} + flavor="github" + selectable + selectionColor={props.textStyle.selectionColor} + selectionHandleColor={props.textStyle.selectionHandleColor} + md4cFlags={{ + latexMath: false, + hardSoftBreaks: props.preserveSoftBreaks ?? false, + admonitions: false, + }} + enableTaskListItemToggle={false} + spoilerOverlay="solid" + onLinkPress={props.onLinkPress ? ({ url }) => props.onLinkPress?.(url) : undefined} + onLinkLongPress={onLinkLongPress} + linkContextMenus={linkContextMenus} + /> + + ); + }; + + return Platform.OS === "android" && props.fileContextMenu ? ( + ({ + id: action.id, + title: action.title, + attributes: { disabled: action.disabled }, + })) ?? [] + } + onPressAction={({ nativeEvent }) => { + if (activeMenu) performMenuAction(activeMenu.url, nativeEvent.event); + }} + style={{ flexShrink: 1, minWidth: 0 }} + > + {renderMarkdown} + + ) : ( + renderMarkdown() + ); +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.android.tsx b/apps/mobile/src/native/SelectableMarkdownText.android.tsx deleted file mode 100644 index 17788da414ed..000000000000 --- a/apps/mobile/src/native/SelectableMarkdownText.android.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { - SelectableMarkdownText as T3SelectableMarkdownText, - type SelectableMarkdownTextProps, -} from "@t3tools/mobile-markdown-text/renderer"; -import { useMemo } from "react"; - -import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; -import { themeColorWithAlpha } from "../lib/mobileTheme"; -import { useUniwindTheme } from "../lib/useUniwindTheme"; - -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) { - const theme = useUniwindTheme(); - const selectionColor = themeColorWithAlpha(theme["--color-focus"], 0.32); - const selectionHandleColor = theme["--color-focus"]; - const textStyle = useMemo( - () => ({ selectionColor, selectionHandleColor, ...props.textStyle }), - [props.textStyle, selectionColor, selectionHandleColor], - ); - return ( - - ); -} diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx deleted file mode 100644 index 55c2f818b3d7..000000000000 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { - SelectableMarkdownText as T3SelectableMarkdownText, - type SelectableMarkdownTextProps, -} from "@t3tools/mobile-markdown-text/renderer"; - -import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; - -type MobileSelectableMarkdownTextProps = Omit; - -export type { - MarkdownFileContextMenu, - MarkdownFileContextMenuAction, - MarkdownImageRenderer, - MarkdownImageRequest, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, -} from "@t3tools/mobile-markdown-text/types"; - -export function hasNativeSelectableMarkdownText(): boolean { - return true; -} - -export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { - return ; -} diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 8b54df001aef..89ec6fcaf81e 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -1,20 +1,32 @@ -import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; +import { useMemo } from "react"; +import { Platform } from "react-native"; +import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; -type MobileSelectableMarkdownTextProps = Omit; +import { MobileEnrichedMarkdownText } from "./EnrichedMarkdownText"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; +import { useUniwindTheme } from "../lib/useUniwindTheme"; export type { MarkdownFileContextMenu, MarkdownFileContextMenuAction, MarkdownImageRenderer, MarkdownImageRequest, + MarkdownImageSourceResolver, + MarkdownLinkCustomization, NativeMarkdownTextStyle, SelectableMarkdownSkill, -} from "@t3tools/mobile-markdown-text/types"; +} from "./SelectableMarkdownText.types"; -export function hasNativeSelectableMarkdownText(): boolean { - return false; -} - -export function SelectableMarkdownText(_props: MobileSelectableMarkdownTextProps) { - return null; +export function SelectableMarkdownText(props: SelectableMarkdownTextProps) { + const theme = useUniwindTheme(); + const selectionColor = themeColorWithAlpha(theme["--color-focus"], 0.32); + const selectionHandleColor = theme["--color-focus"]; + const textStyle = useMemo( + () => + Platform.OS === "android" + ? { selectionColor, selectionHandleColor, ...props.textStyle } + : props.textStyle, + [props.textStyle, selectionColor, selectionHandleColor], + ); + return ; } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts similarity index 68% rename from apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts rename to apps/mobile/src/native/SelectableMarkdownText.types.ts index adfa34365af6..47edecc2046d 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts @@ -22,29 +22,6 @@ export interface NativeMarkdownTextStyle { readonly headingFontSizes?: ReadonlyArray; } -export interface MarkdownHighlightedToken { - readonly content: string; - readonly color: string | null; - readonly fontStyle: number | null; -} - -export interface MarkdownCodeHighlightInput { - /** Identity of the mounted code block, for incremental highlighting. */ - readonly session?: object; - readonly code: string; - readonly language?: string | null; - readonly theme: "light" | "dark"; -} -export interface MarkdownCodeHighlighter { - ( - input: MarkdownCodeHighlightInput, - ): Promise>>; - /** Optional synchronous result for a small append to an already warm block. */ - read?: ( - input: MarkdownCodeHighlightInput, - ) => ReadonlyArray> | undefined; -} - export interface SelectableMarkdownSkill { readonly name: string; readonly displayName?: string | null; @@ -60,10 +37,17 @@ export interface MarkdownImageRequest { * App-supplied renderer for markdown images. The module cannot load * workspace-relative image paths itself — the host app resolves them (for * example through a signed asset URL) and returns the element to show. - * Returning null falls back to the module's plain remote-URI rendering. + * Returning null keeps Enriched's native media rendering. */ export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; +export type MarkdownImageSourceResolver = ( + image: MarkdownImageRequest, +) => + | { uri: string; headers?: Record } + | null + | Promise<{ uri: string; headers?: Record } | null>; + export interface MarkdownFileContextMenuAction { readonly id: string; readonly title: string; @@ -75,18 +59,25 @@ export interface MarkdownFileContextMenu { readonly actions: ReadonlyArray; } +export interface MarkdownLinkCustomization { + readonly iconTintColor?: string; + readonly color?: string; + readonly label?: string; + readonly icon?: import("react-native").ImageSourcePropType; +} + export interface SelectableMarkdownTextProps { readonly markdown: string; - /** Opaque context payload supplied by the host for native selection copy. */ - readonly contextClipboardFragment?: string; readonly textStyle: NativeMarkdownTextStyle; - readonly highlightCode: MarkdownCodeHighlighter; readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; readonly onFileContextMenuAction?: (href: string, actionId: string) => void; + readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined; readonly renderImage?: MarkdownImageRenderer; + readonly resolveImageSource?: MarkdownImageSourceResolver; + readonly onImagePress?: (href: string) => void; readonly marginTop?: number; readonly marginBottom?: number; } diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 5b1a28d5df1c..939fbd0b0512 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -15,12 +15,9 @@ import { import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; import { Image, StyleSheet } from "react-native"; -import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; -import { - composerChipSizeSuffix, - contextChipPresentation, -} from "@t3tools/mobile-markdown-text/markdown"; -import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { markdownFileIconSource } from "../lib/markdownFileIcons"; +import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation"; +import { resolveMarkdownFileIcon } from "../lib/markdownLinks"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; import { useFontFamily } from "../lib/useFontFamily"; diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 7f18bae0570a..d8d5bdb6321a 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -16,12 +16,9 @@ import { import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { Image, Platform, StyleSheet } from "react-native"; -import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; -import { - composerChipSizeSuffix, - contextChipPresentation, -} from "@t3tools/mobile-markdown-text/markdown"; -import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { markdownFileIconSource } from "../lib/markdownFileIcons"; +import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation"; +import { resolveMarkdownFileIcon } from "../lib/markdownLinks"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts new file mode 100644 index 000000000000..742316f2d494 --- /dev/null +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -0,0 +1,152 @@ +import { Asset } from "expo-asset"; +import { useEffect, useMemo, useState } from "react"; +import type { ImageSourcePropType } from "react-native"; +import type { MarkdownDocumentAsset, MarkdownStyle } from "react-native-enriched-markdown"; +import { markdownFileIconSource } from "../lib/markdownFileIcons"; +import { markdownLinkIconSource } from "../lib/markdownLinkIcons"; +import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; +import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; + +import { + enrichedContextLinkPresentation, + enrichedLinkVariantPattern, + enrichedSkillDisplayName, +} from "../lib/enrichedLinkPresentation"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; + +// Native pill drawing accepts local image URIs. Expo resolves bundled PNGs once, +// including Metro's HTTP asset sources during development. +const localIconUris = new Map(); +const iconDownloads = new Map>(); + +function localIconUri(source: number) { + const cached = localIconUris.get(source); + if (cached) return Promise.resolve(cached); + const existing = iconDownloads.get(source); + if (existing) return existing; + const download = Asset.fromModule(source) + .downloadAsync() + .then((asset) => { + if (asset.localUri) localIconUris.set(source, asset.localUri); + return asset.localUri; + }) + .catch(() => { + iconDownloads.delete(source); + return null; + }); + iconDownloads.set(source, download); + return download; +} + +function presentation( + url: string, + props: Pick, +) { + const custom = props.linkCustomization?.(url); + if (custom) return custom; + const context = enrichedContextLinkPresentation(url); + if (context) return { color: context.color, icon: markdownFileIconSource(context.icon) }; + const skill = url.startsWith("$") + ? props.skills?.find((candidate) => `$${candidate.name}` === url) + : undefined; + if (skill) + return { + color: props.textStyle.skillTextColor, + label: enrichedSkillDisplayName(skill), + icon: markdownFileIconSource("mcp"), + }; + const link = resolveMarkdownLinkPresentation(url); + if (link.kind === "file") + return { + color: props.textStyle.fileTextColor, + label: link.label, + icon: markdownFileIconSource(link.icon), + }; + const icon = link.kind === "external" ? resolveMarkdownLinkIcon(link.host) : null; + return icon + ? { + color: props.textStyle.linkColor, + icon: markdownLinkIconSource(icon), + iconTintColor: props.textStyle.linkColor, + } + : undefined; +} + +function iconUri(source: ImageSourcePropType | undefined, downloaded: ReadonlyMap) { + if (typeof source === "number") return downloaded.get(source); + if (source && !Array.isArray(source) && "uri" in source && source.uri?.startsWith("file://")) { + return source.uri; + } + return undefined; +} + +export function useEnrichedLinkVariants( + assets: ReadonlyArray, + props: SelectableMarkdownTextProps, +): MarkdownStyle["linkVariants"] { + const { linkCustomization, skills, textStyle } = props; + const links = useMemo(() => { + const byUrl = new Map>>(); + for (const asset of assets) { + if (asset.kind !== "link" || byUrl.has(asset.url)) continue; + const value = presentation(asset.url, { linkCustomization, skills, textStyle }); + if (value) byUrl.set(asset.url, value); + } + return byUrl; + }, [assets, linkCustomization, skills, textStyle]); + const sourceKey = [ + ...new Set( + [...links.values()].flatMap((link) => (typeof link.icon === "number" ? [link.icon] : [])), + ), + ] + .sort((a, b) => a - b) + .join(","); + const [downloaded, setDownloaded] = useState(() => new Map(localIconUris)); + useEffect(() => { + let cancelled = false; + const sources = sourceKey ? sourceKey.split(",").map(Number) : []; + const missing = sources.filter((source) => !localIconUris.has(source)); + if (sources.length === 0) return; + void Promise.all(missing.map(localIconUri)).then(() => { + if (!cancelled) + setDownloaded((previous) => + sources.every((source) => previous.get(source) === localIconUris.get(source)) + ? previous + : new Map(localIconUris), + ); + }); + return () => { + cancelled = true; + }; + }, [sourceKey]); + + return useMemo( + () => + Object.fromEntries( + [...links].map(([url, link]) => { + const color = link.color ?? props.textStyle.fileTextColor; + return [ + enrichedLinkVariantPattern(url), + { + pill: { + label: link.label, + iconUri: iconUri(link.icon, downloaded), + iconTintColor: link.iconTintColor, + borderColor: + props.textStyle.contextChipBorderColor ?? themeColorWithAlpha(color, 0.2), + borderWidth: 0.5, + borderRadius: 6, + paddingHorizontal: 5, + paddingVertical: 2, + }, + fontFamily: props.textStyle.fontFamily, + color, + underline: false, + backgroundColor: themeColorWithAlpha(color, 0.08), + }, + ]; + }), + ), + [links, downloaded, props.textStyle], + ); +} diff --git a/apps/mobile/src/native/useMarkdownImageSource.ts b/apps/mobile/src/native/useMarkdownImageSource.ts new file mode 100644 index 000000000000..32eeac51b85f --- /dev/null +++ b/apps/mobile/src/native/useMarkdownImageSource.ts @@ -0,0 +1,52 @@ +import { resolveMobileMarkdownMediaSource } from "../lib/markdownMediaSource"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { MarkdownImageSourceResolver } from "./SelectableMarkdownText.types"; +import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks"; +import { useCallback } from "react"; +import * as Option from "effect/Option"; + +import { assetEnvironment, useAssetConnectionPhase } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; + +/** Native nested images use the same signed asset queries as app-owned media. */ +export function useMarkdownImageSource(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId | null; + readonly workspaceRoot: string | null; + readonly captured?: boolean; +}): MarkdownImageSourceResolver { + const { environmentId, threadId, workspaceRoot, captured } = props; + const connection = usePreparedConnection(environmentId); + const connectionPhase = useAssetConnectionPhase(environmentId); + const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); + return useCallback( + async (image) => { + const media = resolveMobileMarkdownMediaSource(image.href, { + threadId: threadId ?? undefined, + workspaceRoot, + imageEmbed: true, + }); + if (media?.access === "direct") return { uri: normalizeNativeMarkdownUrl(media.uri) }; + if ( + captured || + media === null || + media.access === "unavailable" || + httpBaseUrl === null || + connectionPhase === "offline" || + connectionPhase === "error" || + connectionPhase === "unsupported" || + connectionPhase === "reconnecting" + ) + throw new Error("Markdown image is unavailable"); + const result = await createUrl({ environmentId, input: { resource: media.resource } }); + if (result._tag !== "Success") throw new Error("Could not authorize Markdown image"); + const uri = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + if (uri === null) throw new Error("Markdown image URL is invalid"); + return { uri }; + }, + [captured, connectionPhase, createUrl, environmentId, httpBaseUrl, threadId, workspaceRoot], + ); +} diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 15cbd1d9a89f..11f201a486ed 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -35,7 +35,9 @@ const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial( Atom.withLabel("mobile-asset-connection-state:empty"), ); -function useConnectionPhase(environmentId: EnvironmentId | null): EnvironmentConnectionPhase { +export function useAssetConnectionPhase( + environmentId: EnvironmentId | null, +): EnvironmentConnectionPhase { const state = useAtomValue( environmentId === null ? EMPTY_CONNECTION_STATE_ATOM @@ -50,7 +52,7 @@ export function useAssetUrlState( resource: AssetResource | null, ): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); - const connectionPhase = useConnectionPhase(environmentId); + const connectionPhase = useAssetConnectionPhase(environmentId); const result = useAtomValue( environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM diff --git a/knip.jsonc b/knip.jsonc index 5e87b4d5bc55..8e96acf89796 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -72,7 +72,7 @@ "@t3tools/mobile-terminal-native", ], }, - "apps/mobile/modules/t3-markdown-text": { + "apps/mobile/modules/t3-source-text": { // React Native codegen discovers component specifications by filename. "entry": ["src/*NativeComponent.ts!"], }, diff --git a/package.json b/package.json index ab88fbeb4d6e..d0c1900c7177 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,8 @@ "engines": { "node": "^24.13.1" }, - "packageManager": "pnpm@11.10.0" + "packageManager": "pnpm@11.10.0", + "enriched-markdown": { + "enableMath": false + } } diff --git a/patches/react-native-nitro-modules@0.35.9.patch b/patches/react-native-nitro-modules@0.35.9.patch deleted file mode 100644 index d70069dd4d1a..000000000000 --- a/patches/react-native-nitro-modules@0.35.9.patch +++ /dev/null @@ -1,16 +0,0 @@ -diff --git a/package.json b/package.json -index 4e4366fb9d05a6c250acfc62e79487475d8ac05b..200e4680d77cbb4f14d22eb986efde1275a0d3e8 100644 ---- a/package.json -+++ b/package.json -@@ -88,6 +88,11 @@ - "jsSrcsDir": "./src", - "android": { - "javaPackageName": "com.margelo.nitro" -+ }, -+ "ios": { -+ "modulesProvider": { -+ "NitroModules": "NativeNitroModules" -+ } - } - }, - "jest": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0daed3ff962..02d66ace0fa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,7 +108,6 @@ patchedDependencies: expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6 react-native-gesture-handler@2.32.0: 0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 - react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90 @@ -296,12 +295,12 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts - '@t3tools/mobile-markdown-text': - specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(6f0a94b6f541bbd4be3f343e27991996) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff + '@t3tools/mobile-source-text': + specifier: file:./modules/t3-source-text + version: file:apps/mobile/modules/t3-source-text(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) '@t3tools/mobile-terminal-native': specifier: file:./modules/t3-terminal version: file:apps/mobile/modules/t3-terminal @@ -434,6 +433,9 @@ importers: react-native: specifier: 0.86.3 version: 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-enriched-markdown: + specifier: file:./deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz + version: file:apps/mobile/deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz(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-gesture-handler: specifier: ~2.32.0 version: 2.32.0(patch_hash=0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2)(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) @@ -443,12 +445,6 @@ importers: react-native-keyboard-controller: specifier: 1.21.13 version: 1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@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))(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@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@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-nitro-markdown: - specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(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-svg@15.15.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-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-nitro-modules: - specifier: 0.35.9 - version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(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-reanimated: specifier: 4.5.5 version: 4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@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))(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@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) @@ -4697,22 +4693,14 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': - resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': + resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} + + '@t3tools/mobile-source-text@file:apps/mobile/modules/t3-source-text': + resolution: {directory: apps/mobile/modules/t3-source-text, type: directory} peerDependencies: - '@t3tools/client-runtime': '*' - '@t3tools/shared': '*' - expo: '*' - expo-asset: '*' - expo-clipboard: '*' - expo-haptics: '*' - expo-symbols: '*' react: '*' react-native: '*' - react-native-nitro-markdown: '*' - - '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': - resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': resolution: {directory: apps/mobile/modules/t3-terminal, type: directory} @@ -9322,6 +9310,17 @@ packages: '@types/react': '>=18' react: '>=18' + react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz: + resolution: {integrity: sha512-u5tU9M53S0G1kLDNqGEJfBsO3o1f6kKLARyZ+djFaB+jfpP6gwREAvpIpo+hP0xTi/383GO/ea05SZ3qB73WCQ==, tarball: file:apps/mobile/deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz} + version: 1.1.0-nightly-20260918-bb2b0942a + peerDependencies: + katex: '>=0.16.0' + react: '*' + react-native: '*' + peerDependenciesMeta: + katex: + optional: true + react-native-gesture-handler@2.32.0: resolution: {integrity: sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==} peerDependencies: @@ -9347,26 +9346,6 @@ packages: react-native: '*' react-native-reanimated: '>=3.0.0' - react-native-nitro-markdown@0.5.8: - resolution: {integrity: sha512-bBKmdWgMqZoNozuHzJgc8VxWRSjLnrHAK0bRRvz7gj5FqPxLJz+KhRV0VYZDZgvsR3S8hnmmeEqluTRBTRw+yA==} - peerDependencies: - react: '*' - react-native: '>=0.75.0' - react-native-mathjax-svg: '>=0.9.0' - react-native-nitro-modules: '>=0.35.5' - react-native-svg: '>=13.0.0' - peerDependenciesMeta: - react-native-mathjax-svg: - optional: true - react-native-svg: - optional: true - - react-native-nitro-modules@0.35.9: - resolution: {integrity: sha512-yCO6eJ85SPPUo4a4an7H5oj6wPCSIT72fbjr5WZ/20n6zswaJ2gNNpnWtg2We0AZwkAOjSqkOJ0Vjc05p6kGiA==} - peerDependencies: - react: '*' - react-native: '*' - react-native-reanimated@4.5.5: resolution: {integrity: sha512-xtJXZRZ1vkec1AIUVG02St2sbyZS5jc7227TlDu/HonqUYPeZAfiC4RI2jqL1dVMNBu1+7oBx/c9QkjF2kg6Lg==} peerDependencies: @@ -14804,20 +14783,12 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(6f0a94b6f541bbd4be3f343e27991996)': + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} + + '@t3tools/mobile-source-text@file:apps/mobile/modules/t3-source-text(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: - '@t3tools/client-runtime': link:packages/client-runtime - '@t3tools/shared': link:packages/shared - expo: 57.0.18(41fd11498a34454c91128cdad32f22f8) - 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@7.0.2) - 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) - expo-symbols: 57.0.2(expo-font@57.0.2(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@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) 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-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(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-svg@15.15.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-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) - - '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} @@ -20320,6 +20291,11 @@ snapshots: transitivePeerDependencies: - supports-color + react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz(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-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-gesture-handler@2.32.0(patch_hash=0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2)(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: '@egjs/hammerjs': 2.0.17 @@ -20346,19 +20322,6 @@ snapshots: react-native-is-edge-to-edge: 1.3.1(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-reanimated: 4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@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))(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@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-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(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-svg@15.15.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-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-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-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(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) - optionalDependencies: - react-native-svg: 15.15.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-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(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-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-reanimated@4.5.5(patch_hash=bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d)(react-native-worklets@0.11.4(@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))(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@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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7c7106bd7637..55a69481d7b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,7 @@ allowBuilds: msw: false node-pty: true sharp: true + react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.1.0-nightly-20260918-bb2b0942a-main-compat-v1.tgz: true utf-8-validate: false workerd: false @@ -194,7 +195,6 @@ patchedDependencies: expo-widgets@57.0.15: patches/expo-widgets@57.0.15.patch react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch - react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch # Preserve the final layout frame. Backport of [#10171](https://github.com/software-mansion/react-native-reanimated/pull/10171). react-native-reanimated@4.5.5: patches/react-native-reanimated@4.5.5.patch react-native-screens@4.26.2: patches/react-native-screens@4.26.2.patch diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index e33116bb1dbf..3d706ffce483 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -17,8 +17,7 @@ const workspaceFiles = [ "apps/desktop/package.json", "apps/web/package.json", "apps/mobile/package.json", - "apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz", - "apps/mobile/modules/t3-markdown-text/package.json", + "apps/mobile/modules/t3-source-text/package.json", "apps/mobile/modules/t3-review-diff/package.json", "apps/mobile/modules/t3-terminal/package.json", "apps/marketing/package.json", @@ -46,6 +45,13 @@ function copyWorkspaceManifestFixture(targetRoot: string): void { if (NodeFS.existsSync(patchesDirectory)) { NodeFS.cpSync(patchesDirectory, NodePath.resolve(targetRoot, "patches"), { recursive: true }); } + + const mobileDepsDirectory = NodePath.resolve(repoRoot, "apps/mobile/deps"); + if (NodeFS.existsSync(mobileDepsDirectory)) { + NodeFS.cpSync(mobileDepsDirectory, NodePath.resolve(targetRoot, "apps/mobile/deps"), { + recursive: true, + }); + } } function writeMacManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 01955554adf6..a72dbb00b4d2 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -139,7 +139,7 @@ "bundles": ["mobile"], "license": "MIT", "name": "react-native-uitextview", - "noticeFile": "apps/mobile/modules/t3-markdown-text/LICENSE", + "noticeFile": "apps/mobile/modules/t3-source-text/LICENSE", "sourceUrl": "https://github.com/bluesky-social/react-native-uitextview", "version": "2.2.0" }, @@ -445,13 +445,6 @@ "preamble": ["badgin is a refactored fork of Tinycon."] } }, - { - "name": "react-native-nitro-modules", - "generatedNotice": { - "licenseId": "MIT", - "copyrights": ["Copyright (c) 2024 Marc Rousavy"] - } - }, { "name": "boolbase", "generatedNotice": { diff --git a/vite.config.ts b/vite.config.ts index 4e6530160a25..e837606110c7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -208,7 +208,7 @@ export default defineConfig({ "apps/mobile/src/lib/useMobileNavigationTheme.ts", "apps/mobile/src/native/T3ComposerEditor.ios.tsx", "apps/mobile/src/native/T3ComposerEditor.native.tsx", - "apps/mobile/src/native/SelectableMarkdownText.android.tsx", + "apps/mobile/src/native/SelectableMarkdownText.tsx", ], rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }],