authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 17:22:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 17:22:00-07:00
logc5d7309152f2c93f7334afe54f99c41372e15d10
tree5fb9b7d6ea3d1583ea3fcc1132387dbf37183a61
parent6ba6a0daf44333ba6615464118e36d79f6220731
signature Signed by PGP key B5690EEEBB952194

feat(mobile): replace custom Markdown rendering with Enriched (#16979)


248 files changed, 3950 insertions(+), 9478 deletions(-)

apps/mobile/assets/file-icons/pierre_agents.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_agents.png differ
apps/mobile/assets/file-icons/pierre_astro.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_astro.png differ
apps/mobile/assets/file-icons/pierre_babel.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_babel.png differ
apps/mobile/assets/file-icons/pierre_bash.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_bash.png differ
apps/mobile/assets/file-icons/pierre_biome.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_biome.png differ
apps/mobile/assets/file-icons/pierre_bootstrap.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_bootstrap.png differ
apps/mobile/assets/file-icons/pierre_browserslist.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_browserslist.png differ
apps/mobile/assets/file-icons/pierre_bun.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_bun.png differ
apps/mobile/assets/file-icons/pierre_c.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_c.png differ
apps/mobile/assets/file-icons/pierre_claude.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_claude.png differ
apps/mobile/assets/file-icons/pierre_cpp.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_cpp.png differ
apps/mobile/assets/file-icons/pierre_css.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_css.png differ
apps/mobile/assets/file-icons/pierre_database.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_database.png differ
apps/mobile/assets/file-icons/pierre_default.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_default.png differ
apps/mobile/assets/file-icons/pierre_docker.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_docker.png differ
apps/mobile/assets/file-icons/pierre_eslint.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_eslint.png differ
apps/mobile/assets/file-icons/pierre_font.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_font.png differ
apps/mobile/assets/file-icons/pierre_git.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_git.png differ
apps/mobile/assets/file-icons/pierre_go.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_go.png differ
apps/mobile/assets/file-icons/pierre_graphql.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_graphql.png differ
apps/mobile/assets/file-icons/pierre_html.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_html.png differ
apps/mobile/assets/file-icons/pierre_image.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_image.png differ
apps/mobile/assets/file-icons/pierre_javascript.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_javascript.png differ
apps/mobile/assets/file-icons/pierre_json.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_json.png differ
apps/mobile/assets/file-icons/pierre_markdown.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_markdown.png differ
apps/mobile/assets/file-icons/pierre_mcp.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_mcp.png differ
apps/mobile/assets/file-icons/pierre_nextjs.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_nextjs.png differ
apps/mobile/assets/file-icons/pierre_npm.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_npm.png differ
apps/mobile/assets/file-icons/pierre_oxc.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_oxc.png differ
apps/mobile/assets/file-icons/pierre_pnpm.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_pnpm.png differ
apps/mobile/assets/file-icons/pierre_postcss.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_postcss.png differ
apps/mobile/assets/file-icons/pierre_prettier.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_prettier.png differ
apps/mobile/assets/file-icons/pierre_python.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_python.png differ
apps/mobile/assets/file-icons/pierre_react.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_react.png differ
apps/mobile/assets/file-icons/pierre_ruby.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_ruby.png differ
apps/mobile/assets/file-icons/pierre_rust.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_rust.png differ
apps/mobile/assets/file-icons/pierre_sass.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_sass.png differ
apps/mobile/assets/file-icons/pierre_stylelint.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_stylelint.png differ
apps/mobile/assets/file-icons/pierre_svelte.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_svelte.png differ
apps/mobile/assets/file-icons/pierre_svg.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_svg.png differ
apps/mobile/assets/file-icons/pierre_svgo.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_svgo.png differ
apps/mobile/assets/file-icons/pierre_swift.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_swift.png differ
apps/mobile/assets/file-icons/pierre_table.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_table.png differ
apps/mobile/assets/file-icons/pierre_tailwind.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_tailwind.png differ
apps/mobile/assets/file-icons/pierre_terraform.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_terraform.png differ
apps/mobile/assets/file-icons/pierre_text.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_text.png differ
apps/mobile/assets/file-icons/pierre_typescript.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_typescript.png differ
apps/mobile/assets/file-icons/pierre_video.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_video.png differ
apps/mobile/assets/file-icons/pierre_vite.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_vite.png differ
apps/mobile/assets/file-icons/pierre_vscode.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_vscode.png differ
apps/mobile/assets/file-icons/pierre_vue.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_vue.png differ
apps/mobile/assets/file-icons/pierre_wasm.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_wasm.png differ
apps/mobile/assets/file-icons/pierre_webpack.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_webpack.png differ
apps/mobile/assets/file-icons/pierre_yml.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_yml.png differ
apps/mobile/assets/file-icons/pierre_zig.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_zig.png differ
apps/mobile/assets/file-icons/pierre_zip.png created
Binary files /dev/null and b/apps/mobile/assets/file-icons/pierre_zip.png differ
apps/mobile/assets/link-icons/github.png created
Binary files /dev/null and b/apps/mobile/assets/link-icons/github.png differ
apps/mobile/deps/README.md created+3
...@@ -0,0 +1,3 @@
1The Enriched Markdown tarball comes from the [`fork/t3-mobile` branch](https://github.com/juliusmarminge/enriched-markdown/tree/fork/t3-mobile). Its `t3-fork.json` records the exact source commit and upstream base. File/skill link recognition, back navigation from horizontal blocks, table-image presses, and code-block headers use upstream implementations. The remaining fork adds clipboard metadata, parsed asset manifests, signed image source resolution, React media slots, and development-host autolinks.
2
3To replace it, commit the fork changes, run `yarn install --immutable` and `yarn workspace react-native-enriched-markdown prepare` in the fork checkout, then `node scripts/pack-t3.mjs /path/to/t3code/apps/mobile/deps`. Bump the fork package version for each replacement, update the tarball path in `apps/mobile/package.json` and `pnpm-workspace.yaml`'s `allowBuilds`, and run `vp i` to update the lockfile. Native clients need rebuilding when this dependency changes.
apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz created
Binary files /dev/null and b/apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz differ
apps/mobile/modules/t3-composer-editor/android/build.gradle-1
...@@ -20,7 +20,6 @@ android {...@@ -20,7 +20,6 @@ android {
2020
21dependencies {21dependencies {
22 implementation project(':expo-modules-core')22 implementation project(':expo-modules-core')
23 implementation project(':t3tools-mobile-markdown-text')
24 testImplementation 'junit:junit:4.13.2'23 testImplementation 'junit:junit:4.13.2'
25 testImplementation 'org.robolectric:robolectric:4.16.1'24 testImplementation 'org.robolectric:robolectric:4.16.1'
26}25}
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt-1
...@@ -29,7 +29,6 @@ import android.widget.EditText...@@ -29,7 +29,6 @@ import android.widget.EditText
29import expo.modules.kotlin.AppContext29import expo.modules.kotlin.AppContext
30import expo.modules.kotlin.viewevent.EventDispatcher30import expo.modules.kotlin.viewevent.EventDispatcher
31import expo.modules.kotlin.views.ExpoView31import expo.modules.kotlin.views.ExpoView
32import expo.modules.t3markdowntext.T3ContextChip
33import org.json.JSONObject32import org.json.JSONObject
34import kotlin.math.max33import kotlin.math.max
3534
apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt created+184
...@@ -0,0 +1,184 @@
1package expo.modules.t3composereditor
2
3import android.graphics.Canvas
4import android.graphics.Color
5import android.graphics.Paint
6import android.graphics.Path
7import android.graphics.RectF
8import android.graphics.Typeface
9import android.text.TextPaint
10import android.text.TextUtils
11import kotlin.math.ceil
12import kotlin.math.min
13
14/** Shared by editable spans and inline chat images so their metrics and colors agree. */
15class T3ContextChip(
16 content: Content,
17 fontSize: Float,
18 colors: Colors,
19 maximumWidth: Float,
20 private val density: Float
21) {
22 /** What the chip says: its name, the size beside it, and the glyph that leads it. */
23 data class Content(val label: String, val symbol: String, val detail: String = "")
24
25 data class Colors(val accent: Int, val foreground: Int, val border: Int)
26
27 private val symbol = content.symbol
28
29 private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
30 textSize = fontSize
31 typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
32 }
33 private val em = fontSize
34
35 // The size reads as metadata beside the name, so it draws a step down from the label the
36 // way the web chip does.
37 private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
38 textSize = fontSize * 0.84f
39 typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
40 }
41 private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}"
42 private val detailWidth = if (detailText.isEmpty()) 0f else detailPaint.measureText(detailText)
43
44 // The border is stroked, and a stroke straddles the path it follows, so the box has to
45 // reserve a stroke on every side it insets. Reserving it once left the right edge a
46 // stroke short, and antialiasing shaved that curve flat against the span bounds.
47 private val strokeWidth = density
48 private val inset = strokeWidth * 2f
49 val width = ceil(
50 min(
51 maximumWidth.coerceAtLeast(em * 3),
52 paint.measureText(content.label) + detailWidth + em * 2.5f + inset
53 )
54 )
55 val height = ceil(em * 1.41f + inset)
56 private val text = TextUtils.ellipsize(
57 content.label,
58 paint,
59 (width - em * 2.5f - inset - detailWidth).coerceAtLeast(0f),
60 TextUtils.TruncateAt.MIDDLE
61 ).toString()
62 private val fill = Color.argb(
63 28,
64 Color.red(colors.accent),
65 Color.green(colors.accent),
66 Color.blue(colors.accent)
67 )
68 private val textColor = blend(colors.accent, colors.foreground, 0.22f)
69 private val borderColor = blend(colors.accent, colors.border, 0.34f)
70
71 // Half a stroke keeps the border inside the box; the rest of the reserved margin is
72 // slack, so an antialiased edge fades out before it reaches the span bounds.
73 private val shape =
74 RectF(inset / 2f, inset / 2f, width - inset / 2f, height - inset / 2f)
75 private val icon = iconPath(symbol)
76
77 fun draw(canvas: Canvas, x: Float, y: Float) {
78 canvas.save()
79 canvas.translate(x, y)
80 paint.style = Paint.Style.FILL
81 paint.color = fill
82 canvas.drawRoundRect(shape, em / 2, em / 2, paint)
83 paint.style = Paint.Style.STROKE
84 paint.strokeWidth = strokeWidth
85 paint.color = borderColor
86 canvas.drawRoundRect(shape, em / 2, em / 2, paint)
87 paint.color = textColor
88 canvas.save()
89 val iconSize = em * 1.17f
90 canvas.translate(em / 2 + inset / 2f, (height - iconSize) / 2)
91 canvas.scale(iconSize / 24, iconSize / 24)
92 paint.strokeWidth = 1.7f
93 paint.strokeJoin = Paint.Join.ROUND
94 paint.strokeCap = Paint.Cap.ROUND
95 canvas.drawPath(icon, paint)
96 canvas.restore()
97 paint.style = Paint.Style.FILL
98 val metrics = paint.fontMetrics
99 val baseline = (height - metrics.descent - metrics.ascent) / 2
100 canvas.drawText(text, em * 2 + inset / 2f, baseline, paint)
101 if (detailText.isNotEmpty()) {
102 detailPaint.color = paint.color
103 canvas.drawText(
104 detailText,
105 em * 2 + inset / 2f + paint.measureText(text),
106 baseline,
107 detailPaint
108 )
109 }
110 canvas.restore()
111 }
112
113 companion object {
114 fun color(
115 value: String,
116 fallback: Int
117 ): Int = runCatching {
118 // React Native hex colors put alpha last; Android's parser puts it first.
119 val androidColor = if (value.length == 9 && value.startsWith("#")) {
120 "#${value.takeLast(2)}${value.substring(1, 7)}"
121 } else {
122 value
123 }
124 Color.parseColor(androidColor)
125 }.getOrDefault(fallback)
126
127 private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb(
128 (Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(),
129 (Color.green(accent) * weight + Color.green(base) * (1 - weight)).toInt(),
130 (Color.blue(accent) * weight + Color.blue(base) * (1 - weight)).toInt(),
131 )
132
133 private fun iconPath(symbol: String) = Path().apply {
134 fun line(vararg points: Float) {
135 moveTo(points[0], points[1])
136 for (index in 2 until points.size step 2) lineTo(points[index], points[index + 1])
137 }
138 when (symbol) {
139 "cube" -> {
140 line(12f, 2f, 21f, 7f, 21f, 17f, 12f, 22f, 3f, 17f, 3f, 7f, 12f, 2f)
141 line(3f, 7f, 12f, 12f, 21f, 7f)
142 line(12f, 12f, 12f, 22f)
143 }
144 // lucide `git-pull-request`, the glyph web draws: two nodes, an elbow, and a stem.
145 "git-pull-request" -> {
146 addCircle(18f, 18f, 3f, Path.Direction.CW)
147 addCircle(6f, 6f, 3f, Path.Direction.CW)
148 moveTo(13f, 6f)
149 lineTo(16f, 6f)
150 cubicTo(17.1f, 6f, 18f, 6.9f, 18f, 8f)
151 lineTo(18f, 15f)
152 line(6f, 9f, 6f, 21f)
153 }
154 "cursorarrow.click" -> {
155 line(4f, 3f, 19f, 12f, 12f, 14f, 9f, 21f, 4f, 3f)
156 line(13f, 15f, 18f, 21f)
157 }
158 "text.bubble" -> {
159 line(3f, 4f, 21f, 4f, 21f, 17f, 10f, 17f, 5f, 21f, 5f, 17f, 3f, 17f, 3f, 4f)
160 line(7f, 8f, 17f, 8f)
161 line(7f, 12f, 14f, 12f)
162 }
163 "terminal", "play.rectangle", "photo" -> {
164 addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW)
165 when (symbol) {
166 "terminal" -> {
167 line(6f, 8f, 10f, 12f, 6f, 16f)
168 line(13f, 16f, 18f, 16f)
169 }
170 "play.rectangle" -> line(9f, 8f, 16f, 12f, 9f, 16f, 9f, 8f)
171 else -> {
172 addCircle(8f, 9f, 1.5f, Path.Direction.CW)
173 line(3f, 18f, 11f, 12f, 15f, 15f, 18f, 12f, 21f, 16f)
174 }
175 }
176 }
177 else -> {
178 line(5f, 2f, 14f, 2f, 20f, 8f, 20f, 22f, 5f, 22f, 5f, 2f)
179 line(14f, 2f, 14f, 8f, 20f, 8f)
180 }
181 }
182 }
183 }
184}
apps/mobile/modules/t3-markdown-text/LICENSE deleted-20
...@@ -1,20 +0,0 @@
1MIT License
2
3Copyright (c) 2024-25 Bluesky PBC
4Permission is hereby granted, free of charge, to any person obtaining a copy
5of this software and associated documentation files (the "Software"), to deal
6in the Software without restriction, including without limitation the rights
7to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8copies of the Software, and to permit persons to whom the Software is
9furnished to do so, subject to the following conditions:
10
11The above copyright notice and this permission notice shall be included in all
12copies or substantial portions of the Software.
13
14THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20SOFTWARE.
apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec deleted-25
...@@ -1,25 +0,0 @@
1require "json"
2
3package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1"
5
6Pod::Spec.new do |s|
7 s.name = "T3MarkdownText"
8 s.version = package["version"]
9 s.summary = "Native selectable markdown renderer for T3 Code mobile."
10 s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code."
11 s.homepage = "https://t3tools.com"
12 s.license = { :type => "MIT", :file => "LICENSE" }
13 s.author = { "T3 Tools" => "hello@t3tools.com" }
14 s.platforms = { :ios => min_ios_version_supported }
15 s.source = { :path => "." }
16 s.source_files = "ios/**/*.{h,m,mm,cpp}"
17
18 install_modules_dependencies(s)
19
20 if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled
21 add_dependency(s, "React-FabricComponents", :additional_framework_paths => [
22 "react/renderer/textlayoutmanager/platform/ios",
23 ])
24 end
25end
apps/mobile/modules/t3-markdown-text/UPSTREAM.md deleted-12
...@@ -1,12 +0,0 @@
1# Upstream Attribution
2
3The Fabric attributed-text component in this module originated from
4[`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview),
5version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`.
6
7The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under
8the MIT License included in this directory.
9
10T3 Code has substantially modified and renamed the implementation, integrated
11its markdown renderer, and owns the resulting module going forward. This is not
12an upstream package dependency or a compatibility fork.
apps/mobile/modules/t3-markdown-text/android/build.gradle deleted-32
...@@ -1,32 +0,0 @@
1apply plugin: 'com.android.library'
2apply plugin: 'org.jetbrains.kotlin.android'
3
4group = 'com.t3tools.markdowntext'
5version = '0.0.0'
6
7android {
8 namespace 'expo.modules.t3markdowntext'
9 compileSdk rootProject.ext.compileSdkVersion
10
11 testOptions {
12 unitTests.includeAndroidResources = true
13 }
14
15 defaultConfig {
16 minSdkVersion rootProject.ext.minSdkVersion
17 targetSdkVersion rootProject.ext.targetSdkVersion
18 }
19}
20
21dependencies {
22 implementation project(':expo-modules-core')
23 implementation 'com.facebook.react:react-android'
24 testImplementation 'junit:junit:4.13.2'
25 testImplementation 'org.robolectric:robolectric:4.16.1'
26}
27
28tasks.withType(Test).configureEach {
29 javaLauncher = javaToolchains.launcherFor {
30 languageVersion = JavaLanguageVersion.of(21)
31 }
32}
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt deleted-184
...@@ -1,184 +0,0 @@
1package expo.modules.t3markdowntext
2
3import android.graphics.Canvas
4import android.graphics.Color
5import android.graphics.Paint
6import android.graphics.Path
7import android.graphics.RectF
8import android.graphics.Typeface
9import android.text.TextPaint
10import android.text.TextUtils
11import kotlin.math.ceil
12import kotlin.math.min
13
14/** Shared by editable spans and inline chat images so their metrics and colors agree. */
15class T3ContextChip(
16 content: Content,
17 fontSize: Float,
18 colors: Colors,
19 maximumWidth: Float,
20 private val density: Float
21) {
22 /** What the chip says: its name, the size beside it, and the glyph that leads it. */
23 data class Content(val label: String, val symbol: String, val detail: String = "")
24
25 data class Colors(val accent: Int, val foreground: Int, val border: Int)
26
27 private val symbol = content.symbol
28
29 private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
30 textSize = fontSize
31 typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
32 }
33 private val em = fontSize
34
35 // The size reads as metadata beside the name, so it draws a step down from the label the
36 // way the web chip does.
37 private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
38 textSize = fontSize * 0.84f
39 typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
40 }
41 private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}"
42 private val detailWidth = if (detailText.isEmpty()) 0f else detailPaint.measureText(detailText)
43
44 // The border is stroked, and a stroke straddles the path it follows, so the box has to
45 // reserve a stroke on every side it insets. Reserving it once left the right edge a
46 // stroke short, and antialiasing shaved that curve flat against the span bounds.
47 private val strokeWidth = density
48 private val inset = strokeWidth * 2f
49 val width = ceil(
50 min(
51 maximumWidth.coerceAtLeast(em * 3),
52 paint.measureText(content.label) + detailWidth + em * 2.5f + inset
53 )
54 )
55 val height = ceil(em * 1.41f + inset)
56 private val text = TextUtils.ellipsize(
57 content.label,
58 paint,
59 (width - em * 2.5f - inset - detailWidth).coerceAtLeast(0f),
60 TextUtils.TruncateAt.MIDDLE
61 ).toString()
62 private val fill = Color.argb(
63 28,
64 Color.red(colors.accent),
65 Color.green(colors.accent),
66 Color.blue(colors.accent)
67 )
68 private val textColor = blend(colors.accent, colors.foreground, 0.22f)
69 private val borderColor = blend(colors.accent, colors.border, 0.34f)
70
71 // Half a stroke keeps the border inside the box; the rest of the reserved margin is
72 // slack, so an antialiased edge fades out before it reaches the span bounds.
73 private val shape =
74 RectF(inset / 2f, inset / 2f, width - inset / 2f, height - inset / 2f)
75 private val icon = iconPath(symbol)
76
77 fun draw(canvas: Canvas, x: Float, y: Float) {
78 canvas.save()
79 canvas.translate(x, y)
80 paint.style = Paint.Style.FILL
81 paint.color = fill
82 canvas.drawRoundRect(shape, em / 2, em / 2, paint)
83 paint.style = Paint.Style.STROKE
84 paint.strokeWidth = strokeWidth
85 paint.color = borderColor
86 canvas.drawRoundRect(shape, em / 2, em / 2, paint)
87 paint.color = textColor
88 canvas.save()
89 val iconSize = em * 1.17f
90 canvas.translate(em / 2 + inset / 2f, (height - iconSize) / 2)
91 canvas.scale(iconSize / 24, iconSize / 24)
92 paint.strokeWidth = 1.7f
93 paint.strokeJoin = Paint.Join.ROUND
94 paint.strokeCap = Paint.Cap.ROUND
95 canvas.drawPath(icon, paint)
96 canvas.restore()
97 paint.style = Paint.Style.FILL
98 val metrics = paint.fontMetrics
99 val baseline = (height - metrics.descent - metrics.ascent) / 2
100 canvas.drawText(text, em * 2 + inset / 2f, baseline, paint)
101 if (detailText.isNotEmpty()) {
102 detailPaint.color = paint.color
103 canvas.drawText(
104 detailText,
105 em * 2 + inset / 2f + paint.measureText(text),
106 baseline,
107 detailPaint
108 )
109 }
110 canvas.restore()
111 }
112
113 companion object {
114 fun color(
115 value: String,
116 fallback: Int
117 ): Int = runCatching {
118 // React Native hex colors put alpha last; Android's parser puts it first.
119 val androidColor = if (value.length == 9 && value.startsWith("#")) {
120 "#${value.takeLast(2)}${value.substring(1, 7)}"
121 } else {
122 value
123 }
124 Color.parseColor(androidColor)
125 }.getOrDefault(fallback)
126
127 private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb(
128 (Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(),
129 (Color.green(accent) * weight + Color.green(base) * (1 - weight)).toInt(),
130 (Color.blue(accent) * weight + Color.blue(base) * (1 - weight)).toInt(),
131 )
132
133 private fun iconPath(symbol: String) = Path().apply {
134 fun line(vararg points: Float) {
135 moveTo(points[0], points[1])
136 for (index in 2 until points.size step 2) lineTo(points[index], points[index + 1])
137 }
138 when (symbol) {
139 "cube" -> {
140 line(12f, 2f, 21f, 7f, 21f, 17f, 12f, 22f, 3f, 17f, 3f, 7f, 12f, 2f)
141 line(3f, 7f, 12f, 12f, 21f, 7f)
142 line(12f, 12f, 12f, 22f)
143 }
144 // lucide `git-pull-request`, the glyph web draws: two nodes, an elbow, and a stem.
145 "git-pull-request" -> {
146 addCircle(18f, 18f, 3f, Path.Direction.CW)
147 addCircle(6f, 6f, 3f, Path.Direction.CW)
148 moveTo(13f, 6f)
149 lineTo(16f, 6f)
150 cubicTo(17.1f, 6f, 18f, 6.9f, 18f, 8f)
151 lineTo(18f, 15f)
152 line(6f, 9f, 6f, 21f)
153 }
154 "cursorarrow.click" -> {
155 line(4f, 3f, 19f, 12f, 12f, 14f, 9f, 21f, 4f, 3f)
156 line(13f, 15f, 18f, 21f)
157 }
158 "text.bubble" -> {
159 line(3f, 4f, 21f, 4f, 21f, 17f, 10f, 17f, 5f, 21f, 5f, 17f, 3f, 17f, 3f, 4f)
160 line(7f, 8f, 17f, 8f)
161 line(7f, 12f, 14f, 12f)
162 }
163 "terminal", "play.rectangle", "photo" -> {
164 addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW)
165 when (symbol) {
166 "terminal" -> {
167 line(6f, 8f, 10f, 12f, 6f, 16f)
168 line(13f, 16f, 18f, 16f)
169 }
170 "play.rectangle" -> line(9f, 8f, 16f, 12f, 9f, 16f, 9f, 8f)
171 else -> {
172 addCircle(8f, 9f, 1.5f, Path.Direction.CW)
173 line(3f, 18f, 11f, 12f, 15f, 15f, 18f, 12f, 21f, 16f)
174 }
175 }
176 }
177 else -> {
178 line(5f, 2f, 14f, 2f, 20f, 8f, 20f, 22f, 5f, 22f, 5f, 2f)
179 line(14f, 2f, 14f, 8f, 20f, 8f)
180 }
181 }
182 }
183 }
184}
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt deleted-302
...@@ -1,302 +0,0 @@
1package expo.modules.t3markdowntext
2
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.graphics.Bitmap
7import android.graphics.Canvas
8import android.graphics.Color
9import android.graphics.Paint
10import android.graphics.Typeface
11import android.os.Build
12import android.text.Spannable
13import android.text.SpannableStringBuilder
14import android.text.Spanned
15import android.text.TextPaint
16import android.text.style.ReplacementSpan
17import android.view.ActionMode
18import android.view.Menu
19import android.view.MenuItem
20import android.widget.TextView
21import android.util.Base64
22import android.util.LruCache
23import com.facebook.react.bridge.ReactContext
24import com.facebook.react.common.assets.ReactFontManager
25import com.facebook.react.uimanager.UIManagerHelper
26import expo.modules.kotlin.modules.Module
27import expo.modules.kotlin.modules.ModuleDefinition
28import kotlin.math.max
29import kotlin.math.min
30import org.json.JSONObject
31import org.json.JSONArray
32import java.net.URLEncoder
33import java.io.ByteArrayOutputStream
34import kotlin.math.ceil
35
36private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"
37
38// Match React Native's measurement buffer. Android orders tied line-height
39// spans differently in SpannableString, shifting inline images once RN's
40// span priorities are exhausted.
41private object MarkdownSpannableFactory : Spannable.Factory() {
42 override fun newSpannable(source: CharSequence): Spannable =
43 SpannableStringBuilder(source)
44}
45
46internal fun copyTextWithoutInlineImages(
47 text: CharSequence,
48 start: Int,
49 end: Int
50): String {
51 if (text !is Spanned) return text.subSequence(start, end).toString()
52
53 fun isInlineImage(index: Int): Boolean =
54 index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
55 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
56
57 return buildString {
58 for (index in start until end) {
59 // The renderer inserts one NBSP after each image to keep its label on the same line.
60 // Inspect the original text even when selection starts after the image.
61 val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1)
62 if (!isInlineImage(index) && !isIconSpacer) append(text[index])
63 }
64 }
65}
66
67private fun canonicalSelection(
68 originalText: String,
69 start: Int,
70 end: Int,
71 ranges: JSONArray?
72): String? {
73 if (ranges == null) return null
74 val canonical = StringBuilder(originalText)
75 var hasContext = false
76 for (index in ranges.length() - 1 downTo 0) {
77 val range = ranges.optJSONObject(index) ?: continue
78 val first = max(start, range.optInt("start"))
79 val last = min(end, range.optInt("end"))
80 if (last > first) {
81 canonical.replace(first - start, last - start, range.optString("text"))
82 hasContext = true
83 }
84 }
85 return if (hasContext) canonical.toString().replace(OBJECT_REPLACEMENT_CHARACTER, "") else null
86}
87
88private fun selectedContextRecords(records: JSONArray, selectedText: String): JSONArray {
89 val ids = mutableSetOf<String>()
90 for (index in 0 until records.length()) {
91 val record = records.getJSONObject(index)
92 if (selectedText.contains("/${record.optString("contextId")})")) {
93 ids.add(record.optString("contextId"))
94 if (record.has("screenshotContextId")) ids.add(record.getString("screenshotContextId"))
95 }
96 }
97 val copied = JSONArray()
98 for (index in 0 until records.length()) {
99 val record = records.getJSONObject(index)
100 if (ids.contains(record.optString("contextId"))) copied.put(record)
101 }
102 return copied
103}
104
105private fun contextClipData(selectedText: String, fragment: String): ClipData {
106 val payload = runCatching { JSONObject(fragment) }.getOrNull()
107 val records = payload?.optJSONArray("records")
108 val copied = records?.let { selectedContextRecords(it, selectedText) }
109 if (payload == null || copied == null || copied.length() == 0) {
110 return ClipData.newPlainText(null, selectedText)
111 }
112 payload.put("records", copied)
113 val attribute = URLEncoder.encode(payload.toString(), "UTF-8").replace("+", "%20")
114 val escaped = selectedText.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
115 return ClipData.newHtmlText(
116 null,
117 selectedText,
118 "<pre data-t3-context-fragment=\"$attribute\">$escaped</pre>"
119 )
120}
121
122private class SanitizingSelectionActionModeCallback(
123 private val textView: TextView,
124 private val delegate: ActionMode.Callback?,
125 var contextClipboardConfig: String
126) : ActionMode.Callback {
127 override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean =
128 delegate?.onCreateActionMode(mode, menu) ?: true
129
130 override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean =
131 delegate?.onPrepareActionMode(mode, menu) ?: false
132
133 override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
134 if (item.itemId == android.R.id.copy && copySelection()) {
135 mode.finish()
136 return true
137 }
138 return delegate?.onActionItemClicked(mode, item) ?: false
139 }
140
141 private fun copySelection(): Boolean {
142 val start = min(textView.selectionStart, textView.selectionEnd)
143 val end = max(textView.selectionStart, textView.selectionEnd)
144 if (start < 0 || end <= start) return false
145 val originalText = textView.text.subSequence(start, end).toString()
146 val config = runCatching { JSONObject(contextClipboardConfig) }.getOrNull()
147 val canonical = canonicalSelection(originalText, start, end, config?.optJSONArray("ranges"))
148 val selectedText = canonical ?: copyTextWithoutInlineImages(textView.text, start, end)
149 val handled = canonical != null || selectedText != originalText
150 if (handled) {
151 val clipboard =
152 textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
153 val clip = if (canonical != null) {
154 contextClipData(selectedText, config?.optString("fragment") ?: "")
155 } else {
156 ClipData.newPlainText(null, selectedText)
157 }
158 clipboard.setPrimaryClip(clip)
159 }
160 return handled
161 }
162
163 override fun onDestroyActionMode(mode: ActionMode) {
164 delegate?.onDestroyActionMode(mode)
165 }
166}
167
168internal fun applySelectionHandleColor(textView: TextView, color: Int) {
169 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
170 textView.textSelectHandle?.mutate()?.apply { setTint(color) }?.let(textView::setTextSelectHandle)
171 textView.textSelectHandleLeft?.mutate()?.apply {
172 setTint(color)
173 }?.let(textView::setTextSelectHandleLeft)
174 textView.textSelectHandleRight?.mutate()?.apply {
175 setTint(color)
176 }?.let(textView::setTextSelectHandleRight)
177}
178
179class T3MarkdownTextSelectionModule : Module() {
180 private val chipImages = LruCache<String, Map<String, Any>>(128)
181
182 /**
183 * Metrics of the paragraph font a chip sits in, so the inline box it reports can be sized
184 * from the same ascent and descent the surrounding text lays out with.
185 */
186 private fun paragraphFontMetrics(text: JSONObject?, scale: Float): Paint.FontMetricsInt =
187 TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
188 textSize = (text?.optDouble("fontSize", 15.0)?.toFloat() ?: 15f).coerceIn(6f, 80f) * scale
189 val fontFamily = text?.optString("fontFamily").orEmpty()
190 typeface = if (fontFamily.isEmpty()) {
191 Typeface.DEFAULT
192 } else {
193 ReactFontManager.getInstance()
194 .getTypeface(fontFamily, Typeface.NORMAL, appContext.reactContext?.assets)
195 }
196 }.fontMetricsInt
197
198 private fun setSelectionHandleColor(reactTag: Int, color: Int) {
199 val reactContext = appContext.reactContext as? ReactContext ?: return
200 reactContext.runOnUiQueueThread {
201 val textView = runCatching {
202 UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag)
203 }.getOrNull() as? TextView ?: return@runOnUiQueueThread
204 applySelectionHandleColor(textView, color)
205 }
206 }
207
208 override fun definition() = ModuleDefinition {
209 Name("T3MarkdownTextSelection")
210
211 Function("setSelectionHandleColor") { reactTag: Int, color: Int ->
212 setSelectionHandleColor(reactTag, color)
213 }
214
215 Function("renderContextChip") { payloadJson: String ->
216 val resources = appContext.reactContext?.resources ?: return@Function null
217 val metrics = resources.displayMetrics
218 val fontScale = resources.configuration.fontScale
219 val key = "${metrics.density}:$fontScale:${metrics.widthPixels}:$payloadJson"
220 chipImages.get(key)?.let { return@Function it }
221 val payload = JSONObject(payloadJson)
222 val chip = T3ContextChip(
223 content = T3ContextChip.Content(
224 label = payload.optString("label").take(4096),
225 symbol = payload.optString("symbol", "doc")
226 ),
227 // The line box around the chip is measured with `fontScale` below, so the chip has to
228 // carry it too, or it shrinks against the words beside it at a larger text size.
229 fontSize =
230 payload.optDouble("fontSize", 12.0).toFloat().coerceIn(10f, 40f) *
231 metrics.density * fontScale,
232 colors = T3ContextChip.Colors(
233 accent = T3ContextChip.color(payload.optString("accent"), Color.GRAY),
234 foreground = T3ContextChip.color(payload.optString("foreground"), Color.BLACK),
235 border = T3ContextChip.color(payload.optString("border"), Color.GRAY)
236 ),
237 maximumWidth = (metrics.widthPixels - 80 * metrics.density).coerceAtLeast(100f),
238 density = metrics.density,
239 )
240 // toInt() truncates, so a fractional pixel of the chip would fall outside the bitmap
241 // and take the right-hand border with it. Round up: a spare column costs nothing.
242 // One spare pixel of transparency on each side. Whatever rounding happens between the
243 // bitmap's pixels and the box's dp then falls on padding instead of on the border.
244 val bleed = 1
245 val bitmap = Bitmap.createBitmap(
246 ceil(chip.width).toInt() + bleed * 2,
247 ceil(chip.height).toInt() + bleed * 2,
248 Bitmap.Config.ARGB_8888
249 )
250 chip.draw(Canvas(bitmap), bleed.toFloat(), bleed.toFloat())
251 val bytes = ByteArrayOutputStream()
252 bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes)
253 bitmap.recycle()
254 // React Native sits an inline view's box on the text baseline and grows the line's
255 // ascent to fit it, so a box as tall as the chip lifts the chip above the words and
256 // pushes the baseline down. Report a box no taller than the paragraph font's ascent,
257 // which leaves the line exactly as tall as a line of plain text, plus where inside
258 // that box the bitmap must sit so the chip centres on the font's ascent/descent box:
259 // the same rule the composer's ReplacementSpan uses to draw its chips.
260 val lineMetrics = paragraphFontMetrics(
261 payload.optJSONObject("text"),
262 fontScale * metrics.density,
263 )
264 val ascent = -lineMetrics.ascent
265 val descent = lineMetrics.descent
266 val bitmapWidth = ceil(chip.width) + bleed * 2
267 val bitmapHeight = ceil(chip.height) + bleed * 2
268 val result = mapOf<String, Any>(
269 "uri" to
270 "data:image/png;base64,${Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)}",
271 // Layout rounds dp back to whole pixels. Reporting a hair less than the bitmap lets
272 // that rounding land inside the image and crop its right-hand border, so round the
273 // box up: an extra fraction of a pixel is invisible, a missing border is not.
274 "width" to bitmapWidth / metrics.density,
275 "height" to bitmapHeight / metrics.density,
276 "boxHeight" to ascent / metrics.density,
277 "offsetY" to (ascent + descent - bitmapHeight) / 2f / metrics.density,
278 )
279 chipImages.put(key, result)
280 result
281 }
282
283 Function("installCopySanitizer") { reactTag: Int, contextClipboardConfig: String ->
284 val reactContext = appContext.reactContext as? ReactContext ?: return@Function
285 reactContext.runOnUiQueueThread {
286 val textView =
287 runCatching {
288 UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag)
289 }
290 .getOrNull() as? TextView ?: return@runOnUiQueueThread
291 val currentCallback = textView.customSelectionActionModeCallback
292 if (currentCallback is SanitizingSelectionActionModeCallback) {
293 currentCallback.contextClipboardConfig = contextClipboardConfig
294 return@runOnUiQueueThread
295 }
296 textView.setSpannableFactory(MarkdownSpannableFactory)
297 textView.customSelectionActionModeCallback =
298 SanitizingSelectionActionModeCallback(textView, currentCallback, contextClipboardConfig)
299 }
300 }
301 }
302}
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionColorTest.kt deleted-67
...@@ -1,67 +0,0 @@
1package expo.modules.t3markdowntext
2
3import android.graphics.Bitmap
4import android.graphics.Canvas
5import android.graphics.Color
6import android.graphics.drawable.ColorDrawable
7import android.graphics.drawable.Drawable
8import android.widget.TextView
9import org.junit.Assert.assertEquals
10import org.junit.Test
11import org.junit.runner.RunWith
12import org.robolectric.RobolectricTestRunner
13import org.robolectric.RuntimeEnvironment
14import org.robolectric.annotation.Config
15import org.robolectric.annotation.GraphicsMode
16
17@RunWith(RobolectricTestRunner::class)
18@Config(sdk = [36], manifest = Config.NONE)
19@GraphicsMode(GraphicsMode.Mode.NATIVE)
20class MarkdownSelectionColorTest {
21 private fun textView() = TextView(RuntimeEnvironment.getApplication()).apply {
22 setTextSelectHandle(ColorDrawable(Color.WHITE))
23 setTextSelectHandleLeft(ColorDrawable(Color.WHITE))
24 setTextSelectHandleRight(ColorDrawable(Color.WHITE))
25 }
26
27 private fun renderedColor(drawable: Drawable?): Int {
28 requireNotNull(drawable)
29 val bitmap = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888)
30 drawable.setBounds(0, 0, 4, 4)
31 drawable.draw(Canvas(bitmap))
32 val color = bitmap.getPixel(2, 2)
33 bitmap.recycle()
34 return color
35 }
36
37 @Test
38 fun retintsAllHandlesWhenTheThemeChangesWithoutChangingTheHighlight() {
39 val text = textView()
40 val highlight = 0x52FF0088
41 text.highlightColor = highlight
42
43 for (color in listOf(Color.MAGENTA, Color.GREEN, Color.MAGENTA)) {
44 applySelectionHandleColor(text, color)
45 assertEquals(color, renderedColor(text.textSelectHandle))
46 assertEquals(color, renderedColor(text.textSelectHandleLeft))
47 assertEquals(color, renderedColor(text.textSelectHandleRight))
48 assertEquals(highlight, text.highlightColor)
49 }
50 }
51
52 @Test
53 fun doesNotTintOtherTextViewsSharingDrawableState() {
54 val original = ColorDrawable(Color.WHITE)
55 val first = textView().apply {
56 setTextSelectHandleLeft(original.constantState!!.newDrawable())
57 }
58 val second = textView().apply {
59 setTextSelectHandleLeft(original.constantState!!.newDrawable())
60 }
61
62 applySelectionHandleColor(first, Color.MAGENTA)
63
64 assertEquals(Color.MAGENTA, renderedColor(first.textSelectHandleLeft))
65 assertEquals(Color.WHITE, renderedColor(second.textSelectHandleLeft))
66 }
67}
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt deleted-48
...@@ -1,48 +0,0 @@
1package expo.modules.t3markdowntext
2
3import android.graphics.drawable.ColorDrawable
4import android.text.SpannableString
5import android.text.Spanned
6import android.text.style.ImageSpan
7import org.junit.Assert.assertEquals
8import org.junit.Test
9import org.junit.runner.RunWith
10import org.robolectric.RobolectricTestRunner
11import org.robolectric.annotation.Config
12
13@RunWith(RobolectricTestRunner::class)
14@Config(sdk = [36], manifest = Config.NONE)
15class MarkdownSelectionCopyTest {
16 private fun withIcon(value: String): SpannableString = SpannableString(value).apply {
17 val index = value.indexOf('\uFFFC')
18 setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
19 }
20
21 @Test
22 fun removesIconAndInjectedSpacer() {
23 val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
24 assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length))
25 }
26
27 @Test
28 fun removesSpacerWhenSelectionStartsAfterIcon() {
29 val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
30 assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12))
31 }
32
33 @Test
34 fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() {
35 val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0")
36 assertEquals(
37 "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0",
38 copyTextWithoutInlineImages(text, 0, text.length)
39 )
40 }
41
42 @Test
43 fun preservesTextWithoutImageSpans() {
44 val text = "\uFFFC\u00A0main.go"
45 assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length))
46 assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length))
47 }
48}
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png deleted
Binary files a/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png and /dev/null differ
apps/mobile/modules/t3-markdown-text/expo-module.config.json deleted-6
...@@ -1,6 +0,0 @@
1{
2 "platforms": ["android"],
3 "android": {
4 "modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"]
5 }
6}
apps/mobile/modules/t3-markdown-text/index.ts deleted-29
...@@ -1,29 +0,0 @@
1export { markdownFileIconSource } from "./src/markdownFileIcons";
2export {
3 resolveMarkdownFileIcon,
4 resolveMarkdownLinkPresentation,
5 type MarkdownFileIcon,
6 type MarkdownLinkPresentation,
7} from "./src/markdownLinks";
8export {
9 nativeMarkdownChunkSpacing,
10 nativeMarkdownDocumentChunks,
11 nativeMarkdownDocumentRuns,
12 nativeMarkdownListItemBlocks,
13 nativeMarkdownTextRuns,
14 type NativeMarkdownDocumentChunk,
15 type NativeMarkdownTextRun,
16} from "./src/nativeMarkdownText";
17export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive";
18export {
19 SelectableMarkdownText,
20 type MarkdownCodeHighlighter,
21 type MarkdownHighlightedToken,
22} from "./src/SelectableMarkdownText";
23export type {
24 MarkdownFileContextMenu,
25 MarkdownFileContextMenuAction,
26 NativeMarkdownTextStyle,
27 SelectableMarkdownSkill,
28 SelectableMarkdownTextProps,
29} from "./src/SelectableMarkdownText.types";
apps/mobile/modules/t3-markdown-text/ios/T3ContextChip.h deleted-162
...@@ -1,162 +0,0 @@
1#pragma once
2
3#import <UIKit/UIKit.h>
4
5// Used by both the shadow measurement and UITextView rendering paths.
6static NSDictionary *T3ContextChipPayload(NSString *uri)
7{
8 if (![uri hasPrefix:@"chip:"]) return nil;
9 NSData *data = [[uri substringFromIndex:5] dataUsingEncoding:NSUTF8StringEncoding];
10 id payload = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
11 if (![payload isKindOfClass:NSDictionary.class]) return nil;
12 // Every consumer draws or substitutes `label`; a missing or non-string one would raise inside
13 // `replaceCharactersInRange:withString:` while a message is rendering.
14 if (![payload[@"label"] isKindOfClass:NSString.class]) return nil;
15 return payload;
16}
17
18static UIColor *T3ContextChipColor(NSString *hex)
19{
20 unsigned int rgb = 0;
21 if (![hex isKindOfClass:NSString.class] || hex.length != 7) return UIColor.labelColor;
22 [[NSScanner scannerWithString:[hex substringFromIndex:1]] scanHexInt:&rgb];
23 return [UIColor colorWithRed:((rgb >> 16) & 255) / 255.0
24 green:((rgb >> 8) & 255) / 255.0
25 blue:(rgb & 255) / 255.0 alpha:1];
26}
27
28static UIColor *T3ContextChipBlend(UIColor *accent, UIColor *base, CGFloat weight)
29{
30 CGFloat ar = 0, ag = 0, ab = 0, aa = 0, br = 0, bg = 0, bb = 0, ba = 0;
31 [accent getRed:&ar green:&ag blue:&ab alpha:&aa];
32 [base getRed:&br green:&bg blue:&bb alpha:&ba];
33 return [UIColor colorWithRed:ar * weight + br * (1 - weight)
34 green:ag * weight + bg * (1 - weight)
35 blue:ab * weight + bb * (1 - weight)
36 alpha:aa * weight + ba * (1 - weight)];
37}
38
39// Some chip glyphs have no SF Symbol that reads correctly: the pull request one would land on
40// `arrow.triangle.branch`, a road-sign fork that says "branch", not "pull request". Draw those
41// from the same lucide geometry web and Android use so one chip looks alike on every surface.
42static UIImage *T3ContextChipVectorIcon(NSString *symbol, CGFloat size, UIColor *color)
43{
44 if (![symbol isEqualToString:@"git-pull-request"]) return nil;
45 UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc]
46 initWithSize:CGSizeMake(size, size)];
47 return [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) {
48 CGFloat s = size / 24.0; // lucide authors on a 24pt grid.
49 UIBezierPath *path = [UIBezierPath bezierPath];
50 [path appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(18 * s, 18 * s)
51 radius:3 * s startAngle:0
52 endAngle:M_PI * 2 clockwise:YES]];
53 [path appendPath:[UIBezierPath bezierPathWithArcCenter:CGPointMake(6 * s, 6 * s)
54 radius:3 * s startAngle:0
55 endAngle:M_PI * 2 clockwise:YES]];
56 [path moveToPoint:CGPointMake(13 * s, 6 * s)];
57 [path addLineToPoint:CGPointMake(16 * s, 6 * s)];
58 [path addCurveToPoint:CGPointMake(18 * s, 8 * s)
59 controlPoint1:CGPointMake(17.1 * s, 6 * s)
60 controlPoint2:CGPointMake(18 * s, 6.9 * s)];
61 [path addLineToPoint:CGPointMake(18 * s, 15 * s)];
62 [path moveToPoint:CGPointMake(6 * s, 9 * s)];
63 [path addLineToPoint:CGPointMake(6 * s, 21 * s)];
64 path.lineWidth = 2 * s;
65 path.lineCapStyle = kCGLineCapRound;
66 path.lineJoinStyle = kCGLineJoinRound;
67 [color setStroke];
68 [path stroke];
69 }];
70}
71
72// Centres the chip on the run font's ascent/descent box, the rule the composer span and
73// Android use, so the chip lands in the same place beside the words on every surface.
74static inline CGRect T3ContextChipBounds(UIFont *font, CGSize size)
75{
76 CGFloat y = font != nil ? (font.ascender + font.descender - size.height) / 2 : -3;
77 return CGRectMake(0, y, size.width, size.height);
78}
79
80// A bare attachment string carries none of the run's attributes. Losing the paragraph
81// style at a paragraph's first character drops its line height, and losing the font lets
82// a chip-only line shrink to the bitmap, so the placeholder keeps both, plus the run
83// colour so a later re-apply (after an image loads) still tints with it.
84static inline NSAttributedString *T3MarkdownTextAttachmentString(
85 NSTextAttachment *attachment, NSDictionary<NSAttributedStringKey, id> *runAttributes)
86{
87 NSMutableAttributedString *string =
88 [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy];
89 for (NSAttributedStringKey key in
90 @[
91 NSFontAttributeName, NSParagraphStyleAttributeName, NSForegroundColorAttributeName,
92 NSBaselineOffsetAttributeName
93 ]) {
94 id value = runAttributes[key];
95 if (value != nil) {
96 [string addAttribute:key value:value range:NSMakeRange(0, string.length)];
97 }
98 }
99 return string;
100}
101
102static UIFont *T3ContextChipFont(NSDictionary *payload)
103{
104 CGFloat size = MAX(10, MIN(40, [payload[@"fontSize"] doubleValue]));
105 size *= payload[@"fontSizeMultiplier"] != nil ? [payload[@"fontSizeMultiplier"] doubleValue] : 1;
106 return [UIFont fontWithName:@"DMSans-Medium" size:size]
107 ?: [UIFont systemFontOfSize:size weight:UIFontWeightMedium];
108}
109
110static inline CGSize T3ContextChipSize(NSDictionary *payload, CGFloat maximumWidth)
111{
112 UIFont *font = T3ContextChipFont(payload);
113 NSString *label = payload[@"label"];
114 CGFloat textWidth = [label sizeWithAttributes:@{ NSFontAttributeName: font }].width;
115 return CGSizeMake(MIN(maximumWidth, ceil(textWidth + font.pointSize * 2.5)),
116 ceil(font.pointSize * 1.41));
117}
118
119static inline UIImage *T3ContextChipImage(NSDictionary *payload, CGSize size, UIImage *fileIcon)
120{
121 static NSCache<NSString *, UIImage *> *cache;
122 static dispatch_once_t once;
123 dispatch_once(&once, ^{ cache = [NSCache new]; cache.countLimit = 256; });
124 NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:NSJSONWritingSortedKeys error:nil];
125 NSString *key = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
126 key = [key stringByAppendingString:NSStringFromCGSize(size)];
127 if (fileIcon != nil) key = [key stringByAppendingString:@":file-icon"];
128 UIImage *cached = [cache objectForKey:key];
129 if (cached) return cached;
130 UIFont *font = T3ContextChipFont(payload);
131 CGFloat em = font.pointSize;
132 UIColor *accent = T3ContextChipColor(payload[@"accent"]);
133 UIColor *foreground = T3ContextChipBlend(accent, T3ContextChipColor(payload[@"foreground"]), 0.22);
134 UIColor *border = T3ContextChipBlend(accent, T3ContextChipColor(payload[@"border"]), 0.34);
135 UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:size];
136 UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) {
137 UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:
138 CGRectInset(CGRectMake(0, 0, size.width, size.height), 0.5, 0.5)
139 cornerRadius:em * 0.5];
140 [[accent colorWithAlphaComponent:0.11] setFill];
141 [path fill];
142 [border setStroke];
143 path.lineWidth = 1;
144 [path stroke];
145 CGFloat iconSize = em * 1.17;
146 UIImage *icon = fileIcon
147 ?: T3ContextChipVectorIcon(payload[@"symbol"], iconSize, foreground)
148 ?: [[UIImage systemImageNamed:payload[@"symbol"]
149 withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:em weight:UIImageSymbolWeightMedium]]
150 imageWithTintColor:foreground renderingMode:UIImageRenderingModeAlwaysOriginal];
151 [icon drawInRect:CGRectMake(em * 0.5, (size.height - iconSize) / 2, iconSize, iconSize)];
152 NSMutableParagraphStyle *paragraph = [NSMutableParagraphStyle new];
153 paragraph.lineBreakMode = NSLineBreakByTruncatingMiddle;
154 CGFloat x = em * 2;
155 [payload[@"label"] drawInRect:CGRectMake(x, (size.height - font.lineHeight) / 2,
156 MAX(0, size.width - x - em * 0.5), font.lineHeight)
157 withAttributes:@{ NSFontAttributeName: font, NSForegroundColorAttributeName: foreground,
158 NSParagraphStyleAttributeName: paragraph }];
159 }];
160 [cache setObject:image forKey:key];
161 return image;
162}
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h deleted-13
...@@ -1,13 +0,0 @@
1#import <React/RCTViewComponentView.h>
2#import <UIKit/UIKit.h>
3
4#ifndef T3MarkdownTextNativeComponent_h
5#define T3MarkdownTextNativeComponent_h
6
7NS_ASSUME_NONNULL_BEGIN
8@interface T3MarkdownText : RCTViewComponentView
9@end
10
11NS_ASSUME_NONNULL_END
12
13#endif /* UitextviewViewNativeComponent_h */
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm deleted-839
...@@ -1,839 +0,0 @@
1#import "T3MarkdownText.h"
2#import "T3MarkdownTextShadowNode.h"
3#import "T3MarkdownTextComponentDescriptor.h"
4#import "T3MarkdownTextRun.h"
5#import "T3ContextChip.h"
6#import <React/RCTConversions.h>
7#import <objc/runtime.h>
8
9#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
10#import <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h>
11#import <react/renderer/components/T3MarkdownTextSpec/Props.h>
12#import <react/renderer/components/T3MarkdownTextSpec/RCTComponentViewHelpers.h>
13#import <React/RCTFabricComponentsPlugins.h>
14
15using namespace facebook::react;
16
17@interface T3ContextChipAccessibilityElement : UIAccessibilityElement
18@property(nonatomic, weak) T3MarkdownTextRun *run;
19@end
20
21@implementation T3ContextChipAccessibilityElement
22- (BOOL)accessibilityActivate
23{
24 if (self.run == nil) return NO;
25 [self.run onPress];
26 return YES;
27}
28@end
29
30/** Preserve canonical references and their payload when copying a native text selection. */
31@interface T3ContextCopyTextView : UITextView
32@property(nonatomic, copy) NSDictionary *contextClipboardConfig;
33@end
34
35@implementation T3ContextCopyTextView
36// Read-only text still supports selecting the entire document after selecting a word.
37- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
38{
39 if (action == @selector(selectAll:)) {
40 return self.selectable && self.text.length > 0 && self.selectedRange.length < self.text.length;
41 }
42 return [super canPerformAction:action withSender:sender];
43}
44
45- (void)copy:(id)sender
46{
47 NSRange selected = self.selectedRange;
48 NSArray *ranges = self.contextClipboardConfig[@"ranges"];
49 if (selected.location == NSNotFound || selected.length == 0 || NSMaxRange(selected) > self.text.length || ranges.count == 0) {
50 [super copy:sender];
51 return;
52 }
53 NSMutableString *text = [[self.text substringWithRange:selected] mutableCopy];
54 BOOL hasContext = NO;
55 for (NSDictionary *range in [ranges reverseObjectEnumerator]) {
56 NSUInteger start = [range[@"start"] unsignedIntegerValue];
57 NSUInteger end = [range[@"end"] unsignedIntegerValue];
58 if (end <= start || end > self.text.length) continue;
59 NSRange overlap = NSIntersectionRange(selected, NSMakeRange(start, end - start));
60 if (overlap.length == 0 || ![range[@"text"] isKindOfClass:NSString.class]) continue;
61 [text replaceCharactersInRange:NSMakeRange(overlap.location - selected.location, overlap.length) withString:range[@"text"]];
62 hasContext = YES;
63 }
64 if (!hasContext) { [super copy:sender]; return; }
65 [text replaceOccurrencesOfString:@"\uFFFC\u00A0" withString:@"" options:0 range:NSMakeRange(0, text.length)];
66 NSString *fragment = self.contextClipboardConfig[@"fragment"];
67 NSMutableDictionary *payload = [[NSJSONSerialization JSONObjectWithData:[fragment dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:nil] mutableCopy];
68 NSArray *records = payload[@"records"];
69 NSMutableArray *copied = [NSMutableArray array];
70 NSMutableSet *screenshots = [NSMutableSet set];
71 for (NSDictionary *record in records) {
72 if ([text containsString:[NSString stringWithFormat:@"/%@)", record[@"contextId"]]]) {
73 [copied addObject:record];
74 if ([record[@"screenshotContextId"] isKindOfClass:NSString.class]) [screenshots addObject:record[@"screenshotContextId"]];
75 }
76 }
77 for (NSDictionary *record in records) {
78 if ([screenshots containsObject:record[@"contextId"]] && ![copied containsObject:record]) [copied addObject:record];
79 }
80 payload[@"records"] = copied;
81 NSData *encoded = payload ? [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil] : nil;
82 NSMutableDictionary *item = [@{@"public.utf8-plain-text": text} mutableCopy];
83 if (encoded && copied.count > 0) {
84 NSString *raw = [[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding];
85 NSString *attribute = [raw stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.alphanumericCharacterSet];
86 NSString *escaped = [[[text stringByReplacingOccurrencesOfString:@"&" withString:@"&amp;"] stringByReplacingOccurrencesOfString:@"<" withString:@"&lt;"] stringByReplacingOccurrencesOfString:@">" withString:@"&gt;"];
87 item[@"app.t3.context-fragment"] = encoded;
88 item[@"public.html"] = [[NSString stringWithFormat:@"<pre data-t3-context-fragment=\"%@\">%@</pre>", attribute, escaped] dataUsingEncoding:NSUTF8StringEncoding];
89 }
90 UIPasteboard.generalPasteboard.items = @[item];
91}
92@end
93
94static void T3MarkdownTextApplyParagraphStyles(
95 NSMutableAttributedString *attributedString,
96 const std::vector<T3MarkdownTextParagraphStyleRange> &styleRanges)
97{
98 for (const auto &styleRange : styleRanges) {
99 if (styleRange.length == 0 || styleRange.location >= attributedString.length) {
100 continue;
101 }
102
103 const NSRange markerRange = NSMakeRange(
104 styleRange.location,
105 MIN(styleRange.length, attributedString.length - styleRange.location));
106 const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange];
107 const NSParagraphStyle *existingStyle =
108 [attributedString attribute:NSParagraphStyleAttributeName
109 atIndex:paragraphRange.location
110 effectiveRange:nil];
111 NSMutableParagraphStyle *paragraphStyle =
112 existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new];
113 paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent;
114 paragraphStyle.headIndent = styleRange.headIndent;
115 paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing;
116 paragraphStyle.tabStops = @[
117 [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft
118 location:styleRange.headIndent
119 options:@{}]
120 ];
121 paragraphStyle.defaultTabInterval = styleRange.headIndent;
122 [attributedString addAttribute:NSParagraphStyleAttributeName
123 value:paragraphStyle
124 range:paragraphRange];
125 }
126}
127
128static void T3MarkdownTextApplyAttachments(
129 NSMutableAttributedString *attributedString,
130 const std::vector<T3MarkdownTextAttachmentRange> &attachmentRanges,
131 NSDictionary<NSString *, UIImage *> *images)
132{
133 for (const auto &attachmentRange : attachmentRanges) {
134 if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) {
135 continue;
136 }
137
138 NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()];
139 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
140 UIImage *image = images[imageUri];
141 const BOOL isSymbol = [imageUri hasPrefix:@"sf:"];
142 if (isSymbol) {
143 image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]];
144 }
145 NSDictionary *runAttributes =
146 [attributedString attributesAtIndex:attachmentRange.location effectiveRange:nil];
147 UIColor *foregroundColor = runAttributes[NSForegroundColorAttributeName];
148 if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) {
149 image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor
150 renderingMode:UIImageRenderingModeAlwaysOriginal];
151 }
152 attachment.image = image ?: [[UIImage alloc] init];
153 const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange);
154 attachment.bounds = CGRectMake(
155 0,
156 T3MarkdownTextAttachmentBaselineOffset(attachmentRange),
157 attachmentSize,
158 attachmentSize);
159 NSDictionary *chip = T3ContextChipPayload(imageUri);
160 if (chip != nil) {
161 CGSize size = CGSizeMake(attachmentRange.chipWidth, attachmentRange.chipHeight);
162 attachment.bounds = T3ContextChipBounds(runAttributes[NSFontAttributeName], size);
163 NSString *iconUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil;
164 attachment.image = T3ContextChipImage(chip, size, iconUri ? images[iconUri] : nil);
165 }
166 const NSRange range = NSMakeRange(
167 attachmentRange.location,
168 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
169 [attributedString replaceCharactersInRange:range
170 withAttributedString:T3MarkdownTextAttachmentString(attachment, runAttributes)];
171 }
172}
173
174@protocol T3MarkdownOutsideTapTarget <NSObject>
175- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView;
176@end
177
178@interface T3MarkdownOutsideTapCoordinator : NSObject <UIGestureRecognizerDelegate>
179
180- (instancetype)initWithWindow:(UIWindow *)window;
181- (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target;
182- (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target;
183
184@end
185
186static const void *T3MarkdownOutsideTapCoordinatorKey =
187 &T3MarkdownOutsideTapCoordinatorKey;
188
189@implementation T3MarkdownOutsideTapCoordinator {
190 __weak UIWindow *_window;
191 UITapGestureRecognizer *_recognizer;
192 NSHashTable<id<T3MarkdownOutsideTapTarget>> *_targets;
193}
194
195- (instancetype)initWithWindow:(UIWindow *)window
196{
197 if (self = [super init]) {
198 _window = window;
199 _targets = [NSHashTable weakObjectsHashTable];
200 _recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
201 action:@selector(handleTap:)];
202 _recognizer.cancelsTouchesInView = NO;
203 _recognizer.delegate = self;
204 [window addGestureRecognizer:_recognizer];
205 }
206 return self;
207}
208
209- (void)addTarget:(id<T3MarkdownOutsideTapTarget>)target
210{
211 [_targets addObject:target];
212}
213
214- (void)removeTarget:(id<T3MarkdownOutsideTapTarget>)target
215{
216 [_targets removeObject:target];
217 if (_targets.count > 0) {
218 return;
219 }
220
221 UIWindow *window = _window;
222 [window removeGestureRecognizer:_recognizer];
223 if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) {
224 objc_setAssociatedObject(
225 window,
226 T3MarkdownOutsideTapCoordinatorKey,
227 nil,
228 OBJC_ASSOCIATION_RETAIN_NONATOMIC);
229 }
230}
231
232- (void)handleTap:(UITapGestureRecognizer *)sender
233{
234 UIWindow *window = _window;
235 if (window == nil) {
236 return;
237 }
238
239 UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil];
240 if (hitView == nil) {
241 return;
242 }
243 for (id<T3MarkdownOutsideTapTarget> target in _targets.allObjects) {
244 [target clearSelectionForOutsideTapWithHitView:hitView];
245 }
246}
247
248- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
249 shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
250{
251 return YES;
252}
253
254@end
255
256static T3MarkdownOutsideTapCoordinator *
257T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
258{
259 T3MarkdownOutsideTapCoordinator *coordinator =
260 objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey);
261 if (coordinator == nil) {
262 coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window];
263 objc_setAssociatedObject(
264 window,
265 T3MarkdownOutsideTapCoordinatorKey,
266 coordinator,
267 OBJC_ASSOCIATION_RETAIN_NONATOMIC);
268 }
269 return coordinator;
270}
271
272@interface T3MarkdownText () <RCTT3MarkdownTextViewProtocol, UIGestureRecognizerDelegate, UITextViewDelegate>
273
274@end
275
276@interface T3MarkdownText () <T3MarkdownOutsideTapTarget>
277@end
278
279@implementation T3MarkdownText {
280 UIView * _view;
281 T3ContextCopyTextView * _textView;
282 T3MarkdownTextShadowNode::ConcreteState::Shared _state;
283 __weak UIWindow * _outsideTapWindow;
284 BOOL _suppressSelectionChange;
285 NSMutableDictionary<NSString *, UIImage *> * _attachmentImages;
286 NSMutableSet<NSString *> * _pendingAttachmentUris;
287 UILongPressGestureRecognizer *_longPressGestureRecognizer;
288 UITapGestureRecognizer *_pressGestureRecognizer;
289 NSArray *_contextAccessibilityElements;
290 BOOL _textLayoutNeedsUpdate;
291}
292
293+ (ComponentDescriptorProvider)componentDescriptorProvider
294{
295 return concreteComponentDescriptorProvider<T3MarkdownTextComponentDescriptor>();
296}
297
298- (instancetype)initWithFrame:(CGRect)frame
299{
300 if (self = [super initWithFrame:frame]) {
301 static const auto defaultProps = std::make_shared<const T3MarkdownTextProps>();
302 _props = defaultProps;
303
304 _view = [[UIView alloc] init];
305 self.contentView = _view;
306 self.clipsToBounds = true;
307
308 _textView = [[T3ContextCopyTextView alloc] init];
309 _attachmentImages = [[NSMutableDictionary alloc] init];
310 _pendingAttachmentUris = [[NSMutableSet alloc] init];
311 _textView.scrollEnabled = false;
312 _textView.editable = false;
313 _textView.textContainerInset = UIEdgeInsetsZero;
314 _textView.textContainer.lineFragmentPadding = 0;
315 // The shadow node already sized this view to fit every line. A container
316 // that tracks the frame can come out a fraction of a point shorter after
317 // pixel rounding, and TextKit then drops the last line. Size it by hand
318 // with unbounded height and let the view's bounds clip.
319 _textView.textContainer.widthTracksTextView = NO;
320 _textView.textContainer.heightTracksTextView = NO;
321 _textView.delegate = self;
322 // Chat text supports selection and contextual actions, but not drag-and-drop.
323 _textView.textDragInteraction.enabled = NO;
324 _textView.linkTextAttributes = @{};
325 // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO.
326 _textView.layoutManager.usesFontLeading = NO;
327 [self addSubview:_textView];
328
329 _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
330 action:@selector(handleLongPressIfNecessary:)];
331 _longPressGestureRecognizer.delegate = self;
332
333 _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
334 action:@selector(handlePressIfNecessary:)];
335 _pressGestureRecognizer.delegate = self;
336 [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer];
337
338 [_textView addGestureRecognizer:_pressGestureRecognizer];
339 [_textView addGestureRecognizer:_longPressGestureRecognizer];
340 }
341
342 return self;
343}
344
345- (void)didMoveToWindow
346{
347 [super didMoveToWindow];
348 if (_outsideTapWindow == self.window) {
349 return;
350 }
351 if (_outsideTapWindow != nil) {
352 T3MarkdownOutsideTapCoordinator *coordinator =
353 objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey);
354 [coordinator removeTarget:self];
355 }
356 _outsideTapWindow = self.window;
357 if (_outsideTapWindow != nil) {
358 [T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self];
359 }
360}
361
362- (void)dealloc
363{
364 T3MarkdownOutsideTapCoordinator *coordinator =
365 objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey);
366 [coordinator removeTarget:self];
367}
368
369- (NSArray *)accessibilityElements
370{
371 return _contextAccessibilityElements ?: [super accessibilityElements];
372}
373
374// See RCTParagraphComponentView
375- (void)prepareForRecycle
376{
377 [super prepareForRecycle];
378 T3MarkdownOutsideTapCoordinator *coordinator =
379 objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey);
380 [coordinator removeTarget:self];
381 _outsideTapWindow = nil;
382 _state.reset();
383
384 // Reset the frame to zero so that when it properly lays out on the next use
385 _textView.frame = CGRectZero;
386 _textView.attributedText = nil;
387 _contextAccessibilityElements = nil;
388 _textLayoutNeedsUpdate = YES;
389}
390
391- (void)layoutSubviews
392{
393 [super layoutSubviews];
394 // Resize during UIKit's layout pass. Doing this in drawRect can leave the
395 // text view's drawing surface clipped to its previous width after a pane
396 // expands, even though TextKit has already rewrapped the lines.
397 if (!CGRectEqualToRect(_textView.frame, _view.frame)) {
398 _textView.frame = _view.frame;
399 _textLayoutNeedsUpdate = YES;
400 [self setNeedsDisplay];
401 }
402}
403
404- (void)drawRect:(CGRect)rect
405{
406 if (!_state) {
407 return;
408 }
409
410 const auto &props = *std::static_pointer_cast<T3MarkdownTextProps const>(_props);
411
412 const auto attrString = _state->getData().attributedString;
413 NSMutableAttributedString *convertedAttrString =
414 [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy];
415 T3MarkdownTextApplyParagraphStyles(
416 convertedAttrString,
417 _state->getData().paragraphStyleRanges);
418 T3MarkdownTextApplyAttachments(
419 convertedAttrString,
420 _state->getData().attachmentRanges,
421 _attachmentImages);
422 // Matches the shadow node so drawn lines sit where measurement put them.
423 RCTApplyBaselineOffset(convertedAttrString);
424 NSUInteger runLocation = 0;
425 for (UIView *child in self.subviews) {
426 if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
427 continue;
428 }
429
430 T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child;
431 const NSRange runRange = NSMakeRange(runLocation, textChild.text.length);
432 runLocation = NSMaxRange(runRange);
433 if (![textChild hasContextMenu] || runRange.length == 0 ||
434 NSMaxRange(runRange) > convertedAttrString.length) {
435 continue;
436 }
437
438 NSURL *link = [NSURL URLWithString:
439 [NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]];
440 if (link != nil) {
441 // A glyph must not be both a link and an attachment. UIKit caches them as
442 // different text-item classes and can send `attachment` to a cached link
443 // on a later tap. Attachment actions already use primaryActionForTextItem.
444 [convertedAttrString enumerateAttribute:NSAttachmentAttributeName
445 inRange:runRange
446 options:0
447 usingBlock:^(id attachment, NSRange range, BOOL *stop) {
448 if (attachment == nil) {
449 [convertedAttrString addAttribute:NSLinkAttributeName value:link range:range];
450 }
451 }];
452 }
453 }
454 [self loadAttachmentImages:_state->getData().attachmentRanges];
455
456 // Setting attributedText clears any active text selection, and re-assigning
457 // the frame triggers a layout flush that has the same effect. Bail out
458 // entirely when nothing actually changed so a JS-side state update made in
459 // response to onSelectionChange doesn't deselect what the user is selecting.
460 const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString];
461 const BOOL frameChanged = _textLayoutNeedsUpdate;
462 if (!textChanged && !frameChanged) {
463 return;
464 }
465 if (textChanged) {
466 // Reassigning attributedText clears any active selection. Save it and
467 // restore after, while suppressing the synthetic textViewDidChangeSelection
468 // events the clear-then-restore would otherwise produce — those would
469 // round-trip to JS and re-trigger this same path, causing a loop.
470 const NSRange savedRange = _textView.selectedRange;
471 _suppressSelectionChange = YES;
472 _textView.attributedText = convertedAttrString;
473 NSMutableString *accessibleText = [convertedAttrString.string mutableCopy];
474 for (auto it = _state->getData().attachmentRanges.rbegin();
475 it != _state->getData().attachmentRanges.rend(); ++it) {
476 NSDictionary *chip = T3ContextChipPayload([NSString stringWithUTF8String:it->imageUri.c_str()]);
477 if (chip != nil && it->location < accessibleText.length) {
478 [accessibleText replaceCharactersInRange:NSMakeRange(it->location, 1) withString:chip[@"label"]];
479 }
480 }
481 _textView.accessibilityLabel = accessibleText;
482 if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) {
483 _textView.selectedRange = savedRange;
484 }
485 _suppressSelectionChange = NO;
486 }
487 if (frameChanged) {
488 _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX);
489 }
490 _textLayoutNeedsUpdate = NO;
491
492 // Text attachments have no native link element. Expose their existing runs
493 // at the measured glyph bounds, without inserting views into text layout.
494 NSMutableArray *accessibleElements = [NSMutableArray arrayWithObject:_textView];
495 for (UIView *child in self.subviews) {
496 if (![child isKindOfClass:T3MarkdownTextRun.class]) continue;
497 T3MarkdownTextRun *run = (T3MarkdownTextRun *)child;
498 run.contextChipInteractive = NO;
499 }
500 for (const auto &attachmentRange : _state->getData().attachmentRanges) {
501 NSDictionary *chip = T3ContextChipPayload(
502 [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]);
503 if (![chip[@"interactive"] boolValue]) continue;
504 NSRange range = NSMakeRange(attachmentRange.location, 1);
505 T3MarkdownTextRun *run = [self childForCharacterRange:range];
506 if (!run || NSMaxRange(range) > convertedAttrString.length) continue;
507 NSRange glyphRange = [_textView.layoutManager glyphRangeForCharacterRange:range actualCharacterRange:nil];
508 CGRect bounds = [_textView.layoutManager boundingRectForGlyphRange:glyphRange
509 inTextContainer:_textView.textContainer];
510 bounds = CGRectOffset(bounds, _textView.textContainerInset.left, _textView.textContainerInset.top);
511 run.contextChipInteractive = YES;
512 T3ContextChipAccessibilityElement *element =
513 [[T3ContextChipAccessibilityElement alloc] initWithAccessibilityContainer:self];
514 element.run = run;
515 element.accessibilityLabel = chip[@"label"];
516 element.accessibilityTraits = UIAccessibilityTraitButton;
517 element.accessibilityFrameInContainerSpace = [_textView convertRect:bounds toView:self];
518 [accessibleElements addObject:element];
519 }
520 _contextAccessibilityElements = accessibleElements;
521
522 __block std::vector<std::string> lines;
523 const int maxLines = props.numberOfLines;
524 [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect,
525 CGRect usedRect,
526 NSTextContainer * _Nonnull textContainer,
527 NSRange glyphRange,
528 BOOL * _Nonnull stop) {
529 const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil];
530 const auto line = [self->_textView.text substringWithRange:charRange];
531 lines.push_back(line.UTF8String);
532 // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS
533 // 18, so cap explicitly.
534 if (maxLines > 0 && lines.size() >= (size_t)maxLines) {
535 *stop = YES;
536 }
537 }];
538
539 if (_eventEmitter != nullptr) {
540 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter)
541 ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines});
542 };
543}
544
545- (void)loadAttachmentImages:(const std::vector<T3MarkdownTextAttachmentRange> &)attachmentRanges
546{
547 for (const auto &attachmentRange : attachmentRanges) {
548 NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()];
549 if ([imageUri hasPrefix:@"sf:"]) {
550 continue;
551 }
552 NSDictionary *chip = T3ContextChipPayload(imageUri);
553 if (chip != nil) {
554 imageUri = [chip[@"iconUri"] isKindOfClass:NSString.class] ? chip[@"iconUri"] : nil;
555 if (imageUri.length == 0) continue;
556 }
557 if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) {
558 continue;
559 }
560
561 NSURL *url = [NSURL URLWithString:imageUri];
562 if (url == nil) {
563 continue;
564 }
565 if (url.isFileURL) {
566 UIImage *image = [UIImage imageWithContentsOfFile:url.path];
567 if (image != nil) {
568 _attachmentImages[imageUri] = image;
569 dispatch_async(dispatch_get_main_queue(), ^{
570 [self refreshDisplayedAttachments];
571 });
572 }
573 continue;
574 }
575
576 [_pendingAttachmentUris addObject:imageUri];
577 [[[NSURLSession sharedSession] dataTaskWithURL:url
578 completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
579 UIImage *image = data == nil ? nil : [UIImage imageWithData:data];
580 dispatch_async(dispatch_get_main_queue(), ^{
581 [self->_pendingAttachmentUris removeObject:imageUri];
582 if (image != nil) {
583 self->_attachmentImages[imageUri] = image;
584 [self refreshDisplayedAttachments];
585 }
586 });
587 }] resume];
588 }
589}
590
591- (void)refreshDisplayedAttachments
592{
593 if (!_state || _textView.attributedText == nil) {
594 return;
595 }
596
597 NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy];
598 T3MarkdownTextApplyAttachments(
599 attributedText,
600 _state->getData().attachmentRanges,
601 _attachmentImages);
602
603 const NSRange savedRange = _textView.selectedRange;
604 _suppressSelectionChange = YES;
605 _textView.attributedText = attributedText;
606 if (savedRange.location != NSNotFound &&
607 NSMaxRange(savedRange) <= _textView.attributedText.length) {
608 _textView.selectedRange = savedRange;
609 }
610 _suppressSelectionChange = NO;
611 [_textView setNeedsDisplay];
612}
613
614- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
615{
616 const auto &oldViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(_props);
617 const auto &newViewProps = *std::static_pointer_cast<T3MarkdownTextProps const>(props);
618 if (oldViewProps.contextClipboardConfig != newViewProps.contextClipboardConfig) {
619 NSString *config = RCTNSStringFromString(newViewProps.contextClipboardConfig);
620 _textView.contextClipboardConfig = config.length ? [NSJSONSerialization JSONObjectWithData:[config dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil] : nil;
621 }
622
623 if (oldViewProps.numberOfLines != newViewProps.numberOfLines) {
624 _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines;
625 }
626
627 if (oldViewProps.selectable != newViewProps.selectable) {
628 _textView.selectable = newViewProps.selectable;
629 }
630
631 if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) {
632 if (@available(iOS 11.0, *)) {
633 _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling;
634 }
635 }
636
637 if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) {
638 if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) {
639 _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead;
640 } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) {
641 _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle;
642 } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) {
643 _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail;
644 } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) {
645 _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping;
646 }
647 }
648
649
650 // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text
651 // is in will have this background color applied. To apply it just to a particular part of a string, you'd need
652 // to do <Text><Text style={{backgroundColor: 'blue'}}>Hello</Text></Text>.
653 // This is how the base <Text> component works though, so we'll go with it for now. Can change later if we want.
654 if (oldViewProps.backgroundColor != newViewProps.backgroundColor) {
655 _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor);
656 }
657
658 [super updateProps:props oldProps:oldProps];
659}
660
661// See RCTParagraphComponentView
662- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState
663{
664 _state = std::static_pointer_cast<const T3MarkdownTextShadowNode::ConcreteState>(state);
665 [self setNeedsDisplay];
666}
667
668// MARK: - UIGestureRecognizerDelegate
669
670- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
671{
672 return YES;
673}
674
675- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
676{
677 if (gestureRecognizer != _longPressGestureRecognizer &&
678 gestureRecognizer != _pressGestureRecognizer) {
679 return YES;
680 }
681
682 const auto location = [self getLocationOfPress:gestureRecognizer];
683 const auto child = [self getTouchChild:location];
684 return ![child hasContextMenu];
685}
686
687- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
688{
689 return YES;
690}
691
692- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView
693{
694 if ([hitView isDescendantOfView:self]) {
695 return;
696 }
697 // Defer past the current event loop turn so any in-flight edit-menu action
698 // (Copy / Define / Look Up / …) reads the live selection before we clear it.
699 UITextView *textView = _textView;
700 dispatch_async(dispatch_get_main_queue(), ^{
701 UITextRange *range = textView.selectedTextRange;
702 if (range != nil && !range.isEmpty) {
703 textView.selectedTextRange = nil;
704 }
705 });
706}
707
708// MARK: - Touch handling
709
710- (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange
711{
712 NSUInteger location = 0;
713 for (UIView *child in self.subviews) {
714 if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
715 continue;
716 }
717
718 T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child;
719 const NSRange range = NSMakeRange(location, textChild.text.length);
720 if (NSIntersectionRange(range, characterRange).length > 0) {
721 return textChild;
722 }
723 location = NSMaxRange(range);
724 }
725 return nil;
726}
727
728- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender
729{
730 return [sender locationInView:_textView];
731}
732
733- (T3MarkdownTextRun*)getTouchChild:(CGPoint)location
734{
735 const auto charIndex = [_textView.layoutManager characterIndexForPoint:location
736 inTextContainer:_textView.textContainer
737 fractionOfDistanceBetweenInsertionPoints:nil
738 ];
739
740 int currIndex = -1;
741 for (UIView* child in self.subviews) {
742 if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
743 continue;
744 }
745
746 T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child;
747
748 // This is UTF16 code units!!
749 currIndex += textChild.text.length;
750
751 if (charIndex <= currIndex) {
752 return textChild;
753 }
754 }
755
756 return nil;
757}
758
759- (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender
760{
761 const auto location = [self getLocationOfPress:sender];
762 const auto child = [self getTouchChild:location];
763
764 if (child) {
765 [child onPress];
766 }
767}
768
769- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender
770{
771 if (sender.state != UIGestureRecognizerStateBegan) {
772 return;
773 }
774
775 const auto location = [self getLocationOfPress:sender];
776 const auto child = [self getTouchChild:location];
777
778 if (child) {
779 [child onLongPress];
780 }
781}
782
783// MARK: - UITextViewDelegate
784
785- (nullable UIAction *)textView:(UITextView *)textView
786 primaryActionForTextItem:(UITextItem *)textItem
787 defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0))
788{
789 T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range];
790 if (![child hasContextMenu] && !child.contextChipInteractive) {
791 return defaultAction;
792 }
793
794 __weak T3MarkdownTextRun *weakChild = child;
795 return [UIAction actionWithHandler:^(__kindof UIAction *action) {
796 [weakChild onPress];
797 }];
798}
799
800- (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView
801 menuConfigurationForTextItem:(UITextItem *)textItem
802 defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0))
803{
804 T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range];
805 UIMenu *menu = [child contextMenu];
806 if (child.contextChipInteractive && menu == nil) return nil;
807 return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu];
808}
809
810- (void)textViewDidChangeSelection:(UITextView *)textView
811{
812 if (_suppressSelectionChange) {
813 return;
814 }
815 if (_eventEmitter == nullptr) {
816 return;
817 }
818
819 const NSRange selectedRange = textView.selectedRange;
820 if (selectedRange.location == NSNotFound) {
821 return;
822 }
823
824 // Fires on programmatic selection changes too (e.g. the outside-tap clear
825 // in handleOutsideTap:), so JS will see a synthetic empty-range event then.
826 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextEventEmitter>(_eventEmitter)
827 ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{
828 static_cast<int>(self.tag),
829 static_cast<int>(selectedRange.location),
830 static_cast<int>(selectedRange.location + selectedRange.length),
831 });
832}
833
834Class<RCTComponentViewProtocol> T3MarkdownTextCls(void)
835{
836 return T3MarkdownText.class;
837}
838
839@end
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h deleted-13
...@@ -1,13 +0,0 @@
1#pragma once
2
3#include "T3MarkdownTextShadowNode.h"
4
5#include <react/renderer/core/ConcreteComponentDescriptor.h>
6#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
7
8namespace facebook::react {
9using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor<T3MarkdownTextShadowNode>;
10
11void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen(
12 std::shared_ptr<const ComponentDescriptorProviderRegistry> registry);
13}
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm deleted-35
...@@ -1,35 +0,0 @@
1#import <React/RCTViewManager.h>
2#import <React/RCTUIManager.h>
3#import "Utils.h"
4
5@interface T3MarkdownTextManager : RCTViewManager
6@end
7
8@implementation T3MarkdownTextManager
9
10RCT_EXPORT_MODULE(T3MarkdownText)
11
12- (UIView *)view
13{
14 return [[UIView alloc] init];
15}
16
17RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView)
18{
19}
20
21@end
22
23@interface T3MarkdownTextRunManager : RCTViewManager
24@end
25
26@implementation T3MarkdownTextRunManager
27
28RCT_EXPORT_MODULE(T3MarkdownTextRun)
29
30- (UIView *)view
31{
32 return nil;
33}
34
35@end
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h deleted-28
...@@ -1,28 +0,0 @@
1// This guard prevent this file to be compiled in the old architecture.
2#ifdef RCT_NEW_ARCH_ENABLED
3#import <React/RCTViewComponentView.h>
4#import <React/RCTComponent.h>
5#import <UIKit/UIKit.h>
6
7#ifndef T3MarkdownTextRunNativeComponent_h
8#define T3MarkdownTextRunNativeComponent_h
9
10NS_ASSUME_NONNULL_BEGIN
11
12@interface T3MarkdownTextRun : RCTViewComponentView
13
14@property (nonatomic, copy, nullable) NSString *text;
15@property (nonatomic, assign) BOOL contextChipInteractive;
16
17- (nullable UIMenu *)contextMenu;
18- (BOOL)hasContextMenu;
19- (void)onContextMenuAction:(NSString *)actionIdentifier;
20- (void)onPress;
21- (void)onLongPress;
22
23@end
24
25NS_ASSUME_NONNULL_END
26
27#endif /* UitextviewViewNativeComponent_h */
28#endif /* RCT_NEW_ARCH_ENABLED */
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm deleted-140
...@@ -1,140 +0,0 @@
1#import "T3MarkdownTextRun.h"
2#import "T3MarkdownText.h"
3#import "T3MarkdownTextRunComponentDescriptor.h"
4#import <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h>
5#import <react/renderer/components/T3MarkdownTextSpec/Props.h>
6#import <react/renderer/components/T3MarkdownTextSpec/RCTComponentViewHelpers.h>
7#import <React/RCTFabricComponentsPlugins.h>
8#import "Utils.h"
9
10using namespace facebook::react;
11
12@interface T3MarkdownTextRun () <RCTT3MarkdownTextRunViewProtocol>
13
14@end
15
16@implementation T3MarkdownTextRun {
17 NSString * _text;
18 NSString * _contextMenuConfig;
19}
20
21+ (ComponentDescriptorProvider)componentDescriptorProvider
22{
23 return concreteComponentDescriptorProvider<T3MarkdownTextRunComponentDescriptor>();
24}
25
26- (instancetype)initWithFrame:(CGRect)frame
27{
28 if (self = [super initWithFrame:frame]) {
29 static const auto defaultProps = std::make_shared<const T3MarkdownTextRunProps>();
30 _props = defaultProps;
31 }
32 return self;
33}
34
35- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
36{
37 const auto &oldViewProps = *std::static_pointer_cast<T3MarkdownTextRunProps const>(_props);
38 const auto &newViewProps = *std::static_pointer_cast<T3MarkdownTextRunProps const>(props);
39
40 if (newViewProps.text != oldViewProps.text) {
41 NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()];
42 _text = text;
43 }
44
45 if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) {
46 _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()];
47 }
48
49 [super updateProps:props oldProps:oldProps];
50}
51
52- (BOOL)hasContextMenu
53{
54 return _contextMenuConfig.length > 0;
55}
56
57- (nullable UIMenu *)contextMenu
58{
59 if (_contextMenuConfig.length == 0) {
60 return nil;
61 }
62
63 NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding];
64 NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
65 if (![config isKindOfClass:[NSDictionary class]]) {
66 return nil;
67 }
68
69 NSArray *actionConfigs = config[@"actions"];
70 if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) {
71 return nil;
72 }
73
74 NSMutableArray<UIMenuElement *> *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count];
75 __weak T3MarkdownTextRun *weakSelf = self;
76 for (NSDictionary *actionConfig in actionConfigs) {
77 if (![actionConfig isKindOfClass:[NSDictionary class]]) {
78 continue;
79 }
80 NSString *actionIdentifier = actionConfig[@"id"];
81 NSString *title = actionConfig[@"title"];
82 if (![actionIdentifier isKindOfClass:[NSString class]] ||
83 ![title isKindOfClass:[NSString class]]) {
84 continue;
85 }
86
87 UIAction *action = [UIAction actionWithTitle:title
88 image:nil
89 identifier:actionIdentifier
90 handler:^(__kindof UIAction *selectedAction) {
91 [weakSelf onContextMenuAction:selectedAction.identifier];
92 }];
93 if ([actionConfig[@"disabled"] boolValue]) {
94 action.attributes = UIMenuElementAttributesDisabled;
95 }
96 [actions addObject:action];
97 }
98
99 if (actions.count == 0) {
100 return nil;
101 }
102 NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @"";
103 return [UIMenu menuWithTitle:title children:actions];
104}
105
106- (void)onContextMenuAction:(NSString *)actionIdentifier
107{
108 if (_eventEmitter != nullptr) {
109 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)
110 ->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{
111 static_cast<int>(self.tag),
112 actionIdentifier.UTF8String,
113 });
114 }
115}
116
117- (void)onPress {
118 if (_eventEmitter != nullptr) {
119 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)
120 ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{});
121 }
122}
123
124- (void)onLongPress {
125 if (_eventEmitter != nullptr) {
126 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)
127 ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{});
128 }
129}
130
131+ (BOOL)shouldBeRecycled {
132 return NO;
133}
134
135Class<RCTComponentViewProtocol> T3MarkdownTextRunCls(void)
136{
137 return T3MarkdownTextRun.class;
138}
139
140@end
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h deleted-13
...@@ -1,13 +0,0 @@
1#pragma once
2
3#include "T3MarkdownTextRunShadowNode.h"
4
5#include <react/renderer/core/ConcreteComponentDescriptor.h>
6#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
7
8namespace facebook::react {
9using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor<T3MarkdownTextRunShadowNode>;
10
11void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen(
12 std::shared_ptr<const ComponentDescriptorProviderRegistry> registry);
13}
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp deleted-6
...@@ -1,6 +0,0 @@
1#include "T3MarkdownTextRunShadowNode.h"
2
3namespace facebook::react {
4
5extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun";
6} // namespace facebook::react
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h deleted-16
...@@ -1,16 +0,0 @@
1#pragma once
2
3#include <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h>
4#include <react/renderer/components/T3MarkdownTextSpec/Props.h>
5#include <react/renderer/components/T3MarkdownTextSpec/States.h>
6#include <react/renderer/components/view/ConcreteViewShadowNode.h>
7
8namespace facebook::react {
9extern const char T3MarkdownTextRunComponentName[];
10
11using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode<
12 T3MarkdownTextRunComponentName,
13 T3MarkdownTextRunProps,
14 T3MarkdownTextRunEventEmitter,
15 T3MarkdownTextRunState>;
16}
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h deleted-82
...@@ -1,82 +0,0 @@
1#pragma once
2
3#include <react/renderer/components/T3MarkdownTextSpec/EventEmitters.h>
4#include <react/renderer/components/T3MarkdownTextSpec/Props.h>
5#include <react/renderer/components/view/ConcreteViewShadowNode.h>
6#include <react/renderer/textlayoutmanager/TextLayoutManager.h>
7#include <react/renderer/core/LayoutContext.h>
8#include <react/renderer/core/ShadowNode.h>
9
10#include <string>
11#include <vector>
12
13namespace facebook::react {
14
15extern const char T3MarkdownTextComponentName[];
16
17struct T3MarkdownTextParagraphStyleRange {
18 size_t location;
19 size_t length;
20 Float firstLineHeadIndent;
21 Float headIndent;
22 Float paragraphSpacing;
23};
24
25struct T3MarkdownTextAttachmentRange {
26 size_t location;
27 size_t length;
28 std::string imageUri;
29 /// Recolor the loaded image with the run's foreground color, like `sf:` symbols.
30 bool tintWithForeground;
31 Float chipWidth = 0;
32 Float chipHeight = 0;
33};
34
35inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) {
36 return 14;
37}
38
39inline Float T3MarkdownTextAttachmentBaselineOffset(
40 const T3MarkdownTextAttachmentRange &) {
41 return -2;
42}
43
44class T3MarkdownTextStateReal final {
45 public:
46 AttributedString attributedString;
47 std::vector<T3MarkdownTextParagraphStyleRange> paragraphStyleRanges;
48 std::vector<T3MarkdownTextAttachmentRange> attachmentRanges;
49};
50
51class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode<
52T3MarkdownTextComponentName,
53T3MarkdownTextProps,
54T3MarkdownTextEventEmitter,
55T3MarkdownTextStateReal> {
56public:
57 using ConcreteViewShadowNode::ConcreteViewShadowNode;
58
59 T3MarkdownTextShadowNode(
60 const ShadowNode& sourceShadowNode,
61 const ShadowNodeFragment& fragment
62 );
63
64 static ShadowNodeTraits BaseTraits() {
65 auto traits = ConcreteViewShadowNode::BaseTraits();
66 traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
67 traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode);
68 return traits;
69 }
70
71 void layout(LayoutContext layoutContext) override;
72
73 Size measureContent(
74 const LayoutContext& layoutContext,
75 const LayoutConstraints& layoutConstraints) const override;
76
77private:
78 mutable AttributedString _attributedString;
79 mutable std::vector<T3MarkdownTextParagraphStyleRange> _paragraphStyleRanges;
80 mutable std::vector<T3MarkdownTextAttachmentRange> _attachmentRanges;
81};
82} // namespace facebook::React
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm deleted-303
...@@ -1,303 +0,0 @@
1#include "T3MarkdownTextShadowNode.h"
2#include "T3MarkdownTextRunShadowNode.h"
3#import "T3ContextChip.h"
4#include <react/renderer/components/view/ViewShadowNode.h>
5#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
6
7#include <algorithm>
8#include <cmath>
9
10namespace facebook::react {
11
12static constexpr Float ParagraphStyleEncodingOffset = 1000;
13static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:";
14static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:";
15static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:";
16
17static void applyParagraphStyles(
18 NSMutableAttributedString *attributedString,
19 const std::vector<T3MarkdownTextParagraphStyleRange> &styleRanges)
20{
21 for (const auto &styleRange : styleRanges) {
22 if (styleRange.length == 0 || styleRange.location >= attributedString.length) {
23 continue;
24 }
25
26 const NSRange markerRange = NSMakeRange(
27 styleRange.location,
28 MIN(styleRange.length, attributedString.length - styleRange.location));
29 const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange];
30 const NSParagraphStyle *existingStyle =
31 [attributedString attribute:NSParagraphStyleAttributeName
32 atIndex:paragraphRange.location
33 effectiveRange:nil];
34 NSMutableParagraphStyle *paragraphStyle =
35 existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new];
36 paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent;
37 paragraphStyle.headIndent = styleRange.headIndent;
38 paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing;
39 paragraphStyle.tabStops = @[
40 [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft
41 location:styleRange.headIndent
42 options:@{}]
43 ];
44 paragraphStyle.defaultTabInterval = styleRange.headIndent;
45 [attributedString addAttribute:NSParagraphStyleAttributeName
46 value:paragraphStyle
47 range:paragraphRange];
48 }
49}
50
51static void applyAttachments(
52 NSMutableAttributedString *attributedString,
53 const std::vector<T3MarkdownTextAttachmentRange> &attachmentRanges)
54{
55 for (const auto &attachmentRange : attachmentRanges) {
56 if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) {
57 continue;
58 }
59
60 NSTextAttachment *attachment = [[NSTextAttachment alloc] init];
61 attachment.image = [[UIImage alloc] init];
62 const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange);
63 attachment.bounds = CGRectMake(
64 0,
65 T3MarkdownTextAttachmentBaselineOffset(attachmentRange),
66 attachmentSize,
67 attachmentSize);
68 NSDictionary *runAttributes =
69 [attributedString attributesAtIndex:attachmentRange.location effectiveRange:nil];
70 if (attachmentRange.chipWidth > 0) {
71 attachment.bounds = T3ContextChipBounds(
72 runAttributes[NSFontAttributeName],
73 CGSizeMake(attachmentRange.chipWidth, attachmentRange.chipHeight));
74 }
75 const NSRange range = NSMakeRange(
76 attachmentRange.location,
77 MIN(attachmentRange.length, attributedString.length - attachmentRange.location));
78 [attributedString replaceCharactersInRange:range
79 withAttributedString:T3MarkdownTextAttachmentString(attachment, runAttributes)];
80 }
81}
82
83T3MarkdownTextShadowNode::T3MarkdownTextShadowNode(
84 const ShadowNode& sourceShadowNode,
85 const ShadowNodeFragment& fragment
86) : ConcreteViewShadowNode(sourceShadowNode, fragment) {
87};
88
89Size T3MarkdownTextShadowNode::measureContent(
90 const LayoutContext& layoutContext,
91 const LayoutConstraints& layoutConstraints) const {
92 const auto &baseProps = getConcreteProps();
93
94 auto baseTextAttributes = TextAttributes::defaultTextAttributes();
95 baseTextAttributes.backgroundColor = baseProps.backgroundColor;
96 baseTextAttributes.allowFontScaling = baseProps.allowFontScaling;
97
98 Float fontSizeMultiplier = 1.0;
99 if (baseTextAttributes.allowFontScaling) {
100 fontSizeMultiplier = layoutContext.fontSizeMultiplier;
101 }
102
103 auto baseAttributedString = AttributedString{};
104 auto paragraphStyleRanges = std::vector<T3MarkdownTextParagraphStyleRange>{};
105 auto attachmentRanges = std::vector<T3MarkdownTextAttachmentRange>{};
106 size_t utf16Offset = 0;
107 const auto &children = getChildren();
108 for (size_t i = 0; i < children.size(); i++) {
109 const auto child = children[i].get();
110 if (auto textViewChild = dynamic_cast<const T3MarkdownTextRunShadowNode *>(child)) {
111 auto &props = textViewChild->getConcreteProps();
112 auto fragment = AttributedString::Fragment{};
113 auto textAttributes = TextAttributes::defaultTextAttributes();
114
115 textAttributes.allowFontScaling = baseProps.allowFontScaling;
116 textAttributes.backgroundColor = props.backgroundColor;
117 textAttributes.fontSize = props.fontSize * fontSizeMultiplier;
118 textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier;
119 textAttributes.foregroundColor = props.color;
120 const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset;
121 if (!hasParagraphStyle) {
122 textAttributes.textShadowColor = props.shadowColor;
123 textAttributes.textShadowOffset = props.shadowOffset;
124 textAttributes.textShadowRadius = props.shadowRadius;
125 }
126 textAttributes.letterSpacing = props.letterSpacing;
127 textAttributes.textDecorationColor = props.textDecorationColor;
128 textAttributes.fontFamily = props.fontFamily;
129
130 if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) {
131 textAttributes.fontStyle = FontStyle::Italic;
132 } else {
133 textAttributes.fontStyle = FontStyle::Normal;
134 }
135
136 if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) {
137 textAttributes.fontWeight = FontWeight::Bold;
138 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) {
139 textAttributes.fontWeight = FontWeight::UltraLight;
140 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) {
141 textAttributes.fontWeight = FontWeight::Light;
142 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) {
143 textAttributes.fontWeight = FontWeight::Medium;
144 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) {
145 textAttributes.fontWeight = FontWeight::Semibold;
146 } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) {
147 textAttributes.fontWeight = FontWeight::Heavy;
148 } else {
149 textAttributes.fontWeight = FontWeight::Regular;
150 }
151
152 if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) {
153 textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough;
154 } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) {
155 textAttributes.textDecorationLineType = TextDecorationLineType::Underline;
156 } else {
157 textAttributes.textDecorationLineType = TextDecorationLineType::None;
158 }
159
160 if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) {
161 textAttributes.textDecorationStyle = TextDecorationStyle::Solid;
162 } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) {
163 textAttributes.textDecorationStyle = TextDecorationStyle::Dotted;
164 } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) {
165 textAttributes.textDecorationStyle = TextDecorationStyle::Dashed;
166 } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) {
167 textAttributes.textDecorationStyle = TextDecorationStyle::Double;
168 }
169
170 if (props.textAlign == T3MarkdownTextRunTextAlign::Left) {
171 textAttributes.alignment = TextAlignment::Left;
172 } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) {
173 textAttributes.alignment = TextAlignment::Right;
174 } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) {
175 textAttributes.alignment = TextAlignment::Center;
176 } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) {
177 textAttributes.alignment = TextAlignment::Justified;
178 } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) {
179 textAttributes.alignment = TextAlignment::Natural;
180 }
181
182 textAttributes.backgroundColor = props.backgroundColor;
183
184 fragment.string = props.text;
185 fragment.textAttributes = textAttributes;
186
187 NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()];
188 const size_t fragmentLength = fragmentText.length;
189 if (hasParagraphStyle) {
190 paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{
191 utf16Offset,
192 fragmentLength,
193 props.shadowOffset.width,
194 props.shadowOffset.height,
195 props.shadowRadius - ParagraphStyleEncodingOffset,
196 });
197 }
198 if (props.nativeId.rfind("t3-chip:", 0) == 0 && fragmentLength > 0) {
199 const std::string uri = props.nativeId.substr(3);
200 NSMutableDictionary *payload =
201 [T3ContextChipPayload([NSString stringWithUTF8String:uri.c_str()]) mutableCopy];
202 // Chips must scale with the paragraph or smaller Dynamic Type sizes clip them.
203 // Store the scaled payload so measurement and the rendered bitmap use the same font.
204 payload[@"fontSizeMultiplier"] = @(fontSizeMultiplier);
205 NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
206 NSString *scaledUri = [@"chip:" stringByAppendingString:
207 [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]];
208 const CGFloat maxWidth = std::isfinite(layoutConstraints.maximumSize.width)
209 ? layoutConstraints.maximumSize.width : 320;
210 const CGSize size = T3ContextChipSize(payload, maxWidth);
211 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
212 utf16Offset, 1, std::string(scaledUri.UTF8String), false,
213 static_cast<Float>(size.width), static_cast<Float>(size.height),
214 });
215 } else if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
216 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
217 utf16Offset,
218 1,
219 props.nativeId.substr(std::char_traits<char>::length(FileAttachmentNativeIdPrefix)),
220 false,
221 });
222 } else if (
223 props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
224 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
225 utf16Offset,
226 1,
227 props.nativeId.substr(
228 std::char_traits<char>::length(SkillAttachmentNativeIdPrefix)),
229 false,
230 });
231 } else if (
232 props.nativeId.rfind(LinkAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) {
233 attachmentRanges.push_back(T3MarkdownTextAttachmentRange{
234 utf16Offset,
235 1,
236 props.nativeId.substr(std::char_traits<char>::length(LinkAttachmentNativeIdPrefix)),
237 true,
238 });
239 }
240 utf16Offset += fragmentLength;
241 baseAttributedString.appendFragment(std::move(fragment));
242 }
243 }
244
245 _attributedString = baseAttributedString;
246 _paragraphStyleRanges = paragraphStyleRanges;
247 _attachmentRanges = attachmentRanges;
248
249 NSMutableAttributedString *convertedAttributedString =
250 [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy];
251 applyParagraphStyles(convertedAttributedString, paragraphStyleRanges);
252 applyAttachments(convertedAttributedString, attachmentRanges);
253 // TextKit stacks a paragraph's extra line height above the glyphs. React Native's own
254 // layout manager centres them with a baseline offset; do the same, after attachments
255 // so chips shift with the words.
256 RCTApplyBaselineOffset(convertedAttributedString);
257
258 const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width)
259 ? layoutConstraints.maximumSize.width
260 : CGFLOAT_MAX;
261 NSTextStorage *textStorage =
262 [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString];
263 NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
264 layoutManager.usesFontLeading = NO;
265 NSTextContainer *textContainer =
266 [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)];
267 textContainer.lineFragmentPadding = 0;
268 textContainer.maximumNumberOfLines = baseProps.numberOfLines;
269 if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) {
270 textContainer.lineBreakMode = NSLineBreakByTruncatingHead;
271 } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) {
272 textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle;
273 } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) {
274 textContainer.lineBreakMode = NSLineBreakByTruncatingTail;
275 } else {
276 textContainer.lineBreakMode = NSLineBreakByClipping;
277 }
278 [layoutManager addTextContainer:textContainer];
279 [textStorage addLayoutManager:layoutManager];
280 [layoutManager ensureLayoutForTextContainer:textContainer];
281 const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer];
282
283 return {
284 std::clamp(
285 static_cast<Float>(std::ceil(usedRect.size.width)),
286 layoutConstraints.minimumSize.width,
287 layoutConstraints.maximumSize.width),
288 std::clamp(
289 static_cast<Float>(std::ceil(usedRect.size.height)),
290 layoutConstraints.minimumSize.height,
291 layoutConstraints.maximumSize.height),
292 };
293}
294
295void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) {
296 ensureUnsealed();
297 setStateData(T3MarkdownTextStateReal{
298 _attributedString,
299 _paragraphStyleRanges,
300 _attachmentRanges,
301 });
302}
303}
apps/mobile/modules/t3-markdown-text/package.json deleted-57
...@@ -1,57 +0,0 @@
1{
2 "name": "@t3tools/mobile-markdown-text",
3 "version": "0.0.0",
4 "private": true,
5 "source": "./index.ts",
6 "files": [
7 "android",
8 "assets",
9 "expo-module.config.json",
10 "ios",
11 "src",
12 "index.ts",
13 "LICENSE",
14 "UPSTREAM.md",
15 "T3MarkdownText.podspec",
16 "react-native.config.js"
17 ],
18 "main": "./index.ts",
19 "types": "./index.ts",
20 "react-native": "./index.ts",
21 "exports": {
22 ".": "./index.ts",
23 "./file-icons": "./src/markdownFileIcons.ts",
24 "./link-icons": "./src/markdownLinkIcons.ts",
25 "./links": "./src/markdownLinks.ts",
26 "./markdown": "./src/nativeMarkdownText.ts",
27 "./primitive": "./src/MarkdownTextPrimitive.tsx",
28 "./renderer": "./src/SelectableMarkdownText.tsx",
29 "./types": "./src/SelectableMarkdownText.types.ts"
30 },
31 "peerDependencies": {
32 "@t3tools/client-runtime": "*",
33 "@t3tools/shared": "*",
34 "expo": "*",
35 "expo-asset": "*",
36 "expo-clipboard": "*",
37 "expo-haptics": "*",
38 "expo-symbols": "*",
39 "react": "*",
40 "react-native": "*",
41 "react-native-nitro-markdown": "*"
42 },
43 "codegenConfig": {
44 "name": "T3MarkdownTextSpec",
45 "type": "all",
46 "jsSrcsDir": "src",
47 "ios": {
48 "componentProvider": {
49 "T3MarkdownText": "T3MarkdownText",
50 "T3MarkdownTextRun": "T3MarkdownTextRun"
51 }
52 },
53 "outputDir": {
54 "ios": "ios/generated"
55 }
56 }
57}
apps/mobile/modules/t3-markdown-text/react-native.config.js deleted-10
...@@ -1,10 +0,0 @@
1module.exports = {
2 dependency: {
3 platforms: {
4 ios: {
5 podspecPath: "T3MarkdownText.podspec",
6 },
7 android: null,
8 },
9 },
10};
apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs deleted-134
...@@ -1,134 +0,0 @@
1import * as NodeChildProcess from "node:child_process";
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as NodeURL from "node:url";
5
6import { getBuiltInSpriteSheet } from "@pierre/trees";
7
8const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
9const moduleDirectory = NodePath.resolve(scriptDirectory, "..");
10const repositoryRoot = NodePath.resolve(moduleDirectory, "../../../..");
11const outputDirectory = NodePath.join(moduleDirectory, "assets/file-icons");
12const generatedModulePath = NodePath.join(moduleDirectory, "src/markdownFileIcons.generated.ts");
13const webIconSource = NodeFS.readFileSync(
14 NodePath.join(repositoryRoot, "apps/web/src/pierre-icons.ts"),
15 "utf8",
16);
17const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1];
18
19if (!customSprite) {
20 throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts");
21}
22
23const colors = {
24 astro: "#a631be",
25 babel: "#d5a910",
26 bash: "#199f43",
27 biome: "#1a85d4",
28 bootstrap: "#693acf",
29 browserslist: "#d5a910",
30 bun: "#594c5b",
31 c: "#1a85d4",
32 claude: "#d47628",
33 cpp: "#1a85d4",
34 css: "#693acf",
35 database: "#a631be",
36 default: "#84848a",
37 docker: "#1a85d4",
38 eslint: "#693acf",
39 font: "#84848a",
40 git: "#ff8c5b",
41 go: "#1ca1c7",
42 graphql: "#d32a61",
43 html: "#d47628",
44 image: "#d32a61",
45 javascript: "#d5a910",
46 json: "#d47628",
47 markdown: "#199f43",
48 mcp: "#17a5af",
49 nextjs: "#84848a",
50 npm: "#d52c36",
51 oxc: "#1ca1c7",
52 postcss: "#d52c36",
53 prettier: "#17a5af",
54 python: "#1a85d4",
55 react: "#1ca1c7",
56 ruby: "#d52c36",
57 rust: "#d47628",
58 sass: "#d32a61",
59 stylelint: "#84848a",
60 svelte: "#d52c36",
61 svg: "#d47628",
62 svgo: "#199f43",
63 swift: "#d47628",
64 table: "#17a5af",
65 tailwind: "#1ca1c7",
66 terraform: "#693acf",
67 text: "#84848a",
68 typescript: "#1a85d4",
69 video: "#a631be",
70 vite: "#a631be",
71 vscode: "#1a85d4",
72 vue: "#199f43",
73 wasm: "#693acf",
74 webpack: "#1a85d4",
75 yml: "#d52c36",
76 zig: "#d47628",
77 zip: "#d47628",
78};
79
80const customIcons = {
81 agents: "t3-file-icon-agents",
82 pnpm: "t3-file-icon-pnpm",
83 video: "t3-file-icon-video",
84};
85
86function symbolFromSprite(sprite, id) {
87 const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
88 const match = sprite.match(
89 new RegExp(`<symbol id="${escapedId}"([^>]*)>([\\s\\S]*?)<\\/symbol>`),
90 );
91 if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`);
92 return {
93 body: match[2],
94 viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16",
95 };
96}
97
98function renderIcon(token, symbol, color) {
99 const svgPath = NodePath.join(outputDirectory, `.pierre-${token}.svg`);
100 const pngPath = NodePath.join(outputDirectory, `pierre_${token}.png`);
101 NodeFS.writeFileSync(
102 svgPath,
103 `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="${symbol.viewBox}" style="color:${color}">${symbol.body}</svg>`,
104 );
105 NodeChildProcess.execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], {
106 stdio: "ignore",
107 });
108 NodeFS.rmSync(svgPath);
109}
110
111NodeFS.rmSync(outputDirectory, { recursive: true, force: true });
112NodeFS.mkdirSync(outputDirectory, { recursive: true });
113
114const builtInSprite = getBuiltInSpriteSheet("complete");
115const builtInTokens = [...builtInSprite.matchAll(/<symbol id="file-tree-builtin-([^"]+)"/g)]
116 .map((match) => match[1])
117 .sort();
118
119for (const token of builtInTokens) {
120 renderIcon(
121 token,
122 symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`),
123 colors[token] ?? colors.default,
124 );
125}
126for (const [token, symbolId] of Object.entries(customIcons)) {
127 renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default);
128}
129
130const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort();
131const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens
132 .map((token) => ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`)
133 .join("\n")}\n} as const satisfies Readonly<Record<string, ImageSourcePropType>>;\n`;
134NodeFS.writeFileSync(generatedModulePath, generatedSource);
apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx deleted-73
...@@ -1,73 +0,0 @@
1import { SymbolView } from "expo-symbols";
2import * as Clipboard from "expo-clipboard";
3import * as Haptics from "expo-haptics";
4import { memo, useEffect, useRef, useState } from "react";
5import { Pressable, type ColorValue } from "react-native";
6
7const COPY_FEEDBACK_DURATION_MS = 1200;
8
9function copyTextWithHaptic(value: string): void {
10 void Clipboard.setStringAsync(value);
11 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
12}
13
14export const CopyTextButton = memo(function CopyTextButton(props: {
15 readonly accessibilityLabel: string;
16 readonly text: string;
17 readonly tintColor: ColorValue;
18 readonly copiedTintColor?: ColorValue;
19 readonly backgroundColor?: ColorValue;
20 readonly borderColor?: ColorValue;
21 readonly iconSize?: number;
22 readonly buttonSize?: number;
23}) {
24 const [copied, setCopied] = useState(false);
25 const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
26
27 useEffect(
28 () => () => {
29 if (resetTimeoutRef.current) {
30 clearTimeout(resetTimeoutRef.current);
31 }
32 },
33 [],
34 );
35
36 return (
37 <Pressable
38 accessibilityRole="button"
39 accessibilityLabel={copied ? "Copied" : props.accessibilityLabel}
40 disabled={props.text.length === 0}
41 hitSlop={8}
42 onPress={() => {
43 copyTextWithHaptic(props.text);
44 setCopied(true);
45 if (resetTimeoutRef.current) {
46 clearTimeout(resetTimeoutRef.current);
47 }
48 resetTimeoutRef.current = setTimeout(() => {
49 setCopied(false);
50 resetTimeoutRef.current = null;
51 }, COPY_FEEDBACK_DURATION_MS);
52 }}
53 style={({ pressed }) => ({
54 width: props.buttonSize ?? 30,
55 height: props.buttonSize ?? 30,
56 alignItems: "center",
57 justifyContent: "center",
58 borderRadius: 9,
59 borderWidth: props.borderColor ? 1 : 0,
60 borderColor: props.borderColor,
61 backgroundColor: props.backgroundColor,
62 opacity: pressed ? 0.52 : 1,
63 })}
64 >
65 <SymbolView
66 name={copied ? "checkmark" : "doc.on.doc"}
67 size={props.iconSize ?? 13}
68 tintColor={copied ? (props.copiedTintColor ?? props.tintColor) : props.tintColor}
69 type="monochrome"
70 />
71 </Pressable>
72 );
73});
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx deleted-179
...@@ -1,179 +0,0 @@
1import React, { type Ref } from "react";
2import {
3 findNodeHandle,
4 Platform,
5 processColor,
6 StyleSheet,
7 Text as RNText,
8 type ColorValue,
9 type TextInstance,
10 type TextProps,
11 type ViewStyle,
12} from "react-native";
13import { setMarkdownSelectionHandleColor } from "./T3MarkdownTextSelectionModule";
14import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent";
15import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent";
16import { flattenStyles } from "./util";
17
18const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
19 false,
20 StyleSheet.create({}),
21]);
22
23const textDefaults = {
24 allowFontScaling: true,
25 selectable: true,
26} satisfies TextProps;
27
28const useTextAncestorContext = () => React.useContext(TextAncestorContext);
29
30/**
31 * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices
32 * into the rendered string. `start === end` means the selection was cleared.
33 */
34export type SelectionChangeEvent = {
35 nativeEvent: { target: number; start: number; end: number };
36};
37
38export type ContextMenuActionEvent = {
39 nativeEvent: { target: number; actionIdentifier: string };
40};
41
42/**
43 * `onTextLayout` is not offered: the native view reports plain line strings
44 * while the React Native Text fallback reports measured `TextLayoutLine`s.
45 */
46export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & {
47 nativeTextRef?: Ref<TextInstance>;
48 selectionHandleColor?: ColorValue;
49 uiTextView?: boolean;
50 contextMenuConfig?: string;
51 contextClipboardConfig?: string;
52 onContextMenuAction?: (event: ContextMenuActionEvent) => void;
53 /**
54 * Fired when the native text selection changes. Only fires on iOS when
55 * `uiTextView` is true. Note: fires on every selection-edge adjustment
56 * (e.g. dragging a selection handle), so consumers driving expensive work
57 * off this event should debounce.
58 */
59 onSelectionChange?: (event: SelectionChangeEvent) => void;
60};
61
62function MarkdownTextPrimitiveChild({
63 style,
64 children,
65 nativeTextRef: _nativeTextRef,
66 ...rest
67}: MarkdownTextPrimitiveProps) {
68 const [isAncestor, rootStyle] = useTextAncestorContext();
69
70 // Flatten the styles, and apply the root styles when needed
71 const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]);
72 const contextValue = React.useMemo<[boolean, ViewStyle]>(
73 () => [true, flattenedStyle],
74 [flattenedStyle],
75 );
76 let childPosition = 0;
77 const nativeChildren = React.Children.toArray(children).map((child) => {
78 const position = childPosition;
79 childPosition += 1;
80
81 if (React.isValidElement(child)) {
82 return child;
83 }
84 if (typeof child !== "string" && typeof child !== "number") {
85 return null;
86 }
87
88 const text = child.toString();
89 return (
90 // @ts-expect-error The generated run props do not include inherited Text props.
91 <T3MarkdownTextRunNativeComponent
92 key={`text-${position}-${text.length}-${text}`}
93 style={flattenedStyle}
94 text={text}
95 {...rest}
96 />
97 );
98 });
99
100 if (!isAncestor) {
101 // Press handlers are delivered by the text runs; the container never sees them.
102 const { onPress: _onPress, onLongPress: _onLongPress, ...containerProps } = rest;
103 return (
104 <TextAncestorContext.Provider value={contextValue}>
105 <T3MarkdownTextNativeComponent
106 {...textDefaults}
107 {...containerProps}
108 style={[flattenedStyle]}
109 >
110 {nativeChildren}
111 </T3MarkdownTextNativeComponent>
112 </TextAncestorContext.Provider>
113 );
114 }
115
116 return <>{nativeChildren}</>;
117}
118
119function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPrimitiveProps) {
120 const [isAncestor] = useTextAncestorContext();
121
122 // Even if the uiTextView prop is set, we can still default to using
123 // normal selection (i.e. base RN text) if the text doesn't need to be
124 // selectable
125 if ((!props.selectable || !props.uiTextView) && !isAncestor) {
126 return <RNText ref={nativeTextRef} {...props} />;
127 }
128 return <MarkdownTextPrimitiveChild {...props} />;
129}
130
131function AndroidMarkdownText({
132 nativeTextRef,
133 selectionHandleColor,
134 onLayout,
135 contextClipboardConfig: _contextClipboardConfig,
136 ...props
137}: MarkdownTextPrimitiveProps) {
138 const textRef = React.useRef<TextInstance | null>(null);
139 React.useImperativeHandle<TextInstance | null, TextInstance | null>(
140 nativeTextRef,
141 () => textRef.current,
142 [],
143 );
144 const color = processColor(selectionHandleColor);
145 const applyHandleColor = React.useCallback(() => {
146 if (!textRef.current || typeof color !== "number") return;
147 const reactTag = findNodeHandle(textRef.current);
148 if (reactTag != null) setMarkdownSelectionHandleColor(reactTag, color);
149 }, [color]);
150
151 // RN's selectionColor only sets the highlight. Retint mounted handles when
152 // the theme changes, and after layout when the native view first exists.
153 React.useEffect(applyHandleColor, [applyHandleColor]);
154
155 return (
156 <RNText
157 ref={textRef}
158 {...props}
159 onLayout={(event) => {
160 applyHandleColor();
161 onLayout?.(event);
162 }}
163 />
164 );
165}
166
167export function MarkdownTextPrimitive({
168 selectionHandleColor,
169 ...props
170}: MarkdownTextPrimitiveProps) {
171 if (Platform.OS === "android" && selectionHandleColor !== undefined) {
172 return <AndroidMarkdownText {...props} selectionHandleColor={selectionHandleColor} />;
173 }
174 if (Platform.OS !== "ios") {
175 const { nativeTextRef, contextClipboardConfig: _contextClipboardConfig, ...textProps } = props;
176 return <RNText ref={nativeTextRef} {...textProps} />;
177 }
178 return <MarkdownTextPrimitiveInner {...props} />;
179}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx deleted-687
...@@ -1,687 +0,0 @@
1import { SymbolView } from "expo-symbols";
2import { githubAlert } from "./githubAlert";
3import { createContext, memo, useContext, useMemo } from "react";
4import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native";
5import type { MarkdownNode } from "react-native-nitro-markdown/headless";
6
7import { CopyTextButton } from "./CopyTextButton";
8import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
9import {
10 nativeMarkdownDocumentRuns,
11 nativeMarkdownListItemBlocks,
12 nativeMarkdownNodePosition,
13} from "./nativeMarkdownText";
14import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText";
15import type {
16 MarkdownCodeHighlighter,
17 MarkdownHighlightedToken,
18 MarkdownImageRenderer,
19 NativeMarkdownTextStyle,
20 SelectableMarkdownSkill,
21} from "./SelectableMarkdownText.types";
22import { useHighlightedCode, type HighlightedCode } from "./useHighlightedCode";
23
24/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */
25export const MarkdownImageRendererContext = createContext<MarkdownImageRenderer | null>(null);
26
27const MONO_FONT_FAMILY = Platform.select({
28 ios: "ui-monospace",
29 android: "monospace",
30 default: "monospace",
31});
32
33function nodeKey(node: MarkdownNode, index: number): string {
34 return `${node.type}:${nativeMarkdownNodePosition(node, index)}`;
35}
36
37/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */
38function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number {
39 return Math.max(10, Math.round(textStyle.fontSize * 0.8));
40}
41
42function codeBlockLineHeight(textStyle: NativeMarkdownTextStyle): number {
43 return codeBlockFontSize(textStyle) + 6;
44}
45
46function nodeText(node: MarkdownNode): string {
47 if (node.content !== undefined) {
48 return node.content;
49 }
50 return (node.children ?? []).map(nodeText).join("");
51}
52
53function documentFor(node: MarkdownNode): MarkdownNode {
54 return node.type === "document" ? node : { type: "document", children: [node] };
55}
56
57function SelectableNode(props: {
58 readonly node: MarkdownNode;
59 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
60 readonly textStyle: NativeMarkdownTextStyle;
61 readonly onLinkPress?: (href: string) => void;
62}) {
63 return (
64 <NativeMarkdownSelectableText
65 runs={nativeMarkdownDocumentRuns(documentFor(props.node), props.skills)}
66 textStyle={props.textStyle}
67 onLinkPress={props.onLinkPress}
68 />
69 );
70}
71
72const HighlightedCodeLine = memo(function HighlightedCodeLine(props: {
73 readonly tokens: ReadonlyArray<MarkdownHighlightedToken>;
74 readonly color: string;
75 readonly newline: boolean;
76}) {
77 let offset = 0;
78 const children = [];
79 for (const token of props.tokens) {
80 if (!token.content) continue;
81 children.push(
82 <MarkdownTextPrimitive
83 key={offset}
84 style={{
85 color: token.color ?? props.color,
86 fontFamily: MONO_FONT_FAMILY,
87 fontStyle: token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal",
88 fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",
89 }}
90 >
91 {token.content}
92 </MarkdownTextPrimitive>,
93 );
94 offset += token.content.length;
95 }
96 return (
97 <MarkdownTextPrimitive>
98 {children}
99 {props.newline ? "\n" : ""}
100 </MarkdownTextPrimitive>
101 );
102});
103
104function HighlightedCodeText(props: {
105 readonly content: string;
106 readonly highlighted: HighlightedCode | null;
107 readonly textStyle: NativeMarkdownTextStyle;
108}) {
109 // The text root provides inherited styles through context. A new style object
110 // would rerender every token even when its completed line is unchanged.
111 const fontSize = codeBlockFontSize(props.textStyle);
112 const lineHeight = codeBlockLineHeight(props.textStyle);
113 const style = useMemo(
114 () => ({
115 color: props.textStyle.codeColor,
116 fontFamily: MONO_FONT_FAMILY,
117 fontSize,
118 lineHeight,
119 }),
120 [props.textStyle.codeColor, fontSize, lineHeight],
121 );
122 let offset = 0;
123 const lines = [];
124 if (props.highlighted) {
125 for (const tokens of props.highlighted) {
126 lines.push(
127 <HighlightedCodeLine
128 key={offset}
129 tokens={tokens}
130 color={props.textStyle.codeColor}
131 newline={lines.length + 1 < props.highlighted.length}
132 />,
133 );
134 offset += tokens.reduce((length, token) => length + token.content.length, 0) + 1;
135 }
136 }
137 return (
138 <MarkdownTextPrimitive
139 uiTextView
140 selectable
141 selectionColor={props.textStyle.selectionColor}
142 selectionHandleColor={props.textStyle.selectionHandleColor}
143 style={style}
144 >
145 {props.highlighted ? lines : props.content}
146 </MarkdownTextPrimitive>
147 );
148}
149
150function NativeCodeBlock(props: {
151 readonly node: MarkdownNode;
152 readonly textStyle: NativeMarkdownTextStyle;
153 readonly highlightCode: MarkdownCodeHighlighter;
154 readonly compact?: boolean;
155}) {
156 const content = nodeText(props.node).replace(/\n$/, "");
157 const colorScheme = useColorScheme();
158 const theme = colorScheme === "dark" ? "dark" : "light";
159 const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode);
160 const languageLabel = props.node.language?.toUpperCase() ?? "CODE";
161 return (
162 <View
163 style={{
164 backgroundColor: props.textStyle.codeBlockBackgroundColor,
165 borderColor: props.textStyle.dividerColor,
166 borderCurve: "continuous",
167 borderRadius: 10,
168 borderWidth: 1,
169 marginVertical: props.compact ? 7 : 0,
170 overflow: "hidden",
171 }}
172 >
173 <View
174 style={{
175 minHeight: 42,
176 borderBottomColor: props.textStyle.dividerColor,
177 borderBottomWidth: 1,
178 paddingLeft: 14,
179 paddingRight: 6,
180 flexDirection: "row",
181 alignItems: "center",
182 justifyContent: "space-between",
183 }}
184 >
185 <MarkdownTextPrimitive
186 selectable
187 selectionColor={props.textStyle.selectionColor}
188 selectionHandleColor={props.textStyle.selectionHandleColor}
189 style={{
190 flex: 1,
191 color: props.textStyle.mutedColor,
192 fontFamily: MONO_FONT_FAMILY,
193 fontSize: codeBlockFontSize(props.textStyle),
194 }}
195 >
196 {languageLabel}
197 </MarkdownTextPrimitive>
198 <CopyTextButton
199 accessibilityLabel={`Copy ${languageLabel.toLowerCase()} code`}
200 text={content}
201 tintColor={props.textStyle.mutedColor}
202 copiedTintColor={props.textStyle.linkColor}
203 backgroundColor={props.textStyle.codeBackgroundColor}
204 borderColor={props.textStyle.dividerColor}
205 buttonSize={34}
206 iconSize={14}
207 />
208 </View>
209 <ScrollView
210 horizontal
211 bounces={false}
212 nestedScrollEnabled={Platform.OS === "android"}
213 showsHorizontalScrollIndicator={false}
214 contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }}
215 >
216 <HighlightedCodeText
217 content={content}
218 highlighted={highlighted}
219 textStyle={props.textStyle}
220 />
221 </ScrollView>
222 </View>
223 );
224}
225
226function collectTableRows(node: MarkdownNode): MarkdownNode[] {
227 const rows: MarkdownNode[] = [];
228 const visit = (child: MarkdownNode) => {
229 if (child.type === "table_row") {
230 rows.push(child);
231 return;
232 }
233 for (const nested of child.children ?? []) {
234 visit(nested);
235 }
236 };
237 visit(node);
238 return rows;
239}
240
241function NativeTable(props: {
242 readonly node: MarkdownNode;
243 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
244 readonly textStyle: NativeMarkdownTextStyle;
245 readonly onLinkPress?: (href: string) => void;
246}) {
247 const rows = collectTableRows(props.node);
248 return (
249 <ScrollView
250 horizontal
251 bounces={false}
252 nestedScrollEnabled={Platform.OS === "android"}
253 showsHorizontalScrollIndicator={false}
254 >
255 <View
256 style={{
257 borderColor: props.textStyle.dividerColor,
258 borderCurve: "continuous",
259 borderRadius: 8,
260 borderWidth: 1,
261 overflow: "hidden",
262 }}
263 >
264 {rows.map((row, rowIndex) => (
265 <View
266 key={nodeKey(row, rowIndex)}
267 style={{
268 flexDirection: "row",
269 backgroundColor: rowIndex === 0 ? props.textStyle.codeBackgroundColor : "transparent",
270 borderTopColor: props.textStyle.dividerColor,
271 borderTopWidth: rowIndex === 0 ? 0 : 1,
272 }}
273 >
274 {(row.children ?? []).map((cell, cellIndex) => (
275 <View
276 key={nodeKey(cell, cellIndex)}
277 style={{
278 width: 160,
279 borderLeftColor: props.textStyle.dividerColor,
280 borderLeftWidth: cellIndex === 0 ? 0 : 1,
281 paddingHorizontal: 10,
282 paddingVertical: 8,
283 }}
284 >
285 <NativeMarkdownSelectableText
286 runs={nativeMarkdownDocumentRuns(documentFor(cell), props.skills).map((run) =>
287 rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run,
288 )}
289 textStyle={props.textStyle}
290 onLinkPress={props.onLinkPress}
291 />
292 </View>
293 ))}
294 </View>
295 ))}
296 </View>
297 </ScrollView>
298 );
299}
300
301function NativeMarkdownImage(props: {
302 readonly node: MarkdownNode;
303 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
304 readonly textStyle: NativeMarkdownTextStyle;
305 readonly onLinkPress?: (href: string) => void;
306}) {
307 const renderImage = useContext(MarkdownImageRendererContext);
308 const href = props.node.href;
309 if (!href) {
310 return (
311 <SelectableNode
312 node={props.node}
313 skills={props.skills}
314 textStyle={props.textStyle}
315 onLinkPress={props.onLinkPress}
316 />
317 );
318 }
319
320 if (renderImage) {
321 const rendered = renderImage({
322 href,
323 alt: props.node.alt ?? null,
324 title: props.node.title ?? null,
325 });
326 if (rendered != null) {
327 return <>{rendered}</>;
328 }
329 }
330
331 return (
332 <View style={{ gap: 6 }}>
333 <Image
334 source={{ uri: href }}
335 resizeMode="contain"
336 accessibilityLabel={props.node.alt ?? props.node.title}
337 style={{
338 width: "100%",
339 aspectRatio: 16 / 9,
340 backgroundColor: props.textStyle.codeBackgroundColor,
341 borderRadius: 10,
342 }}
343 />
344 {props.node.alt ? (
345 <MarkdownTextPrimitive
346 selectable
347 selectionColor={props.textStyle.selectionColor}
348 selectionHandleColor={props.textStyle.selectionHandleColor}
349 style={{
350 color: props.textStyle.mutedColor,
351 fontFamily: props.textStyle.fontFamily,
352 fontSize: 12,
353 lineHeight: 16,
354 }}
355 >
356 {props.node.alt}
357 </MarkdownTextPrimitive>
358 ) : null}
359 </View>
360 );
361}
362
363function inlineGroups(nodes: ReadonlyArray<MarkdownNode>): MarkdownNode[] {
364 const groups: MarkdownNode[] = [];
365 let inline: MarkdownNode[] = [];
366 const flush = () => {
367 if (inline.length === 0) {
368 return;
369 }
370 groups.push({ type: "paragraph", children: inline });
371 inline = [];
372 };
373
374 for (const node of nodes) {
375 if (node.type === "image") {
376 flush();
377 groups.push(node);
378 } else {
379 inline.push(node);
380 }
381 }
382 flush();
383 return groups;
384}
385
386function NativeMixedParagraph(props: {
387 readonly node: MarkdownNode;
388 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
389 readonly textStyle: NativeMarkdownTextStyle;
390 readonly onLinkPress?: (href: string) => void;
391}) {
392 return (
393 <View style={{ gap: 8 }}>
394 {inlineGroups(props.node.children ?? []).map((child, index) =>
395 child.type === "image" ? (
396 <NativeMarkdownImage
397 key={nodeKey(child, index)}
398 node={child}
399 skills={props.skills}
400 textStyle={props.textStyle}
401 onLinkPress={props.onLinkPress}
402 />
403 ) : (
404 <SelectableNode
405 key={nodeKey(child, index)}
406 node={child}
407 skills={props.skills}
408 textStyle={props.textStyle}
409 onLinkPress={props.onLinkPress}
410 />
411 ),
412 )}
413 </View>
414 );
415}
416
417function NativeList(props: {
418 readonly node: MarkdownNode;
419 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
420 readonly textStyle: NativeMarkdownTextStyle;
421 readonly highlightCode: MarkdownCodeHighlighter;
422 readonly onLinkPress?: (href: string) => void;
423 readonly depth: number;
424}) {
425 const ordered = props.node.ordered ?? false;
426 const start = props.node.start ?? 1;
427 const nested = props.depth > 0;
428 return (
429 <View
430 style={{
431 gap: nested ? 3 : 5,
432 }}
433 >
434 {(props.node.children ?? []).map((item, index) => {
435 const taskMarker = item.type === "task_list_item";
436 const marker = taskMarker
437 ? item.checked
438 ? "☑︎"
439 : "☐︎"
440 : ordered
441 ? `${start + index}.`
442 : props.depth % 3 === 1
443 ? "◦"
444 : props.depth % 3 === 2
445 ? "▪︎"
446 : "•";
447 const markerWidth = ordered ? 28 : taskMarker ? 20 : 18;
448 const markerOffset = taskMarker ? 3 : ordered ? 0 : 2;
449 return (
450 <View
451 key={nodeKey(item, index)}
452 style={{ alignItems: "flex-start", flexDirection: "row" }}
453 >
454 <View
455 style={{
456 width: markerWidth,
457 height: props.textStyle.lineHeight,
458 marginRight: 6,
459 alignItems: ordered ? "flex-end" : "center",
460 justifyContent: "flex-start",
461 }}
462 >
463 <Text
464 style={{
465 color: props.textStyle.color,
466 fontFamily: props.textStyle.fontFamily,
467 fontSize: taskMarker ? 14 : props.textStyle.fontSize,
468 lineHeight: props.textStyle.lineHeight,
469 fontVariant: ordered ? ["tabular-nums"] : undefined,
470 transform: [{ translateY: markerOffset }],
471 }}
472 >
473 {marker}
474 </Text>
475 </View>
476 <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
477 {nativeMarkdownListItemBlocks(item).map((child, childIndex) => (
478 <NativeMarkdownBlock
479 key={nodeKey(child, childIndex)}
480 node={child}
481 skills={props.skills}
482 textStyle={props.textStyle}
483 highlightCode={props.highlightCode}
484 onLinkPress={props.onLinkPress}
485 depth={props.depth + 1}
486 compact
487 />
488 ))}
489 </View>
490 </View>
491 );
492 })}
493 </View>
494 );
495}
496
497export function NativeMarkdownBlock(props: {
498 readonly node: MarkdownNode;
499 readonly skills: ReadonlyArray<SelectableMarkdownSkill>;
500 readonly textStyle: NativeMarkdownTextStyle;
501 readonly highlightCode: MarkdownCodeHighlighter;
502 readonly onLinkPress?: (href: string) => void;
503 readonly depth?: number;
504 readonly compact?: boolean;
505}) {
506 const depth = props.depth ?? 0;
507 const dark = useColorScheme() === "dark";
508 switch (props.node.type) {
509 case "document":
510 return (
511 <View style={{ gap: 8 }}>
512 {(props.node.children ?? []).map((child, index) => (
513 <NativeMarkdownBlock
514 key={nodeKey(child, index)}
515 node={child}
516 skills={props.skills}
517 textStyle={props.textStyle}
518 highlightCode={props.highlightCode}
519 onLinkPress={props.onLinkPress}
520 depth={depth}
521 />
522 ))}
523 </View>
524 );
525 case "code_block":
526 return (
527 <NativeCodeBlock
528 node={props.node}
529 textStyle={props.textStyle}
530 highlightCode={props.highlightCode}
531 compact={props.compact}
532 />
533 );
534 case "table":
535 return (
536 <NativeTable
537 node={props.node}
538 skills={props.skills}
539 textStyle={props.textStyle}
540 onLinkPress={props.onLinkPress}
541 />
542 );
543 case "image":
544 return (
545 <NativeMarkdownImage
546 node={props.node}
547 skills={props.skills}
548 textStyle={props.textStyle}
549 onLinkPress={props.onLinkPress}
550 />
551 );
552 case "horizontal_rule":
553 return (
554 <View
555 style={{
556 height: 1,
557 backgroundColor: props.textStyle.dividerColor,
558 }}
559 />
560 );
561 case "blockquote": {
562 const alert = githubAlert(props.node);
563 const alertColor = dark ? alert?.color : alert?.lightColor;
564 return (
565 <View
566 style={{
567 borderLeftColor: alertColor ?? props.textStyle.quoteMarkerColor,
568 borderLeftWidth: 2,
569 marginVertical: props.compact ? 4 : 0,
570 paddingLeft: 11,
571 paddingVertical: 2,
572 gap: 6,
573 }}
574 >
575 {alert ? (
576 <View style={{ flexDirection: "row", alignItems: "center", gap: 7 }}>
577 {Platform.OS === "ios" ? (
578 <SymbolView name={alert.symbol} size={17} tintColor={alertColor} />
579 ) : null}
580 <Text
581 style={{
582 color: alertColor,
583 fontFamily: props.textStyle.boldFontFamily,
584 fontSize: props.textStyle.fontSize,
585 }}
586 >
587 {alert.title}
588 </Text>
589 </View>
590 ) : null}
591 {(alert?.children ?? props.node.children ?? []).map((child, index) => (
592 <NativeMarkdownBlock
593 key={nodeKey(child, index)}
594 node={child}
595 skills={props.skills}
596 textStyle={props.textStyle}
597 highlightCode={props.highlightCode}
598 onLinkPress={props.onLinkPress}
599 depth={depth}
600 compact
601 />
602 ))}
603 </View>
604 );
605 }
606 case "list":
607 return (
608 <NativeList
609 node={props.node}
610 skills={props.skills}
611 textStyle={props.textStyle}
612 highlightCode={props.highlightCode}
613 onLinkPress={props.onLinkPress}
614 depth={depth}
615 />
616 );
617 case "paragraph":
618 return (props.node.children ?? []).some((child) => child.type === "image") ? (
619 <NativeMixedParagraph
620 node={props.node}
621 skills={props.skills}
622 textStyle={props.textStyle}
623 onLinkPress={props.onLinkPress}
624 />
625 ) : (
626 <SelectableNode
627 node={props.node}
628 skills={props.skills}
629 textStyle={props.textStyle}
630 onLinkPress={props.onLinkPress}
631 />
632 );
633 case "html_block":
634 case "math_block":
635 return (
636 <View
637 style={{
638 marginVertical: props.compact ? 2 : 0,
639 paddingHorizontal: props.node.type === "math_block" ? 10 : 0,
640 paddingVertical: props.node.type === "math_block" ? 8 : 0,
641 backgroundColor:
642 props.node.type === "math_block"
643 ? props.textStyle.codeBackgroundColor
644 : "transparent",
645 }}
646 >
647 <SelectableNode
648 node={props.node}
649 skills={props.skills}
650 textStyle={props.textStyle}
651 onLinkPress={props.onLinkPress}
652 />
653 </View>
654 );
655 case "table_head":
656 case "table_body":
657 case "table_row":
658 case "table_cell":
659 case "list_item":
660 case "task_list_item":
661 return (
662 <View style={{ gap: 4 }}>
663 {(props.node.children ?? []).map((child, index) => (
664 <NativeMarkdownBlock
665 key={nodeKey(child, index)}
666 node={child}
667 skills={props.skills}
668 textStyle={props.textStyle}
669 highlightCode={props.highlightCode}
670 onLinkPress={props.onLinkPress}
671 depth={depth}
672 compact
673 />
674 ))}
675 </View>
676 );
677 default:
678 return (
679 <SelectableNode
680 node={props.node}
681 skills={props.skills}
682 textStyle={props.textStyle}
683 onLinkPress={props.onLinkPress}
684 />
685 );
686 }
687}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx deleted-446
...@@ -1,446 +0,0 @@
1import { createContext, useCallback, useContext, useMemo } from "react";
2import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
3import {
4 findNodeHandle,
5 Image,
6 Linking,
7 Platform,
8 StyleSheet,
9 type TextInstance,
10 type TextStyle,
11 useColorScheme,
12 View,
13} from "react-native";
14
15import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
16import { markdownFileIconSource, markdownIconAssetUri } from "./markdownFileIcons";
17import { markdownLinkIconSource } from "./markdownLinkIcons";
18import { resolveMarkdownFileIcon, resolveMarkdownLinkIcon } from "./markdownLinks";
19import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
20import { nativeMarkdownContextCopyRanges } from "./nativeMarkdownText";
21import type {
22 MarkdownFileContextMenu,
23 NativeMarkdownTextStyle,
24} from "./SelectableMarkdownText.types";
25import {
26 installMarkdownCopySanitizer,
27 renderAndroidContextChip,
28} from "./T3MarkdownTextSelectionModule";
29import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
30import { contextChipPresentation } from "./nativeMarkdownText";
31
32export const MarkdownContextClipboardContext = createContext("");
33
34export interface MarkdownFileContextMenuHandlers {
35 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
36 readonly onFileContextMenuAction: (href: string, actionId: string) => void;
37}
38
39/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */
40export const MarkdownFileContextMenuContext = createContext<MarkdownFileContextMenuHandlers | null>(
41 null,
42);
43
44const EXTERNAL_LINK_PREFIX = "◉ ";
45const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
46const SKILL_ICON_PLACEHOLDER = "\uFFFC";
47// React Native turns a run whose whole text is U+FFFC into a bare inline-view attachment
48// with no font or paragraph style, so a chip opening a paragraph would drop its line
49// height. Any other single character keeps the run's attributes; the native side swaps it
50// for the chip attachment either way.
51const IOS_CHIP_PLACEHOLDER = "\u200B";
52const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000;
53const MONO_FONT_FAMILY = Platform.select({
54 ios: "ui-monospace",
55 android: "monospace",
56 default: "monospace",
57});
58const styles = StyleSheet.create({
59 inlineIcon: {
60 width: 14,
61 height: 14,
62 marginHorizontal: 3,
63 transform: [{ translateY: 2 }],
64 },
65});
66
67function runKeySignature(run: NativeMarkdownTextRun): string {
68 return [
69 run.text,
70 run.bold,
71 run.italic,
72 run.strikethrough,
73 run.code,
74 run.href,
75 run.externalHost,
76 run.fileIcon,
77 run.skillName,
78 run.skillLabel,
79 run.sourceText,
80 run.role,
81 run.headingLevel,
82 run.depth,
83 run.spacing,
84 run.firstLineHeadIndent,
85 run.headIndent,
86 run.paragraphSpacing,
87 ].join(":");
88}
89
90const DEFAULT_BODY_FONT_SIZE = 15;
91const DEFAULT_HEADING_FONT_SIZES = [22, 19, 17, 16, 15, 15] as const;
92
93function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel: number): number {
94 const index = Math.max(0, Math.min(5, headingLevel - 1));
95 const configured = textStyle.headingFontSizes?.[index];
96 if (typeof configured === "number" && Number.isFinite(configured)) {
97 return configured;
98 }
99
100 const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE;
101 return Math.max(12, Math.round((DEFAULT_HEADING_FONT_SIZES[index] ?? 15) * scale));
102}
103
104function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle {
105 const isFile = run.fileIcon != null;
106 const isSkill = run.skillName != null;
107 const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1));
108 const headingFontSize = resolveHeadingFontSize(textStyle, headingLevel);
109 const isHeading = run.role === "heading";
110 const isCodeBlock = run.role === "code-block" || run.role === "code-language";
111 const hasParagraphStyle = run.headIndent !== undefined;
112 const textDecorationLine = run.strikethrough
113 ? "line-through"
114 : run.href && !isFile
115 ? "underline"
116 : "none";
117
118 return {
119 color: isFile
120 ? textStyle.fileTextColor
121 : isSkill
122 ? textStyle.skillTextColor
123 : run.href
124 ? textStyle.linkColor
125 : isHeading
126 ? textStyle.strongColor
127 : run.role === "quote-marker"
128 ? textStyle.quoteMarkerColor
129 : run.role === "divider"
130 ? textStyle.dividerColor
131 : run.role === "code-language"
132 ? textStyle.mutedColor
133 : run.role === "list-marker"
134 ? textStyle.mutedColor
135 : isCodeBlock
136 ? textStyle.codeColor
137 : run.code
138 ? textStyle.inlineCodeColor
139 : run.bold
140 ? textStyle.strongColor
141 : textStyle.color,
142 fontFamily:
143 isFile || isSkill
144 ? textStyle.boldFontFamily
145 : run.code || isCodeBlock
146 ? MONO_FONT_FAMILY
147 : isHeading
148 ? textStyle.headingFontFamily
149 : run.bold
150 ? textStyle.boldFontFamily
151 : textStyle.fontFamily,
152 fontSize:
153 run.role === "spacer"
154 ? (run.spacing ?? 10)
155 : run.role === "list-break"
156 ? textStyle.fontSize
157 : isHeading
158 ? headingFontSize
159 : run.role === "code-language"
160 ? Math.max(10, Math.round(textStyle.fontSize * 0.73))
161 : run.code || isCodeBlock
162 ? Math.max(12, textStyle.fontSize - 2)
163 : textStyle.fontSize,
164 lineHeight:
165 run.role === "spacer"
166 ? (run.spacing ?? 10)
167 : run.role === "list-break"
168 ? textStyle.lineHeight + (run.spacing ?? 0)
169 : isHeading
170 ? Math.max(headingFontSize + 6, textStyle.lineHeight + 2)
171 : isCodeBlock
172 ? Math.max(16, textStyle.lineHeight - 2)
173 : textStyle.lineHeight,
174 fontStyle: run.italic ? "italic" : "normal",
175 fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400",
176 textDecorationLine,
177 backgroundColor: isCodeBlock
178 ? textStyle.codeBlockBackgroundColor
179 : parseComposerContextHref(run.href ?? "")
180 ? textStyle.codeBackgroundColor
181 : undefined,
182 ...(hasParagraphStyle
183 ? {
184 shadowColor: "transparent",
185 shadowOffset: {
186 width: run.firstLineHeadIndent ?? 0,
187 height: run.headIndent,
188 },
189 shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0),
190 }
191 : {}),
192 };
193}
194
195export function NativeMarkdownSelectableText(props: {
196 readonly runs: ReadonlyArray<NativeMarkdownTextRun>;
197 readonly textStyle: NativeMarkdownTextStyle;
198 readonly onLinkPress?: (href: string) => void;
199}) {
200 const colorScheme = useColorScheme();
201 const menu = useContext(MarkdownFileContextMenuContext);
202 const contextClipboardFragment = useContext(MarkdownContextClipboardContext);
203 const contextRecords = useMemo(
204 () => decodeComposerContextFragment(contextClipboardFragment)?.records ?? [],
205 [contextClipboardFragment],
206 );
207 const containsInlineIcon = props.runs.some(
208 (run) =>
209 run.fileIcon != null ||
210 run.skillName != null ||
211 parseComposerContextHref(run.href ?? "") !== null ||
212 (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null),
213 );
214 const keyedRuns = useMemo(() => {
215 const occurrences = new Map<string, number>();
216 const prefixedExternalLinks = new Set<string>();
217 return props.runs.map((run) => {
218 const signature = runKeySignature(run);
219 const occurrence = occurrences.get(signature) ?? 0;
220 occurrences.set(signature, occurrence + 1);
221
222 let text = run.text;
223 let linkIcon = null;
224 const contextReference = parseComposerContextHref(run.href ?? "");
225 const contextRecord = contextReference
226 ? contextRecords.find((record) => record.contextId === contextReference.contextId)
227 : undefined;
228 const chip =
229 contextReference || run.skillName || run.fileIcon
230 ? {
231 ...contextChipPresentation(
232 contextReference?.kind ?? (run.skillName ? "skill" : "mention"),
233 contextRecord,
234 ),
235 label: run.skillLabel ?? run.text,
236 interactive: Boolean(run.href),
237 iconUri:
238 !contextReference && run.fileIcon
239 ? markdownIconAssetUri(markdownFileIconSource(run.fileIcon))
240 : contextRecord?.kind === "mention" && "path" in contextRecord
241 ? markdownIconAssetUri(
242 markdownFileIconSource(resolveMarkdownFileIcon(contextRecord.path)),
243 )
244 : undefined,
245 fontSize: props.textStyle.fontSize * 0.8,
246 foreground: props.textStyle.color,
247 border: props.textStyle.contextChipBorderColor ?? props.textStyle.dividerColor,
248 }
249 : null;
250 // Android sizes the chip's inline box from the paragraph font so the line box stays
251 // the height of a plain text line; see renderContextChip.
252 const androidChip =
253 Platform.OS === "android" && chip
254 ? renderAndroidContextChip(
255 JSON.stringify({
256 ...chip,
257 text: {
258 fontFamily: props.textStyle.fontFamily,
259 fontSize: props.textStyle.fontSize,
260 },
261 }),
262 )
263 : null;
264 if (androidChip) {
265 text = "";
266 } else if (chip && Platform.OS === "ios") {
267 text = IOS_CHIP_PLACEHOLDER;
268 } else if (run.fileIcon && Platform.OS === "ios") {
269 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
270 } else if (run.skillName && run.skillLabel) {
271 text =
272 Platform.OS === "ios"
273 ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`
274 : `$${run.skillName}`;
275 } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) {
276 prefixedExternalLinks.add(run.href);
277 linkIcon = resolveMarkdownLinkIcon(run.externalHost);
278 if (linkIcon === null) {
279 text = `${EXTERNAL_LINK_PREFIX}${text}`;
280 } else if (Platform.OS === "ios") {
281 text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
282 }
283 }
284
285 return { key: `${signature}:${occurrence}`, run, text, linkIcon, chip, androidChip };
286 });
287 }, [props.runs, props.textStyle, contextRecords]);
288 const ranges = nativeMarkdownContextCopyRanges(
289 keyedRuns.map(({ run, text, linkIcon, androidChip }) => ({
290 run,
291 text,
292 inlineImageLength:
293 Platform.OS === "android" && (androidChip || run.fileIcon || linkIcon) ? 1 : 0,
294 })),
295 );
296 const contextClipboardConfig = ranges.length
297 ? JSON.stringify({ fragment: contextClipboardFragment, ranges })
298 : "";
299 const attachAndroidText = useCallback(
300 (textView: TextInstance | null) => {
301 if (Platform.OS !== "android" || !containsInlineIcon || !textView) return;
302 const reactTag = findNodeHandle(textView);
303 if (typeof reactTag === "number") {
304 installMarkdownCopySanitizer(reactTag, contextClipboardConfig);
305 }
306 },
307 [containsInlineIcon, contextClipboardConfig],
308 );
309 // T3MarkdownText only rebuilds its attributed string during native layout. A
310 // color-only child update can otherwise leave the previous appearance cached.
311 const appearanceKey = [
312 colorScheme ?? "unspecified",
313 props.textStyle.fontSize,
314 props.textStyle.lineHeight,
315 props.textStyle.headingFontSizes?.join(","),
316 props.textStyle.color,
317 props.textStyle.strongColor,
318 props.textStyle.mutedColor,
319 props.textStyle.linkColor,
320 props.textStyle.inlineCodeColor,
321 props.textStyle.codeColor,
322 props.textStyle.codeBackgroundColor,
323 props.textStyle.codeBlockBackgroundColor,
324 props.textStyle.fileTextColor,
325 props.textStyle.skillTextColor,
326 props.textStyle.quoteMarkerColor,
327 props.textStyle.dividerColor,
328 props.textStyle.contextChipBorderColor,
329 ].join(":");
330
331 return (
332 <MarkdownTextPrimitive
333 key={appearanceKey}
334 nativeTextRef={attachAndroidText}
335 contextClipboardConfig={contextClipboardConfig}
336 accessibilityLabel={
337 Platform.OS === "android" && containsInlineIcon
338 ? props.runs.map((run) => run.skillLabel ?? run.text).join("")
339 : undefined
340 }
341 uiTextView
342 selectable
343 selectionColor={props.textStyle.selectionColor}
344 selectionHandleColor={props.textStyle.selectionHandleColor}
345 style={{
346 flexShrink: 1,
347 minWidth: 0,
348 color: props.textStyle.color,
349 fontFamily: props.textStyle.fontFamily,
350 fontSize: props.textStyle.fontSize,
351 lineHeight: props.textStyle.lineHeight,
352 }}
353 >
354 {keyedRuns.map(({ key, run, text, linkIcon, chip, androidChip }) => {
355 const href = run.href;
356 const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;
357 const onPress = href
358 ? () => {
359 if (props.onLinkPress) props.onLinkPress(href);
360 else void Linking.openURL(href);
361 }
362 : undefined;
363 const fileIconUri = run.fileIcon
364 ? markdownIconAssetUri(markdownFileIconSource(run.fileIcon))
365 : undefined;
366 const linkIconUri = linkIcon
367 ? markdownIconAssetUri(markdownLinkIconSource(linkIcon))
368 : undefined;
369 return (
370 <MarkdownTextPrimitive
371 key={key}
372 accessibilityLabel={androidChip ? chip?.label : undefined}
373 nativeID={
374 Platform.OS === "ios"
375 ? chip
376 ? `t3-chip:${JSON.stringify(chip)}`
377 : fileIconUri
378 ? `t3-file:${fileIconUri}`
379 : run.skillName
380 ? "t3-skill:sf:cube"
381 : linkIconUri
382 ? `t3-link:${linkIconUri}`
383 : undefined
384 : undefined
385 }
386 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
387 style={[
388 runStyle(run, props.textStyle),
389 chip ? { backgroundColor: "transparent" } : undefined,
390 ]}
391 onPress={onPress}
392 onContextMenuAction={
393 contextMenu && href && menu
394 ? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier)
395 : undefined
396 }
397 >
398 {androidChip ? (
399 // The inline box sits on the baseline and is only as tall as the font's
400 // ascent, so it never changes the line's height. The bitmap hangs off that
401 // box (views in text are not clipped) to centre the chip on the text.
402 <View
403 accessible
404 accessibilityLabel={chip?.label}
405 accessibilityRole={onPress ? "button" : "image"}
406 accessibilityActions={onPress ? [{ name: "activate" }] : undefined}
407 onAccessibilityAction={
408 onPress
409 ? (event) => {
410 if (event.nativeEvent.actionName === "activate") onPress();
411 }
412 : undefined
413 }
414 style={{ width: androidChip.width, height: androidChip.boxHeight }}
415 >
416 <Image
417 // The bitmap is measured in whole pixels but laid out in dp, so the box can
418 // round a hair narrower than the image. `cover` would crop that difference
419 // off the right-hand border; `contain` fits the whole chip instead.
420 resizeMode="contain"
421 source={{ uri: androidChip.uri }}
422 style={{
423 position: "absolute",
424 left: 0,
425 top: androidChip.offsetY,
426 width: androidChip.width,
427 height: androidChip.height,
428 }}
429 />
430 </View>
431 ) : Platform.OS === "android" && run.fileIcon ? (
432 <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} />
433 ) : Platform.OS === "android" && linkIcon ? (
434 <Image
435 source={markdownLinkIconSource(linkIcon)}
436 style={styles.inlineIcon}
437 tintColor={props.textStyle.linkColor}
438 />
439 ) : null}
440 {text}
441 </MarkdownTextPrimitive>
442 );
443 })}
444 </MarkdownTextPrimitive>
445 );
446}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx deleted-125
...@@ -1,125 +0,0 @@
1import { useMemo } from "react";
2import { View } from "react-native";
3import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
4
5import {
6 nativeMarkdownWithHtml,
7 nativeMarkdownChunkSpacing,
8 nativeMarkdownDocumentChunks,
9 nativeMarkdownDocumentRuns,
10 nativeMarkdownWithAuthoredWindowsPaths,
11 nativeMarkdownWithPreservedSoftBreaks,
12} from "./nativeMarkdownText";
13import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock";
14import {
15 MarkdownContextClipboardContext,
16 MarkdownFileContextMenuContext,
17 NativeMarkdownSelectableText,
18 type MarkdownFileContextMenuHandlers,
19} from "./NativeMarkdownSelectableText";
20import type {
21 SelectableMarkdownSkill,
22 SelectableMarkdownTextProps,
23} from "./SelectableMarkdownText.types";
24
25const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = [];
26
27export type {
28 MarkdownCodeHighlighter,
29 MarkdownHighlightedToken,
30 MarkdownImageRenderer,
31 MarkdownImageRequest,
32 NativeMarkdownTextStyle,
33 SelectableMarkdownSkill,
34 SelectableMarkdownTextProps,
35} from "./SelectableMarkdownText.types";
36
37export function hasNativeSelectableMarkdownText(): boolean {
38 return true;
39}
40
41export function SelectableMarkdownText({
42 markdown,
43 contextClipboardFragment,
44 skills = EMPTY_SKILLS,
45 textStyle,
46 highlightCode,
47 preserveSoftBreaks = false,
48 onLinkPress,
49 fileContextMenu,
50 onFileContextMenuAction,
51 renderImage,
52 marginTop = 0,
53 marginBottom = 0,
54}: SelectableMarkdownTextProps) {
55 const chunks = useMemo(() => {
56 const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths(
57 nativeMarkdownWithHtml(
58 parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }),
59 (content) => parseMarkdownWithOptions(content, { gfm: true, html: false, math: false }),
60 ),
61 markdown,
62 );
63 const document = preserveSoftBreaks
64 ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
65 : parsedDocument;
66 return nativeMarkdownDocumentChunks(document).map((chunk) =>
67 chunk.kind === "selectable"
68 ? {
69 ...chunk,
70 runs: nativeMarkdownDocumentRuns(chunk.node, skills),
71 }
72 : chunk,
73 );
74 }, [markdown, preserveSoftBreaks, skills]);
75
76 const fileContextMenuHandlers = useMemo<MarkdownFileContextMenuHandlers | null>(
77 () =>
78 fileContextMenu && onFileContextMenuAction
79 ? { fileContextMenu, onFileContextMenuAction }
80 : null,
81 [fileContextMenu, onFileContextMenuAction],
82 );
83
84 return (
85 <MarkdownContextClipboardContext.Provider value={contextClipboardFragment ?? ""}>
86 <MarkdownImageRendererContext.Provider value={renderImage ?? null}>
87 <MarkdownFileContextMenuContext.Provider value={fileContextMenuHandlers}>
88 {/* A percentage width here creates a cyclic intrinsic measurement inside
89 shrink-to-fit containers such as user-message bubbles. Yoga then gives
90 the native text node an unbounded second pass and the parent only clips
91 the resulting single-line width instead of reflowing it. */}
92 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
93 {chunks.map((chunk, index) => {
94 const content =
95 chunk.kind === "rich" ? (
96 <NativeMarkdownBlock
97 node={chunk.node}
98 skills={skills}
99 textStyle={textStyle}
100 highlightCode={highlightCode}
101 onLinkPress={onLinkPress}
102 />
103 ) : (
104 <NativeMarkdownSelectableText
105 runs={chunk.runs}
106 textStyle={textStyle}
107 onLinkPress={onLinkPress}
108 />
109 );
110
111 return (
112 <View
113 key={chunk.key}
114 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
115 >
116 {content}
117 </View>
118 );
119 })}
120 </View>
121 </MarkdownFileContextMenuContext.Provider>
122 </MarkdownImageRendererContext.Provider>
123 </MarkdownContextClipboardContext.Provider>
124 );
125}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts deleted-92
...@@ -1,92 +0,0 @@
1export interface NativeMarkdownTextStyle {
2 readonly selectionColor?: string;
3 readonly selectionHandleColor?: string;
4 readonly color: string;
5 readonly strongColor: string;
6 readonly mutedColor: string;
7 readonly linkColor: string;
8 readonly inlineCodeColor: string;
9 readonly codeColor: string;
10 readonly codeBackgroundColor: string;
11 readonly codeBlockBackgroundColor: string;
12 readonly fileTextColor: string;
13 readonly skillTextColor: string;
14 readonly quoteMarkerColor: string;
15 readonly dividerColor: string;
16 readonly contextChipBorderColor?: string;
17 readonly fontSize: number;
18 readonly lineHeight: number;
19 readonly fontFamily: string;
20 readonly headingFontFamily: string;
21 readonly boldFontFamily: string;
22 readonly headingFontSizes?: ReadonlyArray<number>;
23}
24
25export interface MarkdownHighlightedToken {
26 readonly content: string;
27 readonly color: string | null;
28 readonly fontStyle: number | null;
29}
30
31export interface MarkdownCodeHighlightInput {
32 /** Identity of the mounted code block, for incremental highlighting. */
33 readonly session?: object;
34 readonly code: string;
35 readonly language?: string | null;
36 readonly theme: "light" | "dark";
37}
38export interface MarkdownCodeHighlighter {
39 (
40 input: MarkdownCodeHighlightInput,
41 ): Promise<ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>>;
42 /** Optional synchronous result for a small append to an already warm block. */
43 read?: (
44 input: MarkdownCodeHighlightInput,
45 ) => ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>> | undefined;
46}
47
48export interface SelectableMarkdownSkill {
49 readonly name: string;
50 readonly displayName?: string | null;
51}
52
53export interface MarkdownImageRequest {
54 readonly href: string;
55 readonly alt: string | null;
56 readonly title: string | null;
57}
58
59/**
60 * App-supplied renderer for markdown images. The module cannot load
61 * workspace-relative image paths itself — the host app resolves them (for
62 * example through a signed asset URL) and returns the element to show.
63 * Returning null falls back to the module's plain remote-URI rendering.
64 */
65export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode;
66
67export interface MarkdownFileContextMenuAction {
68 readonly id: string;
69 readonly title: string;
70 readonly disabled?: boolean;
71}
72
73export interface MarkdownFileContextMenu {
74 readonly title?: string;
75 readonly actions: ReadonlyArray<MarkdownFileContextMenuAction>;
76}
77
78export interface SelectableMarkdownTextProps {
79 readonly markdown: string;
80 /** Opaque context payload supplied by the host for native selection copy. */
81 readonly contextClipboardFragment?: string;
82 readonly textStyle: NativeMarkdownTextStyle;
83 readonly highlightCode: MarkdownCodeHighlighter;
84 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
85 readonly preserveSoftBreaks?: boolean;
86 readonly onLinkPress?: (href: string) => void;
87 readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined;
88 readonly onFileContextMenuAction?: (href: string, actionId: string) => void;
89 readonly renderImage?: MarkdownImageRenderer;
90 readonly marginTop?: number;
91 readonly marginBottom?: number;
92}
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts deleted-50
...@@ -1,50 +0,0 @@
1import { codegenNativeComponent, type CodegenTypes, type ViewProps } from "react-native";
2
3interface TargetedEvent {
4 target: CodegenTypes.Int32;
5}
6
7interface TextLayoutEvent extends TargetedEvent {
8 lines: string[];
9}
10
11/**
12 * Event fired when text selection changes in the MarkdownTextPrimitive.
13 * @property target - The view tag identifier
14 * @property start - The start index of the selected range (0-based)
15 * @property end - The end index of the selected range (0-based, exclusive)
16 */
17interface SelectionChangeEvent extends TargetedEvent {
18 start: CodegenTypes.Int32;
19 end: CodegenTypes.Int32;
20}
21
22type EllipsizeMode = "head" | "middle" | "tail" | "clip";
23
24interface NativeProps extends ViewProps {
25 contextClipboardConfig?: string;
26 numberOfLines?: CodegenTypes.Int32;
27 allowFontScaling?: CodegenTypes.WithDefault<boolean, true>;
28 ellipsizeMode?: CodegenTypes.WithDefault<EllipsizeMode, "tail">;
29 selectable?: boolean;
30 onTextLayout?: CodegenTypes.BubblingEventHandler<TextLayoutEvent>;
31 /**
32 * Callback fired when the text selection changes.
33 *
34 * @example
35 * ```tsx
36 * <MarkdownTextPrimitive
37 * onSelectionChange={(event) => {
38 * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end);
39 * }}
40 * >
41 * Selectable text
42 * </MarkdownTextPrimitive>
43 * ```
44 */
45 onSelectionChange?: CodegenTypes.BubblingEventHandler<SelectionChangeEvent>;
46}
47
48export default codegenNativeComponent<NativeProps>("T3MarkdownText", {
49 excludedPlatforms: ["android"],
50});
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts deleted-55
...@@ -1,55 +0,0 @@
1import {
2 codegenNativeComponent,
3 type CodegenTypes,
4 type ColorValue,
5 type ViewProps,
6} from "react-native";
7
8interface TargetedEvent {
9 target: CodegenTypes.Int32;
10}
11
12interface ContextMenuActionEvent extends TargetedEvent {
13 actionIdentifier: string;
14}
15
16type TextDecorationLine = "none" | "underline" | "line-through";
17
18type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed";
19
20export type NativeFontWeight =
21 | "normal"
22 | "bold"
23 | "ultraLight"
24 | "light"
25 | "medium"
26 | "semibold"
27 | "heavy";
28
29type FontStyle = "normal" | "italic";
30
31type TextAlign = "auto" | "left" | "right" | "center" | "justify";
32
33interface NativeProps extends ViewProps {
34 text: string;
35 color?: ColorValue;
36 fontSize?: CodegenTypes.Float;
37 fontStyle?: CodegenTypes.WithDefault<FontStyle, "normal">;
38 fontWeight?: CodegenTypes.WithDefault<NativeFontWeight, "normal">;
39 fontFamily?: string;
40 letterSpacing?: CodegenTypes.Float;
41 lineHeight?: CodegenTypes.Float;
42 textDecorationLine?: CodegenTypes.WithDefault<TextDecorationLine, "none">;
43 textDecorationStyle?: CodegenTypes.WithDefault<TextDecorationStyle, "solid">;
44 textDecorationColor?: ColorValue;
45 textAlign?: CodegenTypes.WithDefault<TextAlign, "auto">;
46 shadowRadius?: CodegenTypes.WithDefault<CodegenTypes.Float, 0>;
47 contextMenuConfig?: string;
48 onPress?: CodegenTypes.BubblingEventHandler<TargetedEvent>;
49 onLongPress?: CodegenTypes.BubblingEventHandler<TargetedEvent>;
50 onContextMenuAction?: CodegenTypes.BubblingEventHandler<ContextMenuActionEvent>;
51}
52
53export default codegenNativeComponent<NativeProps>("T3MarkdownTextRun", {
54 excludedPlatforms: ["android"],
55});
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts deleted-30
...@@ -1,30 +0,0 @@
1import { requireOptionalNativeModule } from "expo";
2
3interface T3MarkdownTextSelectionNativeModule {
4 readonly setSelectionHandleColor?: (reactTag: number, color: number) => void;
5 readonly installCopySanitizer: (reactTag: number, contextClipboardConfig: string) => void;
6 readonly renderContextChip?: (payloadJson: string) => {
7 readonly uri: string;
8 readonly width: number;
9 readonly height: number;
10 /** Inline box height: the paragraph font's ascent, so the line box never grows. */
11 readonly boxHeight: number;
12 /** Bitmap top relative to the box top; negative when the chip overhangs the box. */
13 readonly offsetY: number;
14 } | null;
15}
16
17const nativeModule =
18 requireOptionalNativeModule<T3MarkdownTextSelectionNativeModule>("T3MarkdownTextSelection");
19
20export function installMarkdownCopySanitizer(reactTag: number, contextClipboardConfig = ""): void {
21 nativeModule?.installCopySanitizer(reactTag, contextClipboardConfig);
22}
23
24export function setMarkdownSelectionHandleColor(reactTag: number, color: number): void {
25 nativeModule?.setSelectionHandleColor?.(reactTag, color);
26}
27
28export function renderAndroidContextChip(payloadJson: string) {
29 return nativeModule?.renderContextChip?.(payloadJson) ?? null;
30}
apps/mobile/modules/t3-markdown-text/src/githubAlert.test.ts deleted-42
...@@ -1,42 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { MarkdownNode } from "react-native-nitro-markdown/headless";
3import { githubAlert } from "./githubAlert";
4
5const quote = (...children: MarkdownNode[]): MarkdownNode => ({
6 type: "blockquote",
7 children: [{ type: "paragraph", children }],
8});
9
10describe("GitHub alerts in native Markdown", () => {
11 it.each(["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"])(
12 "recognizes %s and retains formatted content",
13 (marker) => {
14 const bold: MarkdownNode = { type: "bold", children: [{ type: "text", content: "Details" }] };
15 const node = quote({ type: "text", content: `[!${marker}]` }, { type: "soft_break" }, bold);
16 const original = structuredClone(node);
17 const alert = githubAlert(node);
18 expect(alert?.title.toUpperCase()).toBe(marker);
19 expect(alert?.children[0]?.children).toEqual([bold]);
20 expect(node).toEqual(original);
21 },
22 );
23 it("handles text nodes containing the line break and following text", () => {
24 expect(
25 githubAlert(quote({ type: "text", content: "[!WARNING]\nKeep this text." }))?.children[0]
26 ?.children?.[0]?.content,
27 ).toBe("Keep this text.");
28 });
29 it("leaves inline markers, code, and ordinary quotes alone", () => {
30 expect(githubAlert(quote({ type: "text", content: "[!WARNING] inline" }))).toBeNull();
31 expect(
32 githubAlert(
33 quote(
34 { type: "text", content: "[!WARNING]" },
35 { type: "bold", children: [{ type: "text", content: "inline" }] },
36 ),
37 ),
38 ).toBeNull();
39 expect(githubAlert({ type: "code_block", content: "> [!WARNING]" })).toBeNull();
40 expect(githubAlert(quote({ type: "text", content: "Just a quote" }))).toBeNull();
41 });
42});
apps/mobile/modules/t3-markdown-text/src/githubAlert.ts deleted-52
...@@ -1,52 +0,0 @@
1import type { MarkdownNode } from "react-native-nitro-markdown/headless";
2
3const ALERTS = {
4 NOTE: { title: "Note", color: "#4493f8", lightColor: "#0969da", symbol: "info.circle" },
5 TIP: { title: "Tip", color: "#3fb950", lightColor: "#1a7f37", symbol: "lightbulb" },
6 IMPORTANT: {
7 title: "Important",
8 color: "#ab7df8",
9 lightColor: "#8250df",
10 symbol: "exclamationmark.bubble",
11 },
12 WARNING: {
13 title: "Warning",
14 color: "#d29922",
15 lightColor: "#9a6700",
16 symbol: "exclamationmark.triangle",
17 },
18 CAUTION: {
19 title: "Caution",
20 color: "#f85149",
21 lightColor: "#cf222e",
22 symbol: "exclamationmark.octagon",
23 },
24} as const;
25
26/** GitHub alerts are blockquotes whose first line contains only the alert marker. */
27export function githubAlert(node: MarkdownNode) {
28 if (node.type !== "blockquote") return null;
29 const paragraph = node.children?.[0];
30 const text = paragraph?.children?.[0];
31 if (paragraph?.type !== "paragraph" || text?.type !== "text") return null;
32 const match = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](\r?\n|$)/i.exec(text.content ?? "");
33 if (!match) return null;
34 const rest = (text.content ?? "").slice(match[0].length);
35 const siblings = paragraph.children?.slice(1) ?? [];
36 if (
37 !rest &&
38 !match[2] &&
39 siblings.length &&
40 !["soft_break", "line_break"].includes(siblings[0]!.type)
41 )
42 return null;
43 if (!rest && ["soft_break", "line_break"].includes(siblings[0]?.type ?? "")) siblings.shift();
44 const children = [...(rest ? [{ ...text, content: rest }] : []), ...siblings];
45 return {
46 ...ALERTS[match[1]!.toUpperCase() as keyof typeof ALERTS],
47 children: [
48 ...(children.length ? [{ ...paragraph, children }] : []),
49 ...(node.children?.slice(1) ?? []),
50 ],
51 };
52}
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts deleted-60
...@@ -1,60 +0,0 @@
1import type { ImageSourcePropType } from "react-native";
2
3export const MARKDOWN_FILE_ICON_SOURCES = {
4 agents: require("../assets/file-icons/pierre_agents.png"),
5 astro: require("../assets/file-icons/pierre_astro.png"),
6 babel: require("../assets/file-icons/pierre_babel.png"),
7 bash: require("../assets/file-icons/pierre_bash.png"),
8 biome: require("../assets/file-icons/pierre_biome.png"),
9 bootstrap: require("../assets/file-icons/pierre_bootstrap.png"),
10 browserslist: require("../assets/file-icons/pierre_browserslist.png"),
11 bun: require("../assets/file-icons/pierre_bun.png"),
12 c: require("../assets/file-icons/pierre_c.png"),
13 claude: require("../assets/file-icons/pierre_claude.png"),
14 cpp: require("../assets/file-icons/pierre_cpp.png"),
15 css: require("../assets/file-icons/pierre_css.png"),
16 database: require("../assets/file-icons/pierre_database.png"),
17 default: require("../assets/file-icons/pierre_default.png"),
18 docker: require("../assets/file-icons/pierre_docker.png"),
19 eslint: require("../assets/file-icons/pierre_eslint.png"),
20 font: require("../assets/file-icons/pierre_font.png"),
21 git: require("../assets/file-icons/pierre_git.png"),
22 go: require("../assets/file-icons/pierre_go.png"),
23 graphql: require("../assets/file-icons/pierre_graphql.png"),
24 html: require("../assets/file-icons/pierre_html.png"),
25 image: require("../assets/file-icons/pierre_image.png"),
26 javascript: require("../assets/file-icons/pierre_javascript.png"),
27 json: require("../assets/file-icons/pierre_json.png"),
28 markdown: require("../assets/file-icons/pierre_markdown.png"),
29 mcp: require("../assets/file-icons/pierre_mcp.png"),
30 nextjs: require("../assets/file-icons/pierre_nextjs.png"),
31 npm: require("../assets/file-icons/pierre_npm.png"),
32 oxc: require("../assets/file-icons/pierre_oxc.png"),
33 pnpm: require("../assets/file-icons/pierre_pnpm.png"),
34 postcss: require("../assets/file-icons/pierre_postcss.png"),
35 prettier: require("../assets/file-icons/pierre_prettier.png"),
36 python: require("../assets/file-icons/pierre_python.png"),
37 react: require("../assets/file-icons/pierre_react.png"),
38 ruby: require("../assets/file-icons/pierre_ruby.png"),
39 rust: require("../assets/file-icons/pierre_rust.png"),
40 sass: require("../assets/file-icons/pierre_sass.png"),
41 stylelint: require("../assets/file-icons/pierre_stylelint.png"),
42 svelte: require("../assets/file-icons/pierre_svelte.png"),
43 svg: require("../assets/file-icons/pierre_svg.png"),
44 svgo: require("../assets/file-icons/pierre_svgo.png"),
45 swift: require("../assets/file-icons/pierre_swift.png"),
46 table: require("../assets/file-icons/pierre_table.png"),
47 tailwind: require("../assets/file-icons/pierre_tailwind.png"),
48 terraform: require("../assets/file-icons/pierre_terraform.png"),
49 text: require("../assets/file-icons/pierre_text.png"),
50 typescript: require("../assets/file-icons/pierre_typescript.png"),
51 video: require("../assets/file-icons/pierre_video.png"),
52 vite: require("../assets/file-icons/pierre_vite.png"),
53 vscode: require("../assets/file-icons/pierre_vscode.png"),
54 vue: require("../assets/file-icons/pierre_vue.png"),
55 wasm: require("../assets/file-icons/pierre_wasm.png"),
56 webpack: require("../assets/file-icons/pierre_webpack.png"),
57 yml: require("../assets/file-icons/pierre_yml.png"),
58 zig: require("../assets/file-icons/pierre_zig.png"),
59 zip: require("../assets/file-icons/pierre_zip.png"),
60} as const satisfies Readonly<Record<string, ImageSourcePropType>>;
apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts deleted-17
...@@ -1,17 +0,0 @@
1import { Image, type ImageSourcePropType } from "react-native";
2
3import type { MarkdownFileIcon } from "./markdownLinks";
4import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
5
6export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType {
7 return MARKDOWN_FILE_ICON_SOURCES[icon];
8}
9
10/**
11 * Bundled icon URI handed to native views that draw the icon themselves. The
12 * sources are static requires, so this only comes back undefined when the
13 * bundler dropped the asset; callers treat a missing icon as "no icon".
14 */
15export function markdownIconAssetUri(source: ImageSourcePropType): string | undefined {
16 return Image.resolveAssetSource(source)?.uri;
17}
apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts deleted-12
...@@ -1,12 +0,0 @@
1import type { ImageSourcePropType } from "react-native";
2
3import type { MarkdownLinkIcon } from "./markdownLinks";
4
5// Black-on-transparent marks; callers tint them with the link color.
6const MARKDOWN_LINK_ICON_SOURCES = {
7 github: require("../assets/link-icons/github.png"),
8} as const satisfies Readonly<Record<MarkdownLinkIcon, ImageSourcePropType>>;
9
10export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType {
11 return MARKDOWN_LINK_ICON_SOURCES[icon];
12}
apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts deleted-313
...@@ -1,313 +0,0 @@
1import { fileBasename } from "@t3tools/shared/path";
2import { formatFilePathPosition } from "@t3tools/shared/fileLinks";
3import {
4 inlineCodeFilePathCandidate,
5 normalizeMarkdownLinkDestination,
6 parseMarkdownFileLink,
7} from "@t3tools/shared/markdownLinks";
8import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
9import { videoMimeType } from "@t3tools/shared/video";
10
11import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
12
13const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
14
15export type MarkdownLinkPresentation =
16 | {
17 readonly kind: "external";
18 readonly href: string;
19 readonly host: string;
20 }
21 | {
22 readonly kind: "file";
23 readonly href: string;
24 readonly icon: MarkdownFileIcon;
25 readonly label: string;
26 readonly path: string;
27 readonly line?: number;
28 readonly column?: number;
29 }
30 | {
31 readonly kind: "link";
32 readonly href: string | null;
33 };
34
35export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES;
36
37export type MarkdownLinkIcon = "github";
38
39/**
40 * Sites whose brand mark replaces the generic external-link glyph. The marks
41 * are monochrome and tinted with the link color, so they follow the theme.
42 */
43export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null {
44 const hostname = host.toLowerCase();
45 if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github";
46 return null;
47}
48
49const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {
50 ".babelrc": "babel",
51 ".babelrc.json": "babel",
52 ".bash_profile": "bash",
53 ".bashrc": "bash",
54 ".browserslistrc": "browserslist",
55 ".dockerignore": "docker",
56 ".eslintignore": "eslint",
57 ".eslintrc": "eslint",
58 ".eslintrc.cjs": "eslint",
59 ".eslintrc.js": "eslint",
60 ".eslintrc.json": "eslint",
61 ".eslintrc.yaml": "eslint",
62 ".eslintrc.yml": "eslint",
63 ".gitattributes": "git",
64 ".gitignore": "git",
65 ".gitkeep": "git",
66 ".gitmodules": "git",
67 ".oxlintrc.json": "oxc",
68 ".postcssrc": "postcss",
69 ".postcssrc.json": "postcss",
70 ".postcssrc.yaml": "postcss",
71 ".postcssrc.yml": "postcss",
72 ".prettierignore": "prettier",
73 ".prettierrc": "prettier",
74 ".prettierrc.json": "prettier",
75 ".prettierrc.cjs": "prettier",
76 ".prettierrc.js": "prettier",
77 ".prettierrc.mjs": "prettier",
78 ".prettierrc.toml": "prettier",
79 ".prettierrc.yaml": "prettier",
80 ".prettierrc.yml": "prettier",
81 ".stylelintignore": "stylelint",
82 ".stylelintrc": "stylelint",
83 ".stylelintrc.cjs": "stylelint",
84 ".stylelintrc.js": "stylelint",
85 ".stylelintrc.json": "stylelint",
86 ".stylelintrc.mjs": "stylelint",
87 ".stylelintrc.yaml": "stylelint",
88 ".stylelintrc.yml": "stylelint",
89 ".terraform.lock.hcl": "terraform",
90 ".zprofile": "bash",
91 ".zshenv": "bash",
92 ".zshrc": "bash",
93 "agents.md": "agents",
94 "babel.config.js": "babel",
95 "babel.config.cjs": "babel",
96 "babel.config.json": "babel",
97 "babel.config.mjs": "babel",
98 "biome.json": "biome",
99 "biome.jsonc": "biome",
100 "bun.lock": "bun",
101 "bun.lockb": "bun",
102 "bunfig.toml": "bun",
103 "claude.md": "claude",
104 "compose.yaml": "docker",
105 "compose.yml": "docker",
106 "docker-compose.yaml": "docker",
107 "docker-compose.yml": "docker",
108 "docker-compose.override.yml": "docker",
109 dockerfile: "docker",
110 "eslint.config.js": "eslint",
111 "eslint.config.cjs": "eslint",
112 "eslint.config.mjs": "eslint",
113 "eslint.config.mts": "eslint",
114 "eslint.config.ts": "eslint",
115 gemfile: "ruby",
116 "next.config.js": "nextjs",
117 "next.config.mjs": "nextjs",
118 "next.config.mts": "nextjs",
119 "next.config.ts": "nextjs",
120 "package.json": "npm",
121 "pnpm-lock.yaml": "pnpm",
122 "pnpm-workspace.yaml": "pnpm",
123 "postcss.config.js": "postcss",
124 "postcss.config.cjs": "postcss",
125 "postcss.config.mjs": "postcss",
126 "postcss.config.ts": "postcss",
127 "prettier.config.js": "prettier",
128 "prettier.config.cjs": "prettier",
129 "prettier.config.mjs": "prettier",
130 rakefile: "ruby",
131 "readme.md": "markdown",
132 "stylelint.config.js": "stylelint",
133 "stylelint.config.cjs": "stylelint",
134 "stylelint.config.mjs": "stylelint",
135 "svgo.config.js": "svgo",
136 "svgo.config.cjs": "svgo",
137 "svgo.config.mjs": "svgo",
138 "svgo.config.ts": "svgo",
139 "tailwind.config.js": "tailwind",
140 "tailwind.config.cjs": "tailwind",
141 "tailwind.config.mjs": "tailwind",
142 "tailwind.config.ts": "tailwind",
143 "tsconfig.json": "typescript",
144 "vite.config.js": "vite",
145 "vite.config.mjs": "vite",
146 "vite.config.mts": "vite",
147 "vite.config.ts": "vite",
148 "webpack.config.js": "webpack",
149 "webpack.config.babel.js": "webpack",
150 "webpack.config.cjs": "webpack",
151 "webpack.config.mjs": "webpack",
152 "webpack.config.ts": "webpack",
153};
154
155const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = {
156 "7z": "zip",
157 astro: "astro",
158 avif: "image",
159 "code-workspace": "vscode",
160 bash: "bash",
161 bmp: "image",
162 bz2: "zip",
163 c: "c",
164 cc: "cpp",
165 cpp: "cpp",
166 cxx: "cpp",
167 css: "css",
168 csv: "table",
169 cts: "typescript",
170 db: "database",
171 env: "text",
172 "env.development": "text",
173 "env.local": "text",
174 "env.production": "text",
175 eot: "font",
176 erb: "ruby",
177 fish: "bash",
178 gif: "image",
179 go: "go",
180 gql: "graphql",
181 graphql: "graphql",
182 gz: "zip",
183 h: "c",
184 hh: "cpp",
185 hpp: "cpp",
186 hxx: "cpp",
187 htm: "html",
188 html: "html",
189 ico: "image",
190 icns: "image",
191 ini: "text",
192 inl: "cpp",
193 jar: "zip",
194 jpeg: "image",
195 jpg: "image",
196 js: "javascript",
197 jsx: "react",
198 json: "json",
199 jsonc: "json",
200 less: "css",
201 md: "markdown",
202 mdx: "markdown",
203 "mdx.tsx": "markdown",
204 mjs: "javascript",
205 mts: "typescript",
206 png: "image",
207 postcss: "css",
208 py: "python",
209 pyi: "python",
210 pyw: "python",
211 pyx: "python",
212 rake: "ruby",
213 rar: "zip",
214 rb: "ruby",
215 rs: "rust",
216 sass: "sass",
217 scss: "sass",
218 sh: "bash",
219 sql: "database",
220 sqlite: "database",
221 sqlite3: "database",
222 svelte: "svelte",
223 svg: "svg",
224 swift: "swift",
225 tar: "zip",
226 tf: "terraform",
227 tfstate: "terraform",
228 tfvars: "terraform",
229 tgz: "zip",
230 ts: "typescript",
231 tsv: "table",
232 tsx: "react",
233 txt: "text",
234 woff: "font",
235 woff2: "font",
236 vue: "vue",
237 wasm: "wasm",
238 webp: "image",
239 yml: "yml",
240 yaml: "yml",
241 zig: "zig",
242 zip: "zip",
243 zsh: "bash",
244};
245
246/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */
247export function normalizeNativeMarkdownUrl(value: string): string {
248 return value.startsWith("//") ? `https:${value}` : value;
249}
250
251export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
252 const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
253 if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video";
254 const exactIcon = FILE_ICON_BY_NAME[basename];
255 if (exactIcon) return exactIcon;
256 if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {
257 return "typescript";
258 }
259 const segments = basename.split(".");
260 for (let index = 1; index < segments.length; index += 1) {
261 const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")];
262 if (icon) return icon;
263 }
264 return "default";
265}
266
267export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
268 const normalized = normalizeMarkdownLinkDestination(href);
269 try {
270 const parsed = new URL(normalizeNativeMarkdownUrl(normalized));
271 if (parsed.protocol === "http:" || parsed.protocol === "https:") {
272 return {
273 kind: "external",
274 href: parsed.toString(),
275 host: parsed.hostname,
276 };
277 }
278 } catch {
279 // Relative paths and non-URL link destinations are handled below.
280 }
281
282 const target = parseMarkdownFileLink(normalized);
283 if (target) {
284 return {
285 kind: "file",
286 href: normalized,
287 icon: resolveMarkdownFileIcon(target.path),
288 label: fileBasename(formatFilePathPosition(target)),
289 path: target.path,
290 ...(target.line ? { line: target.line } : {}),
291 ...(target.column ? { column: target.column } : {}),
292 };
293 }
294
295 return {
296 kind: "link",
297 // A thread link keeps its href so a press reaches the feed, which opens the thread.
298 href:
299 /^(?:mailto|tel):/i.test(normalized) || parseThreadLinkHref(normalized) !== null
300 ? normalized
301 : null,
302 };
303}
304
305/** Backticks become file references only when the shared path heuristic recognizes the whole span. */
306export function resolveMarkdownInlineCodePresentation(
307 content: string,
308): Extract<MarkdownLinkPresentation, { readonly kind: "file" }> | null {
309 const candidate = inlineCodeFilePathCandidate(content);
310 if (candidate === null) return null;
311 const presentation = resolveMarkdownLinkPresentation(candidate);
312 return presentation.kind === "file" ? presentation : null;
313}
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts deleted-1159
...@@ -1,1159 +0,0 @@
1import type { MarkdownNode } from "react-native-nitro-markdown/headless";
2import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
3import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
4import { imageMimeType } from "@t3tools/shared/image";
5import { isWindowsAbsolutePath } from "@t3tools/shared/path";
6import { videoMimeType } from "@t3tools/shared/video";
7/**
8 * Every accent shares a lightness so no kind reads heavier than another; only hue carries
9 * identity. These are the sRGB form of the same OKLCH set web uses, so a chip looks the
10 * same on every surface. See `composerInlineChip.ts`.
11 */
12const CONTEXT_CHIP_PRESENTATIONS = {
13 image: { accent: "#d55665", symbol: "photo" },
14 video: { accent: "#d06217", symbol: "play.rectangle" },
15 file: { accent: "#0090cd", symbol: "doc" },
16 mention: { accent: "#0096af", symbol: "doc" },
17 terminal: { accent: "#009f6e", symbol: "terminal" },
18 element: { accent: "#b87501", symbol: "cursorarrow.click" },
19 "preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" },
20 "review-comment": { accent: "#8a70dd", symbol: "text.bubble" },
21 "pull-request": { accent: "#7079e4", symbol: "git-pull-request" },
22 skill: { accent: "#b261be", symbol: "cube" },
23 thread: { accent: "#009c96", symbol: "text.bubble" },
24} as const;
25
26/**
27 * The size an attachment chip reports beside its name, matching web. Rendered as its own
28 * smaller run, so it carries no separator. Only attachment-backed records have bytes.
29 */
30export function composerChipSizeSuffix(record?: {
31 readonly kind?: string;
32 readonly sizeBytes?: number;
33}): string {
34 if (record?.kind !== "file" && record?.kind !== "image") return "";
35 return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : "";
36}
37
38/**
39 * A pull request chip is coloured by what the pull request *is*, the way web colours it and
40 * the way the forge itself does: green open, grey draft, purple merged, red closed. The glyph
41 * stays the same across all four, as it does on web — state is carried by colour alone.
42 */
43const PULL_REQUEST_CHIP_PRESENTATIONS = {
44 open: { accent: "#009f6e", symbol: "git-pull-request" },
45 draft: { accent: "#7f8793", symbol: "git-pull-request" },
46 merged: { accent: "#8a70dd", symbol: "git-pull-request" },
47 closed: { accent: "#d55665", symbol: "git-pull-request" },
48} as const;
49
50export function contextChipPresentation(
51 kind: string,
52 record?: {
53 readonly kind?: string;
54 readonly name?: string;
55 readonly mimeType?: string;
56 readonly sectionId?: string;
57 readonly pullRequest?: {
58 readonly state?: string;
59 readonly isDraft?: boolean;
60 };
61 },
62) {
63 const presentationKind =
64 kind === "file" &&
65 videoMimeType({
66 name: record?.name ?? "",
67 mimeType: record?.mimeType ?? "",
68 })
69 ? "video"
70 : // A picture chosen through the file picker is typed `file`, but it is still a
71 // picture: it reads as one to the user and should not wear the generic file chip.
72 kind === "file" &&
73 imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null
74 ? "image"
75 : kind === "review-comment" && record?.sectionId?.startsWith("pull-request:")
76 ? "pull-request"
77 : kind;
78 if (presentationKind === "pull-request") {
79 const pullRequest = record?.pullRequest;
80 const state =
81 pullRequest?.state === "open" && pullRequest.isDraft === true
82 ? "draft"
83 : (pullRequest?.state ?? "");
84 if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) {
85 return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS];
86 }
87 }
88 return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind)
89 ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS]
90 : CONTEXT_CHIP_PRESENTATIONS.file;
91}
92import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
93import {
94 formatComposerContextReference,
95 parseComposerContextHref,
96} from "@t3tools/shared/composerContextReferences";
97
98/** Native selections count UTF-16 display units, including each inline image placeholder. */
99export function nativeMarkdownContextCopyRanges(
100 runs: ReadonlyArray<{
101 readonly run: {
102 readonly href?: string;
103 readonly text: string;
104 readonly skillName?: string;
105 readonly fileIcon?: string;
106 readonly sourceText?: string;
107 };
108 readonly text: string;
109 readonly inlineImageLength: number;
110 }>,
111) {
112 let offset = 0;
113 const ranges: Array<{ start: number; end: number; text: string }> = [];
114 for (const [index, { run, text, inlineImageLength }] of runs.entries()) {
115 const start = offset;
116 offset += text.length + inlineImageLength;
117 const reference = parseComposerContextHref(run.href ?? "");
118 const source = reference
119 ? formatComposerContextReference({ ...reference, label: run.text })
120 : run.skillName
121 ? `$${run.skillName}`
122 : run.sourceText !== undefined
123 ? run.sourceText
124 : run.fileIcon && run.href
125 ? `[${run.text}](<${run.href}>)`
126 : null;
127 if (source === null) continue;
128 const previous = ranges.at(-1);
129 if (
130 run.sourceText !== undefined &&
131 !runs[index - 1]?.run.fileIcon &&
132 previous?.end === start &&
133 previous.text === source
134 ) {
135 previous.end = offset;
136 } else {
137 ranges.push({ start, end: offset, text: source });
138 }
139 }
140 return ranges;
141}
142
143import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
144import {
145 resolveMarkdownInlineCodePresentation,
146 resolveMarkdownLinkPresentation,
147 type MarkdownFileIcon,
148} from "./markdownLinks";
149
150export interface NativeMarkdownTextRun {
151 readonly text: string;
152 readonly bold?: boolean;
153 readonly italic?: boolean;
154 readonly strikethrough?: boolean;
155 readonly code?: boolean;
156 readonly href?: string;
157 readonly externalHost?: string;
158 readonly fileIcon?: MarkdownFileIcon;
159 readonly skillName?: string;
160 readonly skillLabel?: string;
161 readonly sourceText?: string;
162 readonly role?:
163 | "body"
164 | "heading"
165 | "list-marker"
166 | "list-break"
167 | "quote-marker"
168 | "code-block"
169 | "code-language"
170 | "divider"
171 | "spacer";
172 readonly headingLevel?: number;
173 readonly depth?: number;
174 readonly spacing?: number;
175 readonly firstLineHeadIndent?: number;
176 readonly headIndent?: number;
177 readonly paragraphSpacing?: number;
178}
179
180export type NativeMarkdownDocumentChunk =
181 | {
182 readonly kind: "selectable";
183 readonly key: string;
184 readonly node: MarkdownNode;
185 }
186 | {
187 readonly kind: "rich";
188 readonly key: string;
189 readonly node: MarkdownNode;
190 };
191
192interface RunContext {
193 readonly bold: boolean;
194 readonly italic: boolean;
195 readonly strikethrough: boolean;
196 readonly code: boolean;
197 readonly href?: string;
198 readonly externalHost?: string;
199 readonly fileIcon?: MarkdownFileIcon;
200 readonly sourceText?: string;
201 readonly role?: NativeMarkdownTextRun["role"];
202 readonly headingLevel?: number;
203 readonly depth?: number;
204 readonly spacing?: number;
205 readonly firstLineHeadIndent?: number;
206 readonly headIndent?: number;
207 readonly paragraphSpacing?: number;
208}
209
210const EMPTY_CONTEXT: RunContext = {
211 bold: false,
212 italic: false,
213 strikethrough: false,
214 code: false,
215};
216
217const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi;
218
219function decodeCodePoint(codePoint: number, entity: string): string {
220 if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
221 return entity;
222 }
223 return String.fromCodePoint(codePoint);
224}
225
226function decodeHtmlEntitiesOnce(value: string): string {
227 return value.replace(
228 /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi,
229 (entity, decimal: string | undefined, hexadecimal: string | undefined) => {
230 if (decimal) {
231 return decodeCodePoint(Number.parseInt(decimal, 10), entity);
232 }
233 if (hexadecimal) {
234 return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity);
235 }
236 switch (entity.toLowerCase()) {
237 case "&amp;":
238 return "&";
239 case "&apos;":
240 return "'";
241 case "&gt;":
242 return ">";
243 case "&lt;":
244 return "<";
245 case "&nbsp;":
246 return "\u00a0";
247 case "&quot;":
248 return '"';
249 default:
250 return entity;
251 }
252 },
253 );
254}
255
256function decodeHtmlEntities(value: string): string {
257 let decoded = value;
258 for (let pass = 0; pass < 2; pass += 1) {
259 const next = decodeHtmlEntitiesOnce(decoded);
260 if (next === decoded) {
261 break;
262 }
263 decoded = next;
264 }
265 return decoded;
266}
267
268function textNodeContent(value: string): string {
269 return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, "");
270}
271
272function inlineHtmlText(value: string): string {
273 if (/^<br\s*\/?>$/i.test(value.trim())) {
274 return "\n";
275 }
276 return decodeHtmlEntities(value.replace(/<[^>]+>/g, ""));
277}
278
279function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean {
280 return (
281 left.bold === right.bold &&
282 left.italic === right.italic &&
283 left.strikethrough === right.strikethrough &&
284 left.code === right.code &&
285 left.href === right.href &&
286 left.externalHost === right.externalHost &&
287 left.fileIcon === right.fileIcon &&
288 left.sourceText === right.sourceText &&
289 left.skillName === right.skillName &&
290 left.skillLabel === right.skillLabel &&
291 left.role === right.role &&
292 left.headingLevel === right.headingLevel &&
293 left.depth === right.depth &&
294 left.spacing === right.spacing &&
295 left.firstLineHeadIndent === right.firstLineHeadIndent &&
296 left.headIndent === right.headIndent &&
297 left.paragraphSpacing === right.paragraphSpacing
298 );
299}
300
301function appendRun(
302 runs: NativeMarkdownTextRun[],
303 text: string,
304 context: RunContext,
305): NativeMarkdownTextRun[] {
306 if (text.length === 0) {
307 return runs;
308 }
309
310 const run: NativeMarkdownTextRun = {
311 text,
312 ...(context.bold ? { bold: true } : {}),
313 ...(context.italic ? { italic: true } : {}),
314 ...(context.strikethrough ? { strikethrough: true } : {}),
315 ...(context.code ? { code: true } : {}),
316 ...(context.href ? { href: context.href } : {}),
317 ...(context.externalHost ? { externalHost: context.externalHost } : {}),
318 ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}),
319 ...(context.sourceText !== undefined ? { sourceText: context.sourceText } : {}),
320 ...(context.role ? { role: context.role } : {}),
321 ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}),
322 ...(context.depth ? { depth: context.depth } : {}),
323 ...(context.spacing ? { spacing: context.spacing } : {}),
324 ...(context.firstLineHeadIndent !== undefined
325 ? { firstLineHeadIndent: context.firstLineHeadIndent }
326 : {}),
327 ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}),
328 ...(context.paragraphSpacing !== undefined
329 ? { paragraphSpacing: context.paragraphSpacing }
330 : {}),
331 };
332 const previous = runs.at(-1);
333 if (previous && sameRunStyle(previous, run)) {
334 runs[runs.length - 1] = { ...previous, text: previous.text + run.text };
335 return runs;
336 }
337
338 runs.push(run);
339 return runs;
340}
341
342const SKILL_TOKEN_REGEX =
343 /(^|\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;
344
345function formatSkillLabel(skill: SelectableMarkdownSkill): string {
346 const displayName = skill.displayName?.trim();
347 if (displayName) {
348 return displayName;
349 }
350 return skill.name
351 .split(/[\s:_-]+/)
352 .filter(Boolean)
353 .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
354 .join(" ");
355}
356
357function decorateSkillRuns(
358 runs: ReadonlyArray<NativeMarkdownTextRun>,
359 skills: ReadonlyArray<SelectableMarkdownSkill>,
360): ReadonlyArray<NativeMarkdownTextRun> {
361 if (skills.length === 0) {
362 return runs;
363 }
364 const skillByName = new Map(skills.map((skill) => [skill.name, skill]));
365 const decorated: NativeMarkdownTextRun[] = [];
366
367 for (const run of runs) {
368 if (run.code || run.href || run.fileIcon || run.role === "code-block") {
369 decorated.push(run);
370 continue;
371 }
372
373 let cursor = 0;
374 let matched = false;
375 for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) {
376 const prefix = match[1] ?? "";
377 const name = match[2] ?? "";
378 const skill = skillByName.get(name);
379 if (!skill) {
380 continue;
381 }
382 const start = (match.index ?? 0) + prefix.length;
383 const end = (match.index ?? 0) + match[0].length;
384 if (start > cursor) {
385 decorated.push({ ...run, text: run.text.slice(cursor, start) });
386 }
387 decorated.push({
388 ...run,
389 text: run.text.slice(start, end),
390 skillName: name,
391 skillLabel: formatSkillLabel(skill),
392 });
393 cursor = end;
394 matched = true;
395 }
396 if (!matched) {
397 decorated.push(run);
398 } else if (cursor < run.text.length) {
399 decorated.push({ ...run, text: run.text.slice(cursor) });
400 }
401 }
402
403 return decorated;
404}
405
406function decorateMentionRuns(runs: ReadonlyArray<NativeMarkdownTextRun>) {
407 return runs.flatMap((run) => {
408 if (run.code || run.href || run.skillName || run.role === "code-block") return [run];
409 const decorated: NativeMarkdownTextRun[] = [];
410 let cursor = 0;
411 for (const token of collectComposerInlineTokens(`${run.text} `)) {
412 if (token.type !== "mention" || !token.source.startsWith("@")) continue;
413 // Sentence punctuation is not part of an unquoted file reference.
414 const path = token.source.startsWith('@"')
415 ? token.value
416 : token.value.replace(/[.,;!?]+$/, "");
417 const presentation = resolveMarkdownLinkPresentation(path);
418 if (presentation.kind !== "file") continue;
419 const end = token.end - (token.value.length - path.length);
420 if (token.start > cursor)
421 decorated.push({ ...run, text: run.text.slice(cursor, token.start) });
422 decorated.push({
423 ...run,
424 text: presentation.label,
425 href: presentation.href,
426 fileIcon: presentation.icon,
427 sourceText: run.text.slice(token.start, end),
428 });
429 cursor = end;
430 }
431 if (cursor < run.text.length) decorated.push({ ...run, text: run.text.slice(cursor) });
432 return decorated;
433 });
434}
435
436function appendChildren(
437 runs: NativeMarkdownTextRun[],
438 node: MarkdownNode,
439 context: RunContext,
440): NativeMarkdownTextRun[] {
441 for (const child of node.children ?? []) {
442 appendNode(runs, child, context);
443 }
444 return runs;
445}
446
447function nodeTextContent(node: MarkdownNode): string {
448 if (node.content !== undefined) {
449 return node.content;
450 }
451 return (node.children ?? []).map(nodeTextContent).join("");
452}
453
454function fileLinkLabelMarkdown(node: MarkdownNode): string {
455 const children = (node.children ?? []).map(fileLinkLabelMarkdown).join("");
456 switch (node.type) {
457 case "bold":
458 return `**${children}**`;
459 case "italic":
460 return `*${children}*`;
461 case "strikethrough":
462 return `~~${children}~~`;
463 case "code_inline": {
464 const content = nodeTextContent(node);
465 const fence = "`".repeat(
466 Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + 1,
467 );
468 const padding = /^`|`$|^ .* $/.test(content) && content.trim().length > 0 ? " " : "";
469 return `${fence}${padding}${content}${padding}${fence}`;
470 }
471 case "soft_break":
472 return "\n";
473 case "line_break":
474 return " \n";
475 case "text":
476 return textNodeContent(nodeTextContent(node)).replace(/[\\[\]*_`~]/g, "\\$&");
477 case "image":
478 return `![${(node.alt ?? "").replace(/[\\[\]]/g, "\\$&")}](<${node.href ?? ""}>)`;
479 default:
480 return node.content ?? children;
481 }
482}
483
484function appendNode(
485 runs: NativeMarkdownTextRun[],
486 node: MarkdownNode,
487 context: RunContext,
488): NativeMarkdownTextRun[] {
489 switch (node.type) {
490 case "text":
491 case "math_inline":
492 return appendRun(runs, textNodeContent(nodeTextContent(node)), context);
493 case "html_inline":
494 return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context);
495 case "code_inline": {
496 const content = nodeTextContent(node);
497 const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content);
498 return presentation
499 ? appendRun(runs, presentation.label, {
500 ...context,
501 href: presentation.href,
502 fileIcon: presentation.icon,
503 })
504 : appendRun(runs, content, { ...context, code: true });
505 }
506 case "soft_break":
507 return appendRun(runs, " ", context);
508 case "line_break":
509 return appendRun(runs, "\n", context);
510 case "bold":
511 return appendChildren(runs, node, { ...context, bold: true });
512 case "italic":
513 return appendChildren(runs, node, { ...context, italic: true });
514 case "strikethrough":
515 return appendChildren(runs, node, { ...context, strikethrough: true });
516 case "link": {
517 const reference = parseComposerContextHref(node.href ?? "");
518 if (reference) {
519 // Build the link in isolation: adjacent links with the same href and
520 // style would otherwise merge into one run, collapsing two chips and
521 // their copy ranges into a single reference with a combined label.
522 const referenceRuns: NativeMarkdownTextRun[] = [];
523 appendChildren(referenceRuns, node, {
524 ...context,
525 href: node.href,
526 fileIcon:
527 reference.kind === "image" ? "image" : reference.kind === "terminal" ? "bash" : "text",
528 });
529 runs.push(...referenceRuns);
530 return runs;
531 }
532 const presentation = resolveMarkdownLinkPresentation(node.href ?? "");
533 if (presentation.kind === "file") {
534 const label = textNodeContent(nodeTextContent(node));
535 if (!isMarkdownFileLinkLabel(label, presentation.href)) {
536 const fileRuns: NativeMarkdownTextRun[] = [];
537 const fileContext = {
538 ...context,
539 href: presentation.href,
540 sourceText: `[${fileLinkLabelMarkdown(node)}](<${presentation.href}>)`,
541 };
542 appendChildren(fileRuns, node, fileContext);
543 appendRun(fileRuns, " ", fileContext);
544 appendRun(fileRuns, presentation.label, {
545 ...fileContext,
546 fileIcon: presentation.icon,
547 });
548 runs.push(...fileRuns);
549 return runs;
550 }
551 return appendRun(runs, presentation.label, {
552 ...context,
553 href: presentation.href,
554 fileIcon: presentation.icon,
555 });
556 }
557 if (presentation.kind === "external") {
558 return appendChildren(runs, node, {
559 ...context,
560 href: presentation.href,
561 externalHost: presentation.host,
562 });
563 }
564 return appendChildren(runs, node, {
565 ...context,
566 ...(presentation.href ? { href: presentation.href } : {}),
567 });
568 }
569 case "image":
570 return appendRun(runs, node.alt ?? node.title ?? "", context);
571 default:
572 return appendChildren(runs, node, context);
573 }
574}
575
576export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray<NativeMarkdownTextRun> {
577 return appendChildren([], node, EMPTY_CONTEXT);
578}
579
580export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): MarkdownNode {
581 const children = node.children?.map(nativeMarkdownWithPreservedSoftBreaks);
582 return {
583 ...node,
584 ...(node.type === "soft_break" ? { type: "line_break" as const } : {}),
585 ...(children ? { children } : {}),
586 };
587}
588
589const WINDOWS_DESTINATION_PATTERN = /\](?:\(|:)\s*<?((?:[A-Za-z]:|\\\\)(?:\\.|[^\s()<>\\])*)/g;
590const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/g;
591
592/**
593 * md4c reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link destination.
594 * Every backslash in a Windows path is a separator, so link and image paths go back to
595 * the destination as written in `markdown`. A parsed path that more than one written
596 * destination could have produced stays as parsed.
597 */
598export function nativeMarkdownWithAuthoredWindowsPaths(
599 node: MarkdownNode,
600 markdown: string,
601): MarkdownNode {
602 const authoredByParsed = new Map<string, string | null>();
603 for (const [, authored = ""] of markdown.matchAll(WINDOWS_DESTINATION_PATTERN)) {
604 if (!isWindowsAbsolutePath(authored)) continue;
605 const parsed = authored.replace(MARKDOWN_ESCAPE_PATTERN, "$1");
606 const known = authoredByParsed.get(parsed);
607 authoredByParsed.set(parsed, known === undefined || known === authored ? authored : null);
608 }
609 if (authoredByParsed.size === 0) return node;
610 const restore = (current: MarkdownNode): MarkdownNode => {
611 const href = current.href && authoredByParsed.get(current.href);
612 const children = current.children?.map(restore);
613 return { ...current, ...(href ? { href } : {}), ...(children ? { children } : {}) };
614 };
615 return restore(node);
616}
617
618function appendBlockTerminator(
619 runs: NativeMarkdownTextRun[],
620 context: RunContext,
621): NativeMarkdownTextRun[] {
622 return appendRun(runs, "\n", context);
623}
624
625function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] {
626 return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing });
627}
628
629function appendInlineChildren(
630 runs: NativeMarkdownTextRun[],
631 node: MarkdownNode,
632 context: RunContext,
633): NativeMarkdownTextRun[] {
634 for (const child of node.children ?? []) {
635 appendNode(runs, child, context);
636 }
637 return runs;
638}
639
640function isInlineNode(node: MarkdownNode): boolean {
641 return (
642 node.type === "text" ||
643 node.type === "bold" ||
644 node.type === "italic" ||
645 node.type === "strikethrough" ||
646 node.type === "link" ||
647 node.type === "image" ||
648 node.type === "code_inline" ||
649 node.type === "math_inline" ||
650 node.type === "html_inline" ||
651 node.type === "soft_break" ||
652 node.type === "line_break"
653 );
654}
655
656export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray<MarkdownNode> {
657 const blocks: MarkdownNode[] = [];
658 let inlineNodes: MarkdownNode[] = [];
659 const flushInlineNodes = () => {
660 if (inlineNodes.length === 0) {
661 return;
662 }
663 blocks.push({ type: "paragraph", children: inlineNodes });
664 inlineNodes = [];
665 };
666
667 for (const child of node.children ?? []) {
668 if (isInlineNode(child)) {
669 inlineNodes.push(child);
670 continue;
671 }
672
673 flushInlineNodes();
674 blocks.push(child);
675 }
676 flushInlineNodes();
677 return blocks;
678}
679
680function appendListItem(
681 runs: NativeMarkdownTextRun[],
682 node: MarkdownNode,
683 marker: string,
684 depth: number,
685 markerColumnWidth: number,
686): NativeMarkdownTextRun[] {
687 const firstLineHeadIndent = Math.max(0, depth - 1) * 20;
688 appendRun(runs, `${marker}\t`, {
689 ...EMPTY_CONTEXT,
690 role: "list-marker",
691 depth,
692 firstLineHeadIndent,
693 headIndent: firstLineHeadIndent + markerColumnWidth,
694 paragraphSpacing: 2,
695 });
696
697 const children = node.children ?? [];
698 let wroteInlineContent = false;
699 for (const child of children) {
700 if (child.type === "paragraph") {
701 appendInlineChildren(runs, child, {
702 ...EMPTY_CONTEXT,
703 role: "body",
704 depth,
705 });
706 wroteInlineContent = true;
707 continue;
708 }
709 if (child.type === "list") {
710 if (wroteInlineContent) {
711 appendBlockTerminator(runs, {
712 ...EMPTY_CONTEXT,
713 role: "list-break",
714 depth,
715 spacing: 1,
716 });
717 }
718 appendList(runs, child, depth + 1);
719 wroteInlineContent = false;
720 continue;
721 }
722 if (isInlineNode(child)) {
723 appendNode(runs, child, {
724 ...EMPTY_CONTEXT,
725 role: "body",
726 depth,
727 });
728 wroteInlineContent = true;
729 continue;
730 }
731 appendDocumentBlock(runs, child, depth);
732 wroteInlineContent = true;
733 }
734
735 if (wroteInlineContent) {
736 appendBlockTerminator(runs, {
737 ...EMPTY_CONTEXT,
738 role: "list-break",
739 depth,
740 spacing: depth === 1 ? 4 : 2,
741 });
742 }
743 return runs;
744}
745
746function appendList(
747 runs: NativeMarkdownTextRun[],
748 node: MarkdownNode,
749 depth: number,
750): NativeMarkdownTextRun[] {
751 const ordered = node.ordered ?? false;
752 const start = node.start ?? 1;
753 const children = node.children ?? [];
754 const markers = children.map((child, index) =>
755 child.type === "task_list_item"
756 ? child.checked
757 ? "☑︎"
758 : "☐︎"
759 : ordered
760 ? `${start + index}.`
761 : depth % 3 === 2
762 ? "◦"
763 : depth % 3 === 0
764 ? "▪︎"
765 : "•",
766 );
767 const markerWidth = ordered
768 ? Math.max(0, ...markers.map((marker) => Array.from(marker).length))
769 : 0;
770
771 for (const [index, child] of children.entries()) {
772 const marker = markers[index] ?? "•";
773 const alignedMarker =
774 child.type === "task_list_item"
775 ? marker
776 : ordered
777 ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}`
778 : marker;
779 const markerColumnWidth =
780 child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24;
781 appendListItem(runs, child, alignedMarker, depth, markerColumnWidth);
782 }
783 return runs;
784}
785
786function appendQuoteBlock(
787 runs: NativeMarkdownTextRun[],
788 node: MarkdownNode,
789 depth: number,
790): NativeMarkdownTextRun[] {
791 for (const [index, child] of (node.children ?? []).entries()) {
792 if (index > 0) {
793 appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
794 }
795 appendRun(runs, "│\u00a0", {
796 ...EMPTY_CONTEXT,
797 role: "quote-marker",
798 depth,
799 });
800 if (child.type === "paragraph") {
801 appendInlineChildren(runs, child, {
802 ...EMPTY_CONTEXT,
803 role: "body",
804 depth,
805 });
806 } else {
807 appendDocumentBlock(runs, child, depth);
808 }
809 }
810 appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
811 return runs;
812}
813
814function appendTableRow(
815 runs: NativeMarkdownTextRun[],
816 node: MarkdownNode,
817 depth: number,
818): NativeMarkdownTextRun[] {
819 const cells = node.children ?? [];
820 for (const [index, cell] of cells.entries()) {
821 if (index > 0) {
822 appendRun(runs, "\u00a0│\u00a0", {
823 ...EMPTY_CONTEXT,
824 role: "divider",
825 depth,
826 });
827 }
828 appendInlineChildren(runs, cell, {
829 ...EMPTY_CONTEXT,
830 role: "body",
831 bold: cell.isHeader ?? false,
832 depth,
833 });
834 }
835 appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
836 return runs;
837}
838
839function appendTable(
840 runs: NativeMarkdownTextRun[],
841 node: MarkdownNode,
842 depth: number,
843): NativeMarkdownTextRun[] {
844 const visit = (child: MarkdownNode) => {
845 if (child.type === "table_row") {
846 appendTableRow(runs, child, depth);
847 return;
848 }
849 for (const nested of child.children ?? []) {
850 visit(nested);
851 }
852 };
853 visit(node);
854 return runs;
855}
856
857function appendDocumentBlock(
858 runs: NativeMarkdownTextRun[],
859 node: MarkdownNode,
860 depth = 0,
861): NativeMarkdownTextRun[] {
862 switch (node.type) {
863 case "document": {
864 const children = node.children ?? [];
865 for (const [index, child] of children.entries()) {
866 if (index > 0) {
867 const previous = children[index - 1];
868 appendSpacer(
869 runs,
870 child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12,
871 );
872 }
873 appendDocumentBlock(runs, child, depth);
874 }
875 return runs;
876 }
877 case "heading": {
878 const context: RunContext = {
879 ...EMPTY_CONTEXT,
880 role: "heading",
881 headingLevel: node.level ?? 1,
882 depth,
883 };
884 appendInlineChildren(runs, node, context);
885 return appendBlockTerminator(runs, context);
886 }
887 case "paragraph": {
888 const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth };
889 appendInlineChildren(runs, node, context);
890 return appendBlockTerminator(runs, context);
891 }
892 case "list":
893 return appendList(runs, node, depth + 1);
894 case "blockquote":
895 return appendQuoteBlock(runs, node, depth);
896 case "code_block": {
897 if (node.language) {
898 appendRun(runs, `${node.language.toUpperCase()}\n`, {
899 ...EMPTY_CONTEXT,
900 role: "code-language",
901 code: true,
902 depth,
903 });
904 }
905 const content = nodeTextContent(node);
906 appendRun(runs, content, {
907 ...EMPTY_CONTEXT,
908 role: "code-block",
909 code: true,
910 depth,
911 });
912 if (!content.endsWith("\n")) {
913 appendBlockTerminator(runs, {
914 ...EMPTY_CONTEXT,
915 role: "code-block",
916 code: true,
917 depth,
918 });
919 }
920 return runs;
921 }
922 case "horizontal_rule":
923 appendRun(runs, "────────────────────────\n", {
924 ...EMPTY_CONTEXT,
925 role: "divider",
926 depth,
927 });
928 return runs;
929 case "table":
930 return appendTable(runs, node, depth);
931 case "html_block":
932 appendRun(runs, inlineHtmlText(nodeTextContent(node)), {
933 ...EMPTY_CONTEXT,
934 role: "body",
935 depth,
936 });
937 return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
938 case "math_block":
939 appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth });
940 return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
941 default:
942 appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth });
943 return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
944 }
945}
946
947function containsRichBlock(node: MarkdownNode): boolean {
948 if (
949 node.type === "code_block" ||
950 node.type === "blockquote" ||
951 node.type === "table" ||
952 node.type === "image" ||
953 node.type === "horizontal_rule" ||
954 node.type === "html_block" ||
955 node.type === "math_block"
956 ) {
957 return true;
958 }
959 return (node.children ?? []).some(containsRichBlock);
960}
961
962/**
963 * Sibling identity for React keys. A source offset survives appends while the
964 * document streams; the child index is the fallback for offset-free nodes. The
965 * two never share a namespace, so a positioned node cannot collide with an
966 * offset-free sibling whose index happens to equal its offset.
967 */
968export function nativeMarkdownNodePosition(node: MarkdownNode, index: number): string {
969 return node.beg === undefined ? `index:${index}` : `offset:${node.beg}`;
970}
971
972export function nativeMarkdownDocumentChunks(
973 document: MarkdownNode,
974): ReadonlyArray<NativeMarkdownDocumentChunk> {
975 const chunks: NativeMarkdownDocumentChunk[] = [];
976 let selectableNodes: MarkdownNode[] = [];
977 let selectableStart = 0;
978
979 const flushSelectable = () => {
980 const first = selectableNodes[0];
981 if (!first) {
982 return;
983 }
984 chunks.push({
985 kind: "selectable",
986 key: `selectable:${nativeMarkdownNodePosition(first, selectableStart)}`,
987 node: {
988 type: "document",
989 children: selectableNodes,
990 },
991 });
992 selectableNodes = [];
993 };
994
995 for (const [index, child] of (document.children ?? []).entries()) {
996 if (!containsRichBlock(child)) {
997 if (selectableNodes.length === 0) {
998 selectableStart = index;
999 }
1000 selectableNodes.push(child);
1001 continue;
1002 }
1003
1004 flushSelectable();
1005 chunks.push({
1006 kind: "rich",
1007 key: `rich:${child.type}:${nativeMarkdownNodePosition(child, index)}`,
1008 node: child,
1009 });
1010 }
1011 flushSelectable();
1012 return chunks;
1013}
1014
1015function topLevelNodes(node: MarkdownNode): ReadonlyArray<MarkdownNode> {
1016 return node.type === "document" ? (node.children ?? []) : [node];
1017}
1018
1019export function nativeMarkdownChunkSpacing(
1020 previous: NativeMarkdownDocumentChunk | undefined,
1021 current: NativeMarkdownDocumentChunk,
1022): number {
1023 if (!previous) {
1024 return 0;
1025 }
1026
1027 const previousLast = topLevelNodes(previous.node).at(-1);
1028 const currentFirst = topLevelNodes(current.node)[0];
1029
1030 if (currentFirst?.type === "heading") {
1031 return 20;
1032 }
1033 if (previousLast?.type === "heading") {
1034 return 10;
1035 }
1036 if (previousLast?.type === "list" && currentFirst?.type === "list") {
1037 return 12;
1038 }
1039 return 14;
1040}
1041
1042export function nativeMarkdownDocumentRuns(
1043 node: MarkdownNode,
1044 skills: ReadonlyArray<SelectableMarkdownSkill> = [],
1045): ReadonlyArray<NativeMarkdownTextRun> {
1046 const runs = appendDocumentBlock([], node);
1047 while (runs.length > 0) {
1048 const lastIndex = runs.length - 1;
1049 const last = runs[lastIndex];
1050 if (!last?.text.endsWith("\n")) {
1051 break;
1052 }
1053 const text = last.text.slice(0, -1);
1054 if (text.length === 0) {
1055 runs.pop();
1056 } else {
1057 runs[lastIndex] = { ...last, text };
1058 }
1059 }
1060 return decorateMentionRuns(decorateSkillRuns(runs, skills));
1061}
1062
1063const HTML_TAG = /<!--[\s\S]*?-->|<\/?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi;
1064
1065function attribute(tag: string, name: string): string | null {
1066 const attributes = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
1067 for (const match of tag.matchAll(attributes)) {
1068 if (match[1]?.toLowerCase() === name) {
1069 return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? "");
1070 }
1071 }
1072 return null;
1073}
1074
1075/** Called only for parsed HTML nodes, so Markdown code examples keep their literal tags. */
1076export function markdownHtmlContent(content: string): string {
1077 // An HTML block such as <details> can also contain Markdown fences and code spans.
1078 const codeRanges: Array<{ start: number; end: number }> = [];
1079 const delimiters = [...content.matchAll(/`+|~{3,}/g)];
1080 for (let index = 0; index < delimiters.length; index += 1) {
1081 const opening = delimiters[index]!;
1082 const lineStart = content.lastIndexOf("\n", opening.index - 1) + 1;
1083 const fenced =
1084 opening[0].length >= 3 && /^ {0,3}$/.test(content.slice(lineStart, opening.index));
1085 if (opening[0][0] === "~" && !fenced) continue;
1086 let closingIndex = -1;
1087 for (let candidate = index + 1; candidate < delimiters.length; candidate += 1) {
1088 const closing = delimiters[candidate]!;
1089 if (!fenced && closing[0] === opening[0]) {
1090 closingIndex = candidate;
1091 break;
1092 }
1093 if (!fenced || closing[0][0] !== opening[0][0] || closing[0].length < opening[0].length)
1094 continue;
1095 const start = content.lastIndexOf("\n", closing.index - 1) + 1;
1096 const nextLine = content.indexOf("\n", closing.index);
1097 const end = nextLine === -1 ? content.length : nextLine;
1098 if (
1099 /^ {0,3}$/.test(content.slice(start, closing.index)) &&
1100 /^\s*$/.test(content.slice(closing.index + closing[0].length, end))
1101 ) {
1102 closingIndex = candidate;
1103 break;
1104 }
1105 }
1106 if (closingIndex === -1) {
1107 if (fenced) codeRanges.push({ start: opening.index, end: content.length });
1108 continue;
1109 }
1110 const closing = delimiters[closingIndex]!;
1111 codeRanges.push({ start: opening.index, end: closing.index + closing[0].length });
1112 index = closingIndex;
1113 }
1114 return content.replace(HTML_TAG, (tag, offset: number) => {
1115 if (codeRanges.some((range) => offset >= range.start && offset < range.end)) return tag;
1116 if (tag.startsWith("<!--")) return "";
1117 if (/^<img\b/i.test(tag)) {
1118 const src = attribute(tag, "src");
1119 if (!src) return "";
1120 const alt = (attribute(tag, "alt") ?? "").replace(/[\\[\]]/g, "\\$&");
1121 const href = src.replace(/</g, "%3C").replace(/>/g, "%3E").replace(/\n/g, "");
1122 return `![${alt}](<${href}>)`;
1123 }
1124 if (/^<summary\b/i.test(tag)) return "\n\n**";
1125 if (/^<\/summary\s*>/i.test(tag)) return "**\n\n";
1126 if (/^<br\b/i.test(tag)) return " \n";
1127 if (/^<\/?(?:p|div|details)\b/i.test(tag)) return "\n\n";
1128 return "";
1129 });
1130}
1131
1132export function nativeMarkdownWithHtml(
1133 document: MarkdownNode,
1134 parse: (content: string) => MarkdownNode,
1135): MarkdownNode {
1136 const withoutOffsets = (node: MarkdownNode): MarkdownNode => {
1137 const { beg: _beg, end: _end, children, ...rest } = node;
1138 return { ...rest, ...(children ? { children: children.map(withoutOffsets) } : {}) };
1139 };
1140 const transform = (node: MarkdownNode): MarkdownNode[] => {
1141 if (node.type === "html_block" || node.type === "html_inline") {
1142 const content = markdownHtmlContent(node.content ?? "");
1143 if (!content.trim())
1144 return /^<br\b/i.test(node.content ?? "") ? [{ type: "line_break" }] : [];
1145 // Fragment offsets restart at zero, so they cannot identify chunks in the parent document.
1146 const children = (parse(content).children ?? []).map(withoutOffsets);
1147 return node.type === "html_inline"
1148 ? children.flatMap((child) =>
1149 child.type === "paragraph" ? (child.children ?? []) : [child],
1150 )
1151 : children;
1152 }
1153 if (!node.children) return [node];
1154 const children = node.children.flatMap(transform);
1155 if (node.type === "paragraph" && children.length === 0) return [];
1156 return [{ ...node, children }];
1157 };
1158 return { ...document, children: (document.children ?? []).flatMap(transform) };
1159}
apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.test.ts deleted-28
...@@ -1,28 +0,0 @@
1import { expect, it } from "vite-plus/test";
2import { pendingCodeHighlight } from "./pendingCodeHighlight";
3
4it("keeps completed colors and exact current text without retaining an edited tail", () => {
5 const colored = [
6 [{ content: "const n = 1;", color: "red", fontStyle: 0 }],
7 [{ content: "partial", color: "blue", fontStyle: 0 }],
8 ];
9 const result = pendingCodeHighlight(
10 "const n = 1;\npartial",
11 "const n = 1;\nchanged\nnext",
12 colored,
13 )!;
14 expect(result[0]).toBe(colored[0]);
15 expect(result.map((line) => line.map((token) => token.content).join("")).join("\n")).toBe(
16 "const n = 1;\nchanged\nnext",
17 );
18 expect(
19 result
20 .slice(1)
21 .flat()
22 .every((token) => token.color === null),
23 ).toBe(true);
24 expect(
25 pendingCodeHighlight("const n = 1;\npartial", "const n = 2;\npartial", colored),
26 ).toBeNull();
27 expect(pendingCodeHighlight("partial", "other", colored)).toBeNull();
28});
apps/mobile/modules/t3-markdown-text/src/pendingCodeHighlight.ts deleted-19
...@@ -1,19 +0,0 @@
1import type { MarkdownHighlightedToken } from "./SelectableMarkdownText.types";
2
3/** Keep finished lines colored while the current line awaits highlighting. */
4export function pendingCodeHighlight(
5 previousCode: string,
6 code: string,
7 tokens: ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>,
8): ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>> | null {
9 const end = previousCode.lastIndexOf("\n") + 1;
10 if (!end || !code.startsWith(previousCode.slice(0, end))) return null;
11 const completedLines = previousCode.slice(0, end).split("\n").length - 1;
12 return [
13 ...tokens.slice(0, completedLines),
14 ...code
15 .slice(end)
16 .split("\n")
17 .map((content) => [{ content, color: null, fontStyle: null }]),
18 ];
19}
apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.test.tsx deleted-189
...@@ -1,189 +0,0 @@
1import { act, useLayoutEffect } from "react";
2import { createRoot, type Root } from "react-dom/client";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import type { MarkdownCodeHighlighter } from "./SelectableMarkdownText.types";
6import { useHighlightedCode, type HighlightedCode } from "./useHighlightedCode";
7
8type Deferred = {
9 readonly code: string;
10 readonly resolve: (tokens: HighlightedCode) => void;
11};
12
13/** One colored line per newline-separated line, so a line's color proves where it came from. */
14function tokensFor(code: string, color: string): HighlightedCode {
15 return code.split("\n").map((content) => [{ content, color, fontStyle: 0 }]);
16}
17
18function lineText(tokens: HighlightedCode | null): string | null {
19 return tokens?.map((line) => line.map((token) => token.content).join("")).join("\n") ?? null;
20}
21
22function lineColors(tokens: HighlightedCode | null): ReadonlyArray<string | null> {
23 return tokens?.map((line) => line[0]?.color ?? null) ?? [];
24}
25
26const deferred: Deferred[] = [];
27const readResults = new Map<string, HighlightedCode>();
28let renders = 0;
29let result: HighlightedCode | null = null;
30let root: Root;
31
32const highlightCode: MarkdownCodeHighlighter = Object.assign(
33 vi.fn(
34 (input: { code: string }) =>
35 new Promise<HighlightedCode>((resolve) => {
36 deferred.push({ code: input.code, resolve });
37 }),
38 ),
39 {
40 read: vi.fn((input: { code: string }) => readResults.get(input.code)),
41 },
42);
43
44function Probe(props: {
45 readonly code: string;
46 readonly language?: string;
47 readonly theme?: "light" | "dark";
48}) {
49 const tokens = useHighlightedCode(
50 props.code,
51 props.language ?? "ts",
52 props.theme ?? "dark",
53 highlightCode,
54 );
55 useLayoutEffect(() => {
56 renders += 1;
57 result = tokens;
58 });
59 return null;
60}
61
62async function render(props: Parameters<typeof Probe>[0]) {
63 await act(() => root.render(<Probe {...props} />));
64}
65
66async function resolveAsync(index: number, color: string) {
67 const pending = deferred[index];
68 if (!pending) throw new Error(`no pending highlight at ${index}`);
69 await act(async () => {
70 pending.resolve(tokensFor(pending.code, color));
71 });
72}
73
74beforeEach(async () => {
75 // The probe has no DOM output, but ReactDOM needs an event target.
76 const document = {
77 nodeType: 9,
78 addEventListener() {},
79 removeEventListener() {},
80 };
81 const container = {
82 nodeType: 1,
83 tagName: "DIV",
84 namespaceURI: "http://www.w3.org/1999/xhtml",
85 ownerDocument: document,
86 addEventListener() {},
87 removeEventListener() {},
88 };
89 vi.stubGlobal("document", document);
90 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
91 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
92 deferred.length = 0;
93 readResults.clear();
94 renders = 0;
95 result = null;
96 vi.mocked(highlightCode).mockClear();
97 vi.mocked(highlightCode.read!).mockClear();
98 root = createRoot(container as unknown as HTMLElement);
99});
100
101afterEach(async () => {
102 await act(() => root.unmount());
103 vi.unstubAllGlobals();
104});
105
106// Every test uses a distinct language so the module-level token cache from an
107// earlier test can never satisfy a later one.
108describe("useHighlightedCode", () => {
109 it("keeps colors from the latest synchronous read when a later read misses", async () => {
110 const language = "sync-baseline";
111 await render({ code: "a", language });
112 expect(result).toBeNull();
113 await resolveAsync(0, "async");
114 expect(lineColors(result)).toEqual(["async"]);
115
116 readResults.set("a\nb", tokensFor("a\nb", "sync-1"));
117 readResults.set("a\nb\nc", tokensFor("a\nb\nc", "sync-2"));
118 const rendersBeforeAppends = renders;
119 await render({ code: "a\nb", language });
120 await render({ code: "a\nb\nc", language });
121 expect(lineColors(result)).toEqual(["sync-2", "sync-2", "sync-2"]);
122 // A synchronous hit is one render per append: no state update, no highlighter call.
123 expect(renders - rendersBeforeAppends).toBe(2);
124 expect(highlightCode).toHaveBeenCalledTimes(1);
125
126 await render({ code: "a\nb\nc\nd", language });
127 expect(highlightCode).toHaveBeenCalledTimes(2);
128 expect(lineText(result)).toBe("a\nb\nc\nd");
129 // Lines before the previous code's final newline are the completed ones.
130 expect(lineColors(result)).toEqual(["sync-2", "sync-2", null, null]);
131
132 await resolveAsync(1, "async-2");
133 expect(lineColors(result)).toEqual(["async-2", "async-2", "async-2", "async-2"]);
134 });
135
136 it("does not show obsolete text after a non-append edit", async () => {
137 const language = "non-append";
138 await render({ code: "a\nb", language });
139 await resolveAsync(0, "async");
140 readResults.set("a\nb\nc", tokensFor("a\nb\nc", "sync"));
141 await render({ code: "a\nb\nc", language });
142 expect(lineColors(result)).toEqual(["sync", "sync", "sync"]);
143
144 await render({ code: "x\nb\nc", language });
145 expect(result).toBeNull();
146 await resolveAsync(1, "async-2");
147 expect(lineText(result)).toBe("x\nb\nc");
148 });
149
150 it("never reuses colors across a language or theme change", async () => {
151 const language = "theme-change";
152 await render({ code: "a\nb", language });
153 await resolveAsync(0, "async");
154 readResults.set("a\nb\nc", tokensFor("a\nb\nc", "sync"));
155 await render({ code: "a\nb\nc", language });
156 expect(lineColors(result)).toEqual(["sync", "sync", "sync"]);
157
158 await render({ code: "a\nb\nc\nd", language, theme: "light" });
159 expect(result).toBeNull();
160 await render({ code: "a\nb\nc\nd", language: `${language}-other` });
161 expect(result).toBeNull();
162 });
163
164 it("ignores an asynchronous completion that is older than the current code", async () => {
165 const language = "stale-async";
166 await render({ code: "a\nb", language });
167 await resolveAsync(0, "async");
168
169 await render({ code: "a\nb\nc", language });
170 expect(deferred).toHaveLength(2);
171 expect(lineColors(result)).toEqual(["async", null, null]);
172
173 readResults.set("a\nb\nc\nd", tokensFor("a\nb\nc\nd", "sync"));
174 await render({ code: "a\nb\nc\nd", language });
175 expect(lineColors(result)).toEqual(["sync", "sync", "sync", "sync"]);
176
177 await render({ code: "a\nb\nc\nd\ne", language });
178 expect(deferred).toHaveLength(3);
179 expect(lineColors(result)).toEqual(["sync", "sync", "sync", null, null]);
180
181 // The older request finishes after newer synchronous results exist.
182 await resolveAsync(1, "stale");
183 expect(lineText(result)).toBe("a\nb\nc\nd\ne");
184 expect(lineColors(result)).toEqual(["sync", "sync", "sync", null, null]);
185
186 await resolveAsync(2, "async-3");
187 expect(lineColors(result)).toEqual(["async-3", "async-3", "async-3", "async-3", "async-3"]);
188 });
189});
apps/mobile/modules/t3-markdown-text/src/useHighlightedCode.ts deleted-149
...@@ -1,149 +0,0 @@
1import { useEffect, useMemo, useRef, useState } from "react";
2
3import { pendingCodeHighlight } from "./pendingCodeHighlight";
4import type {
5 MarkdownCodeHighlighter,
6 MarkdownHighlightedToken,
7} from "./SelectableMarkdownText.types";
8
9export type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
10
11interface HighlightedCodeResult {
12 readonly key: string;
13 readonly code: string;
14 readonly language: string | undefined;
15 readonly theme: "light" | "dark";
16 readonly tokens: HighlightedCode | null;
17}
18
19const highlightedCodeCache = new Map<string, HighlightedCode>();
20const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();
21const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;
22
23function codeHighlightCacheKey(
24 code: string,
25 language: string | undefined,
26 theme: "light" | "dark",
27): string {
28 return `${theme}:${language ?? "text"}:${code}`;
29}
30
31function cacheHighlightedCode(key: string, tokens: HighlightedCode): void {
32 highlightedCodeCache.delete(key);
33 highlightedCodeCache.set(key, tokens);
34
35 while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) {
36 const oldestKey = highlightedCodeCache.keys().next().value;
37 if (oldestKey === undefined) {
38 break;
39 }
40 highlightedCodeCache.delete(oldestKey);
41 }
42}
43
44function loadHighlightedCode(
45 code: string,
46 language: string | undefined,
47 theme: "light" | "dark",
48 highlightCode: MarkdownCodeHighlighter,
49 session: object,
50): Promise<HighlightedCode> {
51 const key = codeHighlightCacheKey(code, language, theme);
52 const cached = highlightedCodeCache.get(key);
53 if (cached) {
54 return Promise.resolve(cached);
55 }
56
57 const pending = highlightedCodePromiseCache.get(key);
58 if (pending) {
59 return pending;
60 }
61
62 const promise = highlightCode({ code, language, theme, session })
63 .then((tokens) => {
64 cacheHighlightedCode(key, tokens);
65 highlightedCodePromiseCache.delete(key);
66 return tokens;
67 })
68 .catch((error) => {
69 highlightedCodePromiseCache.delete(key);
70 throw error;
71 });
72 highlightedCodePromiseCache.set(key, promise);
73 return promise;
74}
75
76/**
77 * Tokens for a code block, or null while nothing usable exists yet. A cached or
78 * synchronous result renders in the same pass. Otherwise the most recent
79 * completed result seeds `pendingCodeHighlight` so finished lines keep their
80 * colors while the asynchronous highlighter catches up.
81 */
82export function useHighlightedCode(
83 code: string,
84 language: string | undefined,
85 theme: "light" | "dark",
86 highlightCode: MarkdownCodeHighlighter,
87): HighlightedCode | null {
88 const [session] = useState(() => ({}));
89 const key = codeHighlightCacheKey(code, language, theme);
90 const ready = useMemo(
91 () => highlightedCodeCache.get(key) ?? highlightCode.read?.({ code, language, theme, session }),
92 [code, language, theme, key, highlightCode, session],
93 );
94 const [highlighted, setHighlighted] = useState<HighlightedCodeResult>(() => ({
95 code,
96 language,
97 theme,
98 key,
99 tokens: highlightedCodeCache.get(key) ?? null,
100 }));
101 // Synchronous reads never touch state, so each append costs one render. The
102 // ref remembers the newest of them, and is cleared whenever an asynchronous
103 // result commits so `highlighted` is the baseline again from then on.
104 const latestRead = useRef<HighlightedCodeResult | null>(null);
105
106 useEffect(() => {
107 if (ready) {
108 latestRead.current = { code, language, theme, key, tokens: ready };
109 return;
110 }
111 let active = true;
112 const commit = (tokens: HighlightedCode | null) => {
113 latestRead.current = null;
114 setHighlighted({ code, language, theme, key, tokens });
115 };
116 const cached = highlightedCodeCache.get(key);
117 if (cached) {
118 cacheHighlightedCode(key, cached);
119 commit(cached);
120 return () => {
121 active = false;
122 };
123 }
124
125 void loadHighlightedCode(code, language, theme, highlightCode, session)
126 .then((tokens) => {
127 if (active) {
128 commit(tokens);
129 }
130 })
131 .catch(() => {
132 if (active) {
133 commit(null);
134 }
135 });
136 return () => {
137 active = false;
138 };
139 }, [code, highlightCode, key, language, theme, ready, session]);
140
141 if (ready) return ready;
142 // oxlint-disable-next-line react/refs -- Written only after commit; a discarded render never advances it.
143 const baseline = latestRead.current ?? highlighted;
144 if (baseline.key === key) return baseline.tokens;
145 if (baseline.tokens && baseline.language === language && baseline.theme === theme) {
146 return pendingCodeHighlight(baseline.code, code, baseline.tokens);
147 }
148 return null;
149}
apps/mobile/modules/t3-markdown-text/src/util.ts deleted-62
...@@ -1,62 +0,0 @@
1import { type StyleProp, StyleSheet, type TextStyle } from "react-native";
2import type { NativeFontWeight } from "./T3MarkdownTextRunNativeComponent";
3
4export function flattenStyles(rootStyle: TextStyle, style: StyleProp<TextStyle>) {
5 const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle;
6 return {
7 ...flattenedStyle,
8 fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"),
9 backgroundColor: flattenedStyle.backgroundColor
10 ? flattenedStyle.backgroundColor
11 : "transparent",
12 shadowOffset: flattenedStyle.shadowOffset
13 ? flattenedStyle.shadowOffset
14 : { width: 0, height: 0 },
15 };
16}
17
18// Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop
19// value before returning flattened styles.
20function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight {
21 switch (fontWeight) {
22 case "normal":
23 return "normal";
24 case "bold":
25 return "bold";
26 case 100:
27 case "100":
28 case "ultralight":
29 return "ultraLight";
30 case 200:
31 case "200":
32 return "ultraLight";
33 case 300:
34 case "300":
35 case "light":
36 return "light";
37 case 400:
38 case "400":
39 case "regular":
40 return "normal";
41 case 500:
42 case "500":
43 case "medium":
44 return "medium";
45 case 600:
46 case "600":
47 case "semibold":
48 return "semibold";
49 case 700:
50 case "700":
51 return "semibold";
52 case 800:
53 case "800":
54 return "bold";
55 case 900:
56 case "900":
57 case "heavy":
58 return "heavy";
59 default:
60 return "normal";
61 }
62}
apps/mobile/modules/t3-source-text/LICENSE created+20
...@@ -0,0 +1,20 @@
1MIT License
2
3Copyright (c) 2024-25 Bluesky PBC
4Permission is hereby granted, free of charge, to any person obtaining a copy
5of this software and associated documentation files (the "Software"), to deal
6in the Software without restriction, including without limitation the rights
7to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8copies of the Software, and to permit persons to whom the Software is
9furnished to do so, subject to the following conditions:
10
11The above copyright notice and this permission notice shall be included in all
12copies or substantial portions of the Software.
13
14THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20SOFTWARE.
apps/mobile/modules/t3-source-text/T3SourceText.podspec created+25
...@@ -0,0 +1,25 @@
1require "json"
2
3package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1"
5
6Pod::Spec.new do |s|
7 s.name = "T3SourceText"
8 s.version = package["version"]
9 s.summary = "Native selectable source text for T3 Code mobile."
10 s.description = "Fabric-backed styled source text with whole-document selection."
11 s.homepage = "https://t3tools.com"
12 s.license = { :type => "MIT", :file => "LICENSE" }
13 s.author = { "T3 Tools" => "hello@t3tools.com" }
14 s.platforms = { :ios => min_ios_version_supported }
15 s.source = { :path => "." }
16 s.source_files = "ios/**/*.{h,m,mm,cpp}"
17
18 install_modules_dependencies(s)
19
20 if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled
21 add_dependency(s, "React-FabricComponents", :additional_framework_paths => [
22 "react/renderer/textlayoutmanager/platform/ios",
23 ])
24 end
25end
apps/mobile/modules/t3-source-text/UPSTREAM.md created+11
...@@ -0,0 +1,11 @@
1# Upstream Attribution
2
3The Fabric attributed-text component in this module originated from
4[`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview),
5version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`.
6
7The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under
8the MIT License included in this directory.
9
10T3 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
11an upstream package dependency or a compatibility fork.
apps/mobile/modules/t3-source-text/index.ts created+1
...@@ -0,0 +1 @@
1export { SourceText } from "./src/SourceText";
apps/mobile/modules/t3-source-text/ios/T3SourceText.h created+13
...@@ -0,0 +1,13 @@
1#import <React/RCTViewComponentView.h>
2#import <UIKit/UIKit.h>
3
4#ifndef T3SourceTextNativeComponent_h
5#define T3SourceTextNativeComponent_h
6
7NS_ASSUME_NONNULL_BEGIN
8@interface T3SourceText : RCTViewComponentView
9@end
10
11NS_ASSUME_NONNULL_END
12
13#endif /* UitextviewViewNativeComponent_h */
apps/mobile/modules/t3-source-text/ios/T3SourceText.mm created+302
...@@ -0,0 +1,302 @@
1#import "T3SourceText.h"
2#import <React/RCTFabricComponentsPlugins.h>
3#import "T3SourceTextComponentDescriptor.h"
4#import "T3SourceTextShadowNode.h"
5#import <React/RCTConversions.h>
6#import <objc/runtime.h>
7#import <react/renderer/components/T3SourceTextSpec/EventEmitters.h>
8#import <react/renderer/components/T3SourceTextSpec/Props.h>
9#import <react/renderer/components/T3SourceTextSpec/RCTComponentViewHelpers.h>
10#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
11
12using namespace facebook::react;
13
14@interface T3SourceTextView : UITextView
15@end
16
17@implementation T3SourceTextView
18- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
19 // Read-only UITextViews must still allow selecting the whole file.
20 if (action == @selector(selectAll:)) {
21 return self.selectable && self.text.length > 0 &&
22 self.selectedRange.length < self.text.length;
23 }
24 return [super canPerformAction:action withSender:sender];
25}
26@end
27
28@protocol T3SourceTextOutsideTapTarget <NSObject>
29- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView;
30@end
31
32@interface T3SourceTextOutsideTapCoordinator
33 : NSObject <UIGestureRecognizerDelegate>
34
35- (instancetype)initWithWindow:(UIWindow *)window;
36- (void)addTarget:(id<T3SourceTextOutsideTapTarget>)target;
37- (void)removeTarget:(id<T3SourceTextOutsideTapTarget>)target;
38
39@end
40
41static const void *T3SourceTextOutsideTapCoordinatorKey =
42 &T3SourceTextOutsideTapCoordinatorKey;
43
44@implementation T3SourceTextOutsideTapCoordinator {
45 __weak UIWindow *_window;
46 UITapGestureRecognizer *_recognizer;
47 NSHashTable<id<T3SourceTextOutsideTapTarget>> *_targets;
48}
49
50- (instancetype)initWithWindow:(UIWindow *)window {
51 if (self = [super init]) {
52 _window = window;
53 _targets = [NSHashTable weakObjectsHashTable];
54 _recognizer =
55 [[UITapGestureRecognizer alloc] initWithTarget:self
56 action:@selector(handleTap:)];
57 _recognizer.cancelsTouchesInView = NO;
58 _recognizer.delegate = self;
59 [window addGestureRecognizer:_recognizer];
60 }
61 return self;
62}
63
64- (void)addTarget:(id<T3SourceTextOutsideTapTarget>)target {
65 [_targets addObject:target];
66}
67
68- (void)removeTarget:(id<T3SourceTextOutsideTapTarget>)target {
69 [_targets removeObject:target];
70 if (_targets.count > 0) {
71 return;
72 }
73
74 UIWindow *window = _window;
75 [window removeGestureRecognizer:_recognizer];
76 if (objc_getAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey) ==
77 self) {
78 objc_setAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey, nil,
79 OBJC_ASSOCIATION_RETAIN_NONATOMIC);
80 }
81}
82
83- (void)handleTap:(UITapGestureRecognizer *)sender {
84 UIWindow *window = _window;
85 if (window == nil) {
86 return;
87 }
88
89 UIView *hitView = [window hitTest:[sender locationInView:window]
90 withEvent:nil];
91 if (hitView == nil) {
92 return;
93 }
94 for (id<T3SourceTextOutsideTapTarget> target in _targets.allObjects) {
95 [target clearSelectionForOutsideTapWithHitView:hitView];
96 }
97}
98
99- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
100 shouldRecognizeSimultaneouslyWithGestureRecognizer:
101 (UIGestureRecognizer *)otherGestureRecognizer {
102 return YES;
103}
104
105@end
106
107static T3SourceTextOutsideTapCoordinator *
108T3SourceTextOutsideTapCoordinatorForWindow(UIWindow *window) {
109 T3SourceTextOutsideTapCoordinator *coordinator =
110 objc_getAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey);
111 if (coordinator == nil) {
112 coordinator =
113 [[T3SourceTextOutsideTapCoordinator alloc] initWithWindow:window];
114 objc_setAssociatedObject(window, T3SourceTextOutsideTapCoordinatorKey,
115 coordinator, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
116 }
117 return coordinator;
118}
119
120@interface T3SourceText () <RCTT3SourceTextViewProtocol, UITextViewDelegate,
121 T3SourceTextOutsideTapTarget>
122@end
123
124@implementation T3SourceText {
125 UIView *_view;
126 T3SourceTextView *_textView;
127 BOOL _textLayoutNeedsUpdate;
128 T3SourceTextShadowNode::ConcreteState::Shared _state;
129 __weak UIWindow *_outsideTapWindow;
130 BOOL _suppressSelectionChange;
131}
132
133+ (ComponentDescriptorProvider)componentDescriptorProvider {
134 return concreteComponentDescriptorProvider<T3SourceTextComponentDescriptor>();
135}
136
137- (instancetype)initWithFrame:(CGRect)frame {
138 if (self = [super initWithFrame:frame]) {
139 static const auto defaultProps =
140 std::make_shared<const T3SourceTextProps>();
141 _props = defaultProps;
142 _view = [[UIView alloc] init];
143 self.contentView = _view;
144 self.clipsToBounds = YES;
145 _textView = [[T3SourceTextView alloc] init];
146 _textView.scrollEnabled = NO;
147 _textView.editable = NO;
148 _textView.textContainerInset = UIEdgeInsetsZero;
149 _textView.textContainer.lineFragmentPadding = 0;
150 _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingTail;
151 _textView.textContainer.widthTracksTextView = NO;
152 _textView.textContainer.heightTracksTextView = NO;
153 _textView.delegate = self;
154 _textView.textDragInteraction.enabled = NO;
155 _textView.layoutManager.usesFontLeading = NO;
156 [self addSubview:_textView];
157 }
158 return self;
159}
160
161- (void)didMoveToWindow {
162 [super didMoveToWindow];
163 if (_outsideTapWindow == self.window)
164 return;
165 T3SourceTextOutsideTapCoordinator *coordinator = objc_getAssociatedObject(
166 _outsideTapWindow, T3SourceTextOutsideTapCoordinatorKey);
167 [coordinator removeTarget:self];
168 _outsideTapWindow = self.window;
169 if (_outsideTapWindow != nil) {
170 [T3SourceTextOutsideTapCoordinatorForWindow(_outsideTapWindow)
171 addTarget:self];
172 }
173}
174
175- (void)dealloc {
176 T3SourceTextOutsideTapCoordinator *coordinator = objc_getAssociatedObject(
177 _outsideTapWindow, T3SourceTextOutsideTapCoordinatorKey);
178 [coordinator removeTarget:self];
179}
180
181- (void)prepareForRecycle {
182 [super prepareForRecycle];
183 T3SourceTextOutsideTapCoordinator *coordinator = objc_getAssociatedObject(
184 _outsideTapWindow, T3SourceTextOutsideTapCoordinatorKey);
185 [coordinator removeTarget:self];
186 _outsideTapWindow = nil;
187 _state.reset();
188 _textView.frame = CGRectZero;
189 _textView.attributedText = nil;
190 _textLayoutNeedsUpdate = YES;
191}
192
193- (void)layoutSubviews {
194 [super layoutSubviews];
195 if (!CGRectEqualToRect(_textView.frame, _view.frame)) {
196 _textView.frame = _view.frame;
197 _textLayoutNeedsUpdate = YES;
198 [self setNeedsDisplay];
199 }
200}
201
202- (void)drawRect:(CGRect)rect {
203 if (!_state)
204 return;
205 NSMutableAttributedString *text = [RCTNSAttributedStringFromAttributedString(
206 _state->getData().attributedString) mutableCopy];
207 RCTApplyBaselineOffset(text);
208 const BOOL textChanged =
209 ![_textView.attributedText isEqualToAttributedString:text];
210 const BOOL frameChanged = _textLayoutNeedsUpdate;
211 if (!textChanged && !frameChanged)
212 return;
213 if (textChanged) {
214 const NSRange savedRange = _textView.selectedRange;
215 _suppressSelectionChange = YES;
216 _textView.attributedText = text;
217 if (savedRange.location != NSNotFound && savedRange.length > 0 &&
218 NSMaxRange(savedRange) <= text.length) {
219 _textView.selectedRange = savedRange;
220 }
221 _suppressSelectionChange = NO;
222 }
223 if (frameChanged)
224 _textView.textContainer.size = CGSizeMake(CGRectGetWidth(_view.frame), CGFLOAT_MAX);
225 _textLayoutNeedsUpdate = NO;
226}
227
228- (void)updateProps:(Props::Shared const &)props
229 oldProps:(Props::Shared const &)oldProps {
230 const auto &oldViewProps =
231 *std::static_pointer_cast<T3SourceTextProps const>(_props);
232 const auto &newViewProps =
233 *std::static_pointer_cast<T3SourceTextProps const>(props);
234 if (oldViewProps.numberOfLines != newViewProps.numberOfLines) {
235 _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines;
236 }
237 if (oldViewProps.selectable != newViewProps.selectable)
238 _textView.selectable = newViewProps.selectable;
239 if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) {
240 _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling;
241 }
242 if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) {
243 switch (newViewProps.ellipsizeMode) {
244 case T3SourceTextEllipsizeMode::Head:
245 _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingHead;
246 break;
247 case T3SourceTextEllipsizeMode::Middle:
248 _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle;
249 break;
250 case T3SourceTextEllipsizeMode::Tail:
251 _textView.textContainer.lineBreakMode = NSLineBreakByTruncatingTail;
252 break;
253 case T3SourceTextEllipsizeMode::Clip:
254 _textView.textContainer.lineBreakMode = NSLineBreakByClipping;
255 break;
256 }
257 }
258 if (oldViewProps.backgroundColor != newViewProps.backgroundColor) {
259 _textView.backgroundColor =
260 RCTUIColorFromSharedColor(newViewProps.backgroundColor);
261 }
262 [super updateProps:props oldProps:oldProps];
263}
264
265- (void)updateState:(const facebook::react::State::Shared &)state
266 oldState:(const facebook::react::State::Shared &)oldState {
267 _state =
268 std::static_pointer_cast<const T3SourceTextShadowNode::ConcreteState>(
269 state);
270 [self setNeedsDisplay];
271}
272
273- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView {
274 if ([hitView isDescendantOfView:self])
275 return;
276 UITextView *textView = _textView;
277 // Allow a pending native Copy action to consume its selection first.
278 dispatch_async(dispatch_get_main_queue(), ^{
279 UITextRange *range = textView.selectedTextRange;
280 if (range != nil && !range.isEmpty)
281 textView.selectedTextRange = nil;
282 });
283}
284
285- (void)textViewDidChangeSelection:(UITextView *)textView {
286 if (_suppressSelectionChange || _eventEmitter == nullptr)
287 return;
288 const NSRange range = textView.selectedRange;
289 if (range.location == NSNotFound)
290 return;
291 std::dynamic_pointer_cast<const facebook::react::T3SourceTextEventEmitter>(
292 _eventEmitter)
293 ->onSelectionChange(
294 facebook::react::T3SourceTextEventEmitter::OnSelectionChange{
295 static_cast<int>(self.tag), static_cast<int>(range.location),
296 static_cast<int>(NSMaxRange(range))});
297}
298
299Class<RCTComponentViewProtocol> T3SourceTextCls(void) {
300 return T3SourceText.class;
301}
302@end
apps/mobile/modules/t3-source-text/ios/T3SourceTextComponentDescriptor.h created+14
...@@ -0,0 +1,14 @@
1#pragma once
2
3#include "T3SourceTextShadowNode.h"
4
5#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
6#include <react/renderer/core/ConcreteComponentDescriptor.h>
7
8namespace facebook::react {
9using T3SourceTextComponentDescriptor =
10 ConcreteComponentDescriptor<T3SourceTextShadowNode>;
11
12void T3SourceTextSpec_registerComponentDescriptorsFromCodegen(
13 std::shared_ptr<const ComponentDescriptorProviderRegistry> registry);
14} // namespace facebook::react
apps/mobile/modules/t3-source-text/ios/T3SourceTextManager.mm created+28
...@@ -0,0 +1,28 @@
1#import <React/RCTUIManager.h>
2#import <React/RCTViewManager.h>
3
4@interface T3SourceTextManager : RCTViewManager
5@end
6
7@implementation T3SourceTextManager
8
9RCT_EXPORT_MODULE(T3SourceText)
10
11- (UIView *)view {
12 return [[UIView alloc] init];
13}
14
15@end
16
17@interface T3SourceTextRunManager : RCTViewManager
18@end
19
20@implementation T3SourceTextRunManager
21
22RCT_EXPORT_MODULE(T3SourceTextRun)
23
24- (UIView *)view {
25 return nil;
26}
27
28@end
apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.h created+20
...@@ -0,0 +1,20 @@
1// This guard prevent this file to be compiled in the old architecture.
2#ifdef RCT_NEW_ARCH_ENABLED
3#import <React/RCTComponent.h>
4#import <React/RCTViewComponentView.h>
5#import <UIKit/UIKit.h>
6
7#ifndef T3SourceTextRunNativeComponent_h
8#define T3SourceTextRunNativeComponent_h
9
10NS_ASSUME_NONNULL_BEGIN
11
12@interface T3SourceTextRun : RCTViewComponentView
13
14@property(nonatomic, copy, nullable) NSString *text;
15@end
16
17NS_ASSUME_NONNULL_END
18
19#endif /* UitextviewViewNativeComponent_h */
20#endif /* RCT_NEW_ARCH_ENABLED */
apps/mobile/modules/t3-source-text/ios/T3SourceTextRun.mm created+54
...@@ -0,0 +1,54 @@
1#import "T3SourceTextRun.h"
2#import <React/RCTFabricComponentsPlugins.h>
3#import "T3SourceTextRunComponentDescriptor.h"
4#import <react/renderer/components/T3SourceTextSpec/Props.h>
5#import <react/renderer/components/T3SourceTextSpec/RCTComponentViewHelpers.h>
6
7using namespace facebook::react;
8
9@interface T3SourceTextRun () <RCTT3SourceTextRunViewProtocol>
10
11@end
12
13@implementation T3SourceTextRun {
14 NSString *_text;
15}
16
17+ (ComponentDescriptorProvider)componentDescriptorProvider {
18 return concreteComponentDescriptorProvider<
19 T3SourceTextRunComponentDescriptor>();
20}
21
22- (instancetype)initWithFrame:(CGRect)frame {
23 if (self = [super initWithFrame:frame]) {
24 static const auto defaultProps =
25 std::make_shared<const T3SourceTextRunProps>();
26 _props = defaultProps;
27 }
28 return self;
29}
30
31- (void)updateProps:(Props::Shared const &)props
32 oldProps:(Props::Shared const &)oldProps {
33 const auto &oldViewProps =
34 *std::static_pointer_cast<T3SourceTextRunProps const>(_props);
35 const auto &newViewProps =
36 *std::static_pointer_cast<T3SourceTextRunProps const>(props);
37
38 if (newViewProps.text != oldViewProps.text) {
39 NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()];
40 _text = text;
41 }
42
43 [super updateProps:props oldProps:oldProps];
44}
45
46+ (BOOL)shouldBeRecycled {
47 return NO;
48}
49
50Class<RCTComponentViewProtocol> T3SourceTextRunCls(void) {
51 return T3SourceTextRun.class;
52}
53
54@end
apps/mobile/modules/t3-source-text/ios/T3SourceTextRunComponentDescriptor.h created+14
...@@ -0,0 +1,14 @@
1#pragma once
2
3#include "T3SourceTextRunShadowNode.h"
4
5#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
6#include <react/renderer/core/ConcreteComponentDescriptor.h>
7
8namespace facebook::react {
9using T3SourceTextRunComponentDescriptor =
10 ConcreteComponentDescriptor<T3SourceTextRunShadowNode>;
11
12void T3SourceTextRunSpec_registerComponentDescriptorsFromCodegen(
13 std::shared_ptr<const ComponentDescriptorProviderRegistry> registry);
14} // namespace facebook::react
apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.cpp created+6
...@@ -0,0 +1,6 @@
1#include "T3SourceTextRunShadowNode.h"
2
3namespace facebook::react {
4
5extern const char T3SourceTextRunComponentName[] = "T3SourceTextRun";
6} // namespace facebook::react
apps/mobile/modules/t3-source-text/ios/T3SourceTextRunShadowNode.h created+14
...@@ -0,0 +1,14 @@
1#pragma once
2
3#include <react/renderer/components/T3SourceTextSpec/EventEmitters.h>
4#include <react/renderer/components/T3SourceTextSpec/Props.h>
5#include <react/renderer/components/T3SourceTextSpec/States.h>
6#include <react/renderer/components/view/ConcreteViewShadowNode.h>
7
8namespace facebook::react {
9extern const char T3SourceTextRunComponentName[];
10
11using T3SourceTextRunShadowNode =
12 ConcreteViewShadowNode<T3SourceTextRunComponentName, T3SourceTextRunProps,
13 T3SourceTextRunEventEmitter, T3SourceTextRunState>;
14} // namespace facebook::react
apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.h created+45
...@@ -0,0 +1,45 @@
1#pragma once
2
3#include <react/renderer/components/T3SourceTextSpec/EventEmitters.h>
4#include <react/renderer/components/T3SourceTextSpec/Props.h>
5#include <react/renderer/components/view/ConcreteViewShadowNode.h>
6#include <react/renderer/core/LayoutContext.h>
7#include <react/renderer/core/ShadowNode.h>
8#include <react/renderer/textlayoutmanager/TextLayoutManager.h>
9
10namespace facebook::react {
11
12extern const char T3SourceTextComponentName[];
13
14class T3SourceTextStateReal final {
15public:
16 AttributedString attributedString;
17};
18
19class T3SourceTextShadowNode final
20 : public ConcreteViewShadowNode<T3SourceTextComponentName,
21 T3SourceTextProps, T3SourceTextEventEmitter,
22 T3SourceTextStateReal> {
23public:
24 using ConcreteViewShadowNode::ConcreteViewShadowNode;
25
26 T3SourceTextShadowNode(const ShadowNode &sourceShadowNode,
27 const ShadowNodeFragment &fragment);
28
29 static ShadowNodeTraits BaseTraits() {
30 auto traits = ConcreteViewShadowNode::BaseTraits();
31 traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
32 traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode);
33 return traits;
34 }
35
36 void layout(LayoutContext layoutContext) override;
37
38 Size
39 measureContent(const LayoutContext &layoutContext,
40 const LayoutConstraints &layoutConstraints) const override;
41
42private:
43 mutable AttributedString _attributedString;
44};
45} // namespace facebook::react
apps/mobile/modules/t3-source-text/ios/T3SourceTextShadowNode.mm created+171
...@@ -0,0 +1,171 @@
1#include "T3SourceTextShadowNode.h"
2#include "T3SourceTextRunShadowNode.h"
3#include <react/renderer/components/view/ViewShadowNode.h>
4#import <react/renderer/textlayoutmanager/RCTAttributedTextUtils.h>
5
6#include <algorithm>
7#include <cmath>
8
9namespace facebook::react {
10
11T3SourceTextShadowNode::T3SourceTextShadowNode(
12 const ShadowNode &sourceShadowNode, const ShadowNodeFragment &fragment)
13 : ConcreteViewShadowNode(sourceShadowNode, fragment){};
14
15Size T3SourceTextShadowNode::measureContent(
16 const LayoutContext &layoutContext,
17 const LayoutConstraints &layoutConstraints) const {
18 const auto &baseProps = getConcreteProps();
19
20 auto baseTextAttributes = TextAttributes::defaultTextAttributes();
21 baseTextAttributes.backgroundColor = baseProps.backgroundColor;
22 baseTextAttributes.allowFontScaling = baseProps.allowFontScaling;
23
24 Float fontSizeMultiplier = 1.0;
25 if (baseTextAttributes.allowFontScaling) {
26 fontSizeMultiplier = layoutContext.fontSizeMultiplier;
27 }
28
29 auto baseAttributedString = AttributedString{};
30 const auto &children = getChildren();
31 for (size_t i = 0; i < children.size(); i++) {
32 const auto child = children[i].get();
33 if (auto textViewChild =
34 dynamic_cast<const T3SourceTextRunShadowNode *>(child)) {
35 auto &props = textViewChild->getConcreteProps();
36 auto fragment = AttributedString::Fragment{};
37 auto textAttributes = TextAttributes::defaultTextAttributes();
38
39 textAttributes.allowFontScaling = baseProps.allowFontScaling;
40 textAttributes.backgroundColor = props.backgroundColor;
41 textAttributes.fontSize = props.fontSize * fontSizeMultiplier;
42 textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier;
43 textAttributes.foregroundColor = props.color;
44 textAttributes.textShadowColor = props.shadowColor;
45 textAttributes.textShadowOffset = props.shadowOffset;
46 textAttributes.textShadowRadius = props.shadowRadius;
47 textAttributes.letterSpacing = props.letterSpacing;
48 textAttributes.textDecorationColor = props.textDecorationColor;
49 textAttributes.fontFamily = props.fontFamily;
50
51 if (props.fontStyle == T3SourceTextRunFontStyle::Italic) {
52 textAttributes.fontStyle = FontStyle::Italic;
53 } else {
54 textAttributes.fontStyle = FontStyle::Normal;
55 }
56
57 if (props.fontWeight == T3SourceTextRunFontWeight::Bold) {
58 textAttributes.fontWeight = FontWeight::Bold;
59 } else if (props.fontWeight == T3SourceTextRunFontWeight::UltraLight) {
60 textAttributes.fontWeight = FontWeight::UltraLight;
61 } else if (props.fontWeight == T3SourceTextRunFontWeight::Light) {
62 textAttributes.fontWeight = FontWeight::Light;
63 } else if (props.fontWeight == T3SourceTextRunFontWeight::Medium) {
64 textAttributes.fontWeight = FontWeight::Medium;
65 } else if (props.fontWeight == T3SourceTextRunFontWeight::Semibold) {
66 textAttributes.fontWeight = FontWeight::Semibold;
67 } else if (props.fontWeight == T3SourceTextRunFontWeight::Heavy) {
68 textAttributes.fontWeight = FontWeight::Heavy;
69 } else {
70 textAttributes.fontWeight = FontWeight::Regular;
71 }
72
73 if (props.textDecorationLine ==
74 T3SourceTextRunTextDecorationLine::LineThrough) {
75 textAttributes.textDecorationLineType =
76 TextDecorationLineType::Strikethrough;
77 } else if (props.textDecorationLine ==
78 T3SourceTextRunTextDecorationLine::Underline) {
79 textAttributes.textDecorationLineType =
80 TextDecorationLineType::Underline;
81 } else {
82 textAttributes.textDecorationLineType = TextDecorationLineType::None;
83 }
84
85 if (props.textDecorationStyle ==
86 T3SourceTextRunTextDecorationStyle::Solid) {
87 textAttributes.textDecorationStyle = TextDecorationStyle::Solid;
88 } else if (props.textDecorationStyle ==
89 T3SourceTextRunTextDecorationStyle::Dotted) {
90 textAttributes.textDecorationStyle = TextDecorationStyle::Dotted;
91 } else if (props.textDecorationStyle ==
92 T3SourceTextRunTextDecorationStyle::Dashed) {
93 textAttributes.textDecorationStyle = TextDecorationStyle::Dashed;
94 } else if (props.textDecorationStyle ==
95 T3SourceTextRunTextDecorationStyle::Double) {
96 textAttributes.textDecorationStyle = TextDecorationStyle::Double;
97 }
98
99 if (props.textAlign == T3SourceTextRunTextAlign::Left) {
100 textAttributes.alignment = TextAlignment::Left;
101 } else if (props.textAlign == T3SourceTextRunTextAlign::Right) {
102 textAttributes.alignment = TextAlignment::Right;
103 } else if (props.textAlign == T3SourceTextRunTextAlign::Center) {
104 textAttributes.alignment = TextAlignment::Center;
105 } else if (props.textAlign == T3SourceTextRunTextAlign::Justify) {
106 textAttributes.alignment = TextAlignment::Justified;
107 } else if (props.textAlign == T3SourceTextRunTextAlign::Auto) {
108 textAttributes.alignment = TextAlignment::Natural;
109 }
110
111 textAttributes.backgroundColor = props.backgroundColor;
112
113 fragment.string = props.text;
114 fragment.textAttributes = textAttributes;
115
116 baseAttributedString.appendFragment(std::move(fragment));
117 }
118 }
119
120 _attributedString = baseAttributedString;
121
122 NSMutableAttributedString *convertedAttributedString =
123 [RCTNSAttributedStringFromAttributedString(baseAttributedString)
124 mutableCopy];
125 // Match React Native's baseline offset in both measurement and rendering.
126 RCTApplyBaselineOffset(convertedAttributedString);
127
128 const CGFloat maximumWidth =
129 std::isfinite(layoutConstraints.maximumSize.width)
130 ? layoutConstraints.maximumSize.width
131 : CGFLOAT_MAX;
132 NSTextStorage *textStorage = [[NSTextStorage alloc]
133 initWithAttributedString:convertedAttributedString];
134 NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
135 layoutManager.usesFontLeading = NO;
136 NSTextContainer *textContainer = [[NSTextContainer alloc]
137 initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)];
138 textContainer.lineFragmentPadding = 0;
139 textContainer.maximumNumberOfLines = baseProps.numberOfLines;
140 if (baseProps.ellipsizeMode == T3SourceTextEllipsizeMode::Head) {
141 textContainer.lineBreakMode = NSLineBreakByTruncatingHead;
142 } else if (baseProps.ellipsizeMode == T3SourceTextEllipsizeMode::Middle) {
143 textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle;
144 } else if (baseProps.ellipsizeMode == T3SourceTextEllipsizeMode::Tail) {
145 textContainer.lineBreakMode = NSLineBreakByTruncatingTail;
146 } else {
147 textContainer.lineBreakMode = NSLineBreakByClipping;
148 }
149 [layoutManager addTextContainer:textContainer];
150 [textStorage addLayoutManager:layoutManager];
151 [layoutManager ensureLayoutForTextContainer:textContainer];
152 const CGRect usedRect =
153 [layoutManager usedRectForTextContainer:textContainer];
154
155 return {
156 std::clamp(static_cast<Float>(std::ceil(usedRect.size.width)),
157 layoutConstraints.minimumSize.width,
158 layoutConstraints.maximumSize.width),
159 std::clamp(static_cast<Float>(std::ceil(usedRect.size.height)),
160 layoutConstraints.minimumSize.height,
161 layoutConstraints.maximumSize.height),
162 };
163}
164
165void T3SourceTextShadowNode::layout(LayoutContext layoutContext) {
166 ensureUnsealed();
167 setStateData(T3SourceTextStateReal{
168 _attributedString,
169 });
170}
171} // namespace facebook::react
apps/mobile/modules/t3-source-text/package.json created+35
...@@ -0,0 +1,35 @@
1{
2 "name": "@t3tools/mobile-source-text",
3 "version": "0.0.0",
4 "private": true,
5 "files": [
6 "ios",
7 "src",
8 "index.ts",
9 "LICENSE",
10 "UPSTREAM.md",
11 "T3SourceText.podspec",
12 "react-native.config.js"
13 ],
14 "main": "./index.ts",
15 "types": "./index.ts",
16 "react-native": "./index.ts",
17 "peerDependencies": {
18 "react": "*",
19 "react-native": "*"
20 },
21 "codegenConfig": {
22 "name": "T3SourceTextSpec",
23 "type": "components",
24 "jsSrcsDir": "src",
25 "ios": {
26 "componentProvider": {
27 "T3SourceText": "T3SourceText",
28 "T3SourceTextRun": "T3SourceTextRun"
29 }
30 },
31 "outputDir": {
32 "ios": "ios/generated"
33 }
34 }
35}
apps/mobile/modules/t3-source-text/react-native.config.js created+10
...@@ -0,0 +1,10 @@
1module.exports = {
2 dependency: {
3 platforms: {
4 ios: {
5 podspecPath: "T3SourceText.podspec",
6 },
7 android: null,
8 },
9 },
10};
apps/mobile/modules/t3-source-text/src/SourceText.tsx created+54
...@@ -0,0 +1,54 @@
1import React, { type ComponentRef, type Ref } from "react";
2import { Platform, StyleSheet, Text, type TextProps, type TextStyle } from "react-native";
3import T3SourceTextRunNativeComponent from "./T3SourceTextRunNativeComponent";
4import T3SourceTextNativeComponent from "./T3SourceTextNativeComponent";
5import { flattenStyles } from "./util";
6
7const TextAncestorContext = React.createContext<[boolean, TextStyle]>([false, {}]);
8
9export type SourceTextProps = Omit<TextProps, "onTextLayout" | "onPress" | "onLongPress"> & {
10 nativeTextRef?: Ref<ComponentRef<typeof Text>>;
11 onSelectionChange?: (event: {
12 nativeEvent: { target: number; start: number; end: number };
13 }) => void;
14};
15
16/** One selectable document on iOS, with nested runs carrying syntax colors. */
17export function SourceText({ style, children, nativeTextRef, ...props }: SourceTextProps) {
18 const [isAncestor, rootStyle] = React.useContext(TextAncestorContext);
19 const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]);
20 const contextValue = React.useMemo<[boolean, TextStyle]>(
21 () => [true, StyleSheet.flatten([rootStyle, style]) ?? {}],
22 [rootStyle, style],
23 );
24 if (Platform.OS !== "ios") {
25 const { onSelectionChange: _onSelectionChange, ...textProps } = props;
26 return (
27 <Text ref={nativeTextRef} style={style} {...textProps}>
28 {children}
29 </Text>
30 );
31 }
32 const runs = React.Children.toArray(children).map((child, index) => {
33 if (React.isValidElement(child)) return child;
34 if (typeof child !== "string" && typeof child !== "number") return null;
35 return (
36 <T3SourceTextRunNativeComponent
37 key={index}
38 style={flattenedStyle}
39 {...flattenedStyle}
40 text={String(child)}
41 />
42 );
43 });
44 if (isAncestor) {
45 return <TextAncestorContext.Provider value={contextValue}>{runs}</TextAncestorContext.Provider>;
46 }
47 return (
48 <TextAncestorContext.Provider value={contextValue}>
49 <T3SourceTextNativeComponent allowFontScaling selectable {...props} style={flattenedStyle}>
50 {runs}
51 </T3SourceTextNativeComponent>
52 </TextAncestorContext.Provider>
53 );
54}
apps/mobile/modules/t3-source-text/src/T3SourceTextNativeComponent.ts created+45
...@@ -0,0 +1,45 @@
1import { codegenNativeComponent, type CodegenTypes } from "react-native";
2import type { ViewProps } from "react-native";
3
4interface TargetedEvent {
5 target: CodegenTypes.Int32;
6}
7
8/**
9 * Event fired when text selection changes in the SourceText.
10 * @property target - The view tag identifier
11 * @property start - The start index of the selected range (0-based)
12 * @property end - The end index of the selected range (0-based, exclusive)
13 */
14interface SelectionChangeEvent extends TargetedEvent {
15 start: CodegenTypes.Int32;
16 end: CodegenTypes.Int32;
17}
18
19type EllipsizeMode = "head" | "middle" | "tail" | "clip";
20
21interface NativeProps extends ViewProps {
22 numberOfLines?: CodegenTypes.Int32;
23 allowFontScaling?: CodegenTypes.WithDefault<boolean, true>;
24 ellipsizeMode?: CodegenTypes.WithDefault<EllipsizeMode, "tail">;
25 selectable?: boolean;
26 /**
27 * Callback fired when the text selection changes.
28 *
29 * @example
30 * ```tsx
31 * <SourceText
32 * onSelectionChange={(event) => {
33 * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end);
34 * }}
35 * >
36 * Selectable text
37 * </SourceText>
38 * ```
39 */
40 onSelectionChange?: CodegenTypes.BubblingEventHandler<SelectionChangeEvent>;
41}
42
43export default codegenNativeComponent<NativeProps>("T3SourceText", {
44 excludedPlatforms: ["android"],
45});
apps/mobile/modules/t3-source-text/src/T3SourceTextRunNativeComponent.ts created+39
...@@ -0,0 +1,39 @@
1import { codegenNativeComponent, type CodegenTypes } from "react-native";
2import type { ColorValue, ViewProps } from "react-native";
3
4type TextDecorationLine = "none" | "underline" | "line-through";
5
6type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed";
7
8export type NativeFontWeight =
9 | "normal"
10 | "bold"
11 | "ultraLight"
12 | "light"
13 | "medium"
14 | "semibold"
15 | "heavy";
16
17type FontStyle = "normal" | "italic";
18
19type TextAlign = "auto" | "left" | "right" | "center" | "justify";
20
21interface NativeProps extends ViewProps {
22 text: string;
23 color?: ColorValue;
24 fontSize?: CodegenTypes.Float;
25 fontStyle?: CodegenTypes.WithDefault<FontStyle, "normal">;
26 fontWeight?: CodegenTypes.WithDefault<NativeFontWeight, "normal">;
27 fontFamily?: string;
28 letterSpacing?: CodegenTypes.Float;
29 lineHeight?: CodegenTypes.Float;
30 textDecorationLine?: CodegenTypes.WithDefault<TextDecorationLine, "none">;
31 textDecorationStyle?: CodegenTypes.WithDefault<TextDecorationStyle, "solid">;
32 textDecorationColor?: ColorValue;
33 textAlign?: CodegenTypes.WithDefault<TextAlign, "auto">;
34 shadowRadius?: CodegenTypes.WithDefault<CodegenTypes.Float, 0>;
35}
36
37export default codegenNativeComponent<NativeProps>("T3SourceTextRun", {
38 excludedPlatforms: ["android"],
39});
apps/mobile/modules/t3-source-text/src/util.ts created+79
...@@ -0,0 +1,79 @@
1import { I18nManager, type StyleProp, StyleSheet, type TextStyle } from "react-native";
2import type { NativeFontWeight } from "./T3SourceTextRunNativeComponent";
3
4export function flattenStyles(rootStyle: TextStyle, style: StyleProp<TextStyle>) {
5 const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle;
6 const textDecorationLine = flattenedStyle.textDecorationLine;
7 return {
8 ...flattenedStyle,
9 textAlign:
10 flattenedStyle.textAlign === "start"
11 ? I18nManager.isRTL
12 ? ("right" as const)
13 : ("left" as const)
14 : flattenedStyle.textAlign === "end"
15 ? I18nManager.isRTL
16 ? ("left" as const)
17 : ("right" as const)
18 : flattenedStyle.textAlign,
19 textDecorationStyle:
20 flattenedStyle.textDecorationStyle === "wavy" ? "solid" : flattenedStyle.textDecorationStyle,
21 textDecorationLine:
22 textDecorationLine === "underline" || textDecorationLine === "line-through"
23 ? textDecorationLine
24 : ("none" as const),
25 fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"),
26 backgroundColor: flattenedStyle.backgroundColor
27 ? flattenedStyle.backgroundColor
28 : "transparent",
29 shadowOffset: flattenedStyle.shadowOffset
30 ? flattenedStyle.shadowOffset
31 : { width: 0, height: 0 },
32 };
33}
34
35// Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop
36// value before returning flattened styles.
37function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight {
38 switch (fontWeight) {
39 case "normal":
40 return "normal";
41 case "bold":
42 return "bold";
43 case 100:
44 case "100":
45 case "ultralight":
46 return "ultraLight";
47 case 200:
48 case "200":
49 return "ultraLight";
50 case 300:
51 case "300":
52 case "light":
53 return "light";
54 case 400:
55 case "400":
56 case "regular":
57 return "normal";
58 case 500:
59 case "500":
60 case "medium":
61 return "medium";
62 case 600:
63 case "600":
64 case "semibold":
65 return "semibold";
66 case 700:
67 case "700":
68 return "semibold";
69 case 800:
70 case "800":
71 return "bold";
72 case 900:
73 case "900":
74 case "heavy":
75 return "heavy";
76 default:
77 return "normal";
78 }
79}
apps/mobile/package.json+13-5
...@@ -40,7 +40,7 @@...@@ -40,7 +40,7 @@
40 "config:preview": "APP_VARIANT=preview expo config",40 "config:preview": "APP_VARIANT=preview expo config",
41 "config:prod": "APP_VARIANT=production expo config",41 "config:prod": "APP_VARIANT=production expo config",
42 "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review",42 "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review",
43 "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs",43 "sync:pierre-icons": "node scripts/sync-pierre-file-icons.mjs",
44 "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts",44 "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts",
45 "test": "vp test run --config ../../vite.config.ts --dir .",45 "test": "vp test run --config ../../vite.config.ts --dir .",
46 "typecheck": "tsc --noEmit"46 "typecheck": "tsc --noEmit"
...@@ -66,8 +66,8 @@...@@ -66,8 +66,8 @@
66 "@shikijs/themes": "4.2.0",66 "@shikijs/themes": "4.2.0",
67 "@t3tools/client-runtime": "workspace:*",67 "@t3tools/client-runtime": "workspace:*",
68 "@t3tools/contracts": "workspace:*",68 "@t3tools/contracts": "workspace:*",
69 "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text",
70 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",69 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
70 "@t3tools/mobile-source-text": "file:./modules/t3-source-text",
71 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",71 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
72 "@t3tools/provider-acp-registry": "workspace:*",72 "@t3tools/provider-acp-registry": "workspace:*",
73 "@t3tools/provider-core": "workspace:*",73 "@t3tools/provider-core": "workspace:*",
...@@ -120,11 +120,10 @@...@@ -120,11 +120,10 @@
120 "react": "19.3.0",120 "react": "19.3.0",
121 "react-dom": "19.3.0",121 "react-dom": "19.3.0",
122 "react-native": "0.88.0-rc.3",122 "react-native": "0.88.0-rc.3",
123 "react-native-enriched-markdown": "file:deps/react-native-enriched-markdown-1.2.0-t3.10.tgz",
123 "react-native-gesture-handler": "~3.2.1",124 "react-native-gesture-handler": "~3.2.1",
124 "react-native-image-viewing": "^0.2.2",125 "react-native-image-viewing": "^0.2.2",
125 "react-native-keyboard-controller": "1.22.6",126 "react-native-keyboard-controller": "1.22.6",
126 "react-native-nitro-markdown": "^0.5.0",
127 "react-native-nitro-modules": "0.35.9",
128 "react-native-reanimated": "4.7.0",127 "react-native-reanimated": "4.7.0",
129 "react-native-safe-area-context": "~5.9.1",128 "react-native-safe-area-context": "~5.9.1",
130 "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.24.tgz",129 "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.24.tgz",
...@@ -132,13 +131,17 @@...@@ -132,13 +131,17 @@
132 "react-native-svg": "15.15.5",131 "react-native-svg": "15.15.5",
133 "react-native-webview": "14.0.1",132 "react-native-webview": "14.0.1",
134 "react-native-worklets": "0.13.0",133 "react-native-worklets": "0.13.0",
134 "remark-gfm": "catalog:",
135 "remark-parse": "catalog:",
135 "shiki": "4.2.0",136 "shiki": "4.2.0",
136 "tailwind-merge": "^3.5.0",137 "tailwind-merge": "^3.5.0",
138 "unified": "catalog:",
137 "uniwind": "1.12.1"139 "uniwind": "1.12.1"
138 },140 },
139 "devDependencies": {141 "devDependencies": {
140 "@effect/vitest": "catalog:",142 "@effect/vitest": "catalog:",
141 "@pierre/trees": "1.0.0-beta.6",143 "@pierre/trees": "1.0.0-beta.6",
144 "@types/mdast": "^4.0.4",
142 "@types/react": "~19.3.0",145 "@types/react": "~19.3.0",
143 "@types/react-dom": "~19.2.3",146 "@types/react-dom": "~19.2.3",
144 "babel-preset-expo": "~58.0.8",147 "babel-preset-expo": "~58.0.8",
...@@ -146,12 +149,17 @@...@@ -146,12 +149,17 @@
146 "tailwindcss": "^4.0.0",149 "tailwindcss": "^4.0.0",
147 "typescript": "catalog:"150 "typescript": "catalog:"
148 },151 },
152 "enriched-markdown": {
153 "enableMath": false,
154 "enableVideo": false
155 },
149 "expo": {156 "expo": {
150 "install": {157 "install": {
151 "exclude": [158 "exclude": [
152 "react-native-keyboard-controller",159 "react-native-keyboard-controller",
153 "typescript",160 "typescript",
154 "react-native-screens"161 "react-native-screens",
162 "react-native-enriched-markdown"
155 ]163 ]
156 },164 },
157 "autolinking": {165 "autolinking": {
apps/mobile/scripts/sync-pierre-file-icons.mjs created+134
...@@ -0,0 +1,134 @@
1import * as NodeChildProcess from "node:child_process";
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as NodeURL from "node:url";
5
6import { getBuiltInSpriteSheet } from "@pierre/trees";
7
8const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
9const appDirectory = NodePath.resolve(scriptDirectory, "..");
10const repositoryRoot = NodePath.resolve(appDirectory, "../..");
11const outputDirectory = NodePath.join(appDirectory, "assets/file-icons");
12const generatedModulePath = NodePath.join(appDirectory, "src/lib/markdownFileIcons.generated.ts");
13const webIconSource = NodeFS.readFileSync(
14 NodePath.join(repositoryRoot, "apps/web/src/pierre-icons.ts"),
15 "utf8",
16);
17const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1];
18
19if (!customSprite) {
20 throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts");
21}
22
23const colors = {
24 astro: "#a631be",
25 babel: "#d5a910",
26 bash: "#199f43",
27 biome: "#1a85d4",
28 bootstrap: "#693acf",
29 browserslist: "#d5a910",
30 bun: "#594c5b",
31 c: "#1a85d4",
32 claude: "#d47628",
33 cpp: "#1a85d4",
34 css: "#693acf",
35 database: "#a631be",
36 default: "#84848a",
37 docker: "#1a85d4",
38 eslint: "#693acf",
39 font: "#84848a",
40 git: "#ff8c5b",
41 go: "#1ca1c7",
42 graphql: "#d32a61",
43 html: "#d47628",
44 image: "#d32a61",
45 javascript: "#d5a910",
46 json: "#d47628",
47 markdown: "#199f43",
48 mcp: "#17a5af",
49 nextjs: "#84848a",
50 npm: "#d52c36",
51 oxc: "#1ca1c7",
52 postcss: "#d52c36",
53 prettier: "#17a5af",
54 python: "#1a85d4",
55 react: "#1ca1c7",
56 ruby: "#d52c36",
57 rust: "#d47628",
58 sass: "#d32a61",
59 stylelint: "#84848a",
60 svelte: "#d52c36",
61 svg: "#d47628",
62 svgo: "#199f43",
63 swift: "#d47628",
64 table: "#17a5af",
65 tailwind: "#1ca1c7",
66 terraform: "#693acf",
67 text: "#84848a",
68 typescript: "#1a85d4",
69 video: "#a631be",
70 vite: "#a631be",
71 vscode: "#1a85d4",
72 vue: "#199f43",
73 wasm: "#693acf",
74 webpack: "#1a85d4",
75 yml: "#d52c36",
76 zig: "#d47628",
77 zip: "#d47628",
78};
79
80const customIcons = {
81 agents: "t3-file-icon-agents",
82 pnpm: "t3-file-icon-pnpm",
83 video: "t3-file-icon-video",
84};
85
86function symbolFromSprite(sprite, id) {
87 const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
88 const match = sprite.match(
89 new RegExp(`<symbol id="${escapedId}"([^>]*)>([\\s\\S]*?)<\\/symbol>`),
90 );
91 if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`);
92 return {
93 body: match[2],
94 viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16",
95 };
96}
97
98function renderIcon(token, symbol, color) {
99 const svgPath = NodePath.join(outputDirectory, `.pierre-${token}.svg`);
100 const pngPath = NodePath.join(outputDirectory, `pierre_${token}.png`);
101 NodeFS.writeFileSync(
102 svgPath,
103 `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="${symbol.viewBox}" style="color:${color}">${symbol.body}</svg>`,
104 );
105 NodeChildProcess.execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], {
106 stdio: "ignore",
107 });
108 NodeFS.rmSync(svgPath);
109}
110
111NodeFS.rmSync(outputDirectory, { recursive: true, force: true });
112NodeFS.mkdirSync(outputDirectory, { recursive: true });
113
114const builtInSprite = getBuiltInSpriteSheet("complete");
115const builtInTokens = [...builtInSprite.matchAll(/<symbol id="file-tree-builtin-([^"]+)"/g)]
116 .map((match) => match[1])
117 .sort();
118
119for (const token of builtInTokens) {
120 renderIcon(
121 token,
122 symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`),
123 colors[token] ?? colors.default,
124 );
125}
126for (const [token, symbolId] of Object.entries(customIcons)) {
127 renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default);
128}
129
130const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort();
131const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens
132 .map((token) => ` ${token}: require("../../assets/file-icons/pierre_${token}.png"),`)
133 .join("\n")}\n} as const satisfies Readonly<Record<string, ImageSourcePropType>>;\n`;
134NodeFS.writeFileSync(generatedModulePath, generatedSource);
apps/mobile/src/components/AppSheet.tsx created+80
...@@ -0,0 +1,80 @@
1import { useId, useState, type ReactNode } from "react";
2import { Platform, View } from "react-native";
3import { FormSheet, Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
4import { useResolveClassNames, withUniwind } from "uniwind";
5
6import { AndroidSheetHeader } from "./AndroidScreenHeader";
7import { nativeHeaderScrollEdgeEffects } from "../native/StackHeader";
8
9const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
10const NativeScreen = withUniwind(Screen);
11const NativeHeader = withUniwind(ScreenStackHeaderConfig, {
12 color: { fromClassName: "tintColorClassName", styleProperty: "accentColor" },
13 titleColor: { fromClassName: "titleColorClassName", styleProperty: "accentColor" },
14});
15
16/** Native presentation for local context whose owner supplies its dismissal callback. */
17export function AppSheet(props: {
18 readonly title: string;
19 readonly children: ReactNode;
20 readonly onClose: () => void;
21}) {
22 const containerStyle = useResolveClassNames("bg-sheet");
23 const screenId = useId();
24 const [isOpen, setIsOpen] = useState(true);
25 const close = () => setIsOpen(false);
26
27 return (
28 <FormSheet
29 isOpen={isOpen}
30 detents={[0.5, 0.9]}
31 prefersGrabberVisible
32 nativeContainerStyle={containerStyle}
33 onDismiss={props.onClose}
34 onNativeDismiss={props.onClose}
35 >
36 <View collapsable={false} className="flex-1 bg-sheet">
37 {Platform.OS === "ios" ? (
38 <ScreenStack style={{ flex: 1 }}>
39 <NativeScreen
40 activityState={2}
41 enabled
42 isNativeStack
43 screenId={`app-sheet-${screenId}`}
44 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
45 className="flex-1 bg-sheet"
46 >
47 {props.children}
48 <NativeHeader
49 backgroundColor="rgba(0,0,0,0)"
50 tintColorClassName="accent-foreground"
51 hideBackButton
52 hideShadow={false}
53 title={props.title}
54 titleColorClassName="accent-foreground"
55 titleFontSize={18}
56 titleFontWeight="800"
57 translucent
58 headerRightBarButtonItems={[
59 {
60 type: "button",
61 title: "Done",
62 variant: "done",
63 accessibilityLabel: `Close ${props.title}`,
64 identifier: `app-sheet-done-${screenId}`,
65 onPress: close,
66 },
67 ]}
68 />
69 </NativeScreen>
70 </ScreenStack>
71 ) : (
72 <>
73 <AndroidSheetHeader title={props.title} onBack={close} />
74 {props.children}
75 </>
76 )}
77 </View>
78 </FormSheet>
79 );
80}
apps/mobile/src/components/ComposerContextSheet.tsx+192-267
...@@ -10,16 +10,7 @@ import type {...@@ -10,16 +10,7 @@ import type {
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";11import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
12import { videoMimeType } from "@t3tools/shared/video";12import { videoMimeType } from "@t3tools/shared/video";
13import { useState } from "react";13import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
14import {
15 Alert,
16 Modal,
17 Platform,
18 Pressable,
19 ScrollView,
20 useWindowDimensions,
21 View,
22} from "react-native";
23import { useSafeAreaInsets } from "react-native-safe-area-context";14import { useSafeAreaInsets } from "react-native-safe-area-context";
24import { REVIEW_MONO_FONT_FAMILY } from "../features/review/reviewDiffRendering";15import { REVIEW_MONO_FONT_FAMILY } from "../features/review/reviewDiffRendering";
25import { ReviewCommentCard, useReviewCommentColors } from "../features/review/ReviewCommentCard";16import { ReviewCommentCard, useReviewCommentColors } from "../features/review/ReviewCommentCard";
...@@ -33,8 +24,7 @@ import { FilePreviewModal } from "./FilePreviewModal";...@@ -33,8 +24,7 @@ import { FilePreviewModal } from "./FilePreviewModal";
33import { VideoPreviewModal } from "./VideoPreviewModal";24import { VideoPreviewModal } from "./VideoPreviewModal";
34import { ComposerContextAttachment } from "./ComposerContextAttachment";25import { ComposerContextAttachment } from "./ComposerContextAttachment";
35import { AppText as Text } from "./AppText";26import { AppText as Text } from "./AppText";
36import { SymbolView } from "./AppSymbol";27import { AppSheet } from "./AppSheet";
37import { ContextSheetSize } from "./ContextSheetSize";
38import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";28import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
39import { getMobileTerminalTheme } from "../features/terminal/terminalTheme";29import { getMobileTerminalTheme } from "../features/terminal/terminalTheme";
4030
...@@ -106,12 +96,8 @@ export function ComposerContextSheet(props: {...@@ -106,12 +96,8 @@ export function ComposerContextSheet(props: {
106 });96 });
107 const reviewColors = useReviewCommentColors();97 const reviewColors = useReviewCommentColors();
108 const insets = useSafeAreaInsets();98 const insets = useSafeAreaInsets();
109 const { height: windowHeight } = useWindowDimensions();
110 const { themeId, themeAppearance } = useAppearancePreferences();99 const { themeId, themeAppearance } = useAppearancePreferences();
111 const terminalTheme = getMobileTerminalTheme(themeId, themeAppearance);100 const terminalTheme = getMobileTerminalTheme(themeId, themeAppearance);
112 const [headerHeight, setHeaderHeight] = useState(0);
113 const [bodyHeight, setBodyHeight] = useState(0);
114 const measuredHeight = headerHeight + bodyHeight;
115 const record = props.record;101 const record = props.record;
116 const localAttachment =102 const localAttachment =
117 record && "attachmentId" in record103 record && "attachmentId" in record
...@@ -197,274 +183,213 @@ export function ComposerContextSheet(props: {...@@ -197,274 +183,213 @@ export function ComposerContextSheet(props: {
197 : undefined;183 : undefined;
198 const terminal = record?.kind === "terminal" && !("payload" in record) ? record : null;184 const terminal = record?.kind === "terminal" && !("payload" in record) ? record : null;
199 return (185 return (
200 <Modal186 <AppSheet title={terminal?.terminalLabel ?? props.label} onClose={props.onClose}>
201 animationType="slide"187 <ScrollView
202 presentationStyle={Platform.OS === "android" ? "overFullScreen" : "pageSheet"}188 className="flex-1"
203 transparent={Platform.OS === "android"}189 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
204 onRequestClose={props.onClose}190 contentContainerStyle={{
205 >191 paddingHorizontal: 20,
206 <View192 paddingTop: 12,
207 className={193 gap: 16,
208 Platform.OS === "android" ? "flex-1 justify-end bg-backdrop" : "flex-1 justify-end"194 paddingBottom: Math.max(20, insets.bottom),
209 }195 }}
210 >196 >
211 {Platform.OS === "android" ? (197 {terminal ? (
212 <Pressable198 <Text className="text-sm text-foreground-muted">
213 accessibilityRole="button"199 Lines {terminal.lineStart}–{terminal.lineEnd}
214 accessibilityLabel="Dismiss context"200 </Text>
215 onPress={props.onClose}
216 style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }}
217 />
218 ) : null}201 ) : null}
219 <View202 {!record ? (
220 className="overflow-hidden rounded-t-3xl bg-sheet-solid"203 <Text className="text-foreground">
221 style={204 Context unavailable. The reference was copied without its payload. Copy it again from
222 Platform.OS === "android"205 the original message or remove it.
223 ? {206 </Text>
224 height: Math.min(207 ) : "payload" in record ? (
225 measuredHeight || windowHeight * 0.5,208 <Text className="text-foreground">
226 windowHeight - insets.top - 24,209 This context type is not supported by this version of the app. Its payload will be
227 ),210 preserved when sent.
228 }211 </Text>
229 : { flex: 1 }212 ) : (
230 }213 <>
231 >214 {record.kind === "terminal" ? (
232 <ContextSheetSize height={measuredHeight} />215 <View
233 <View216 className="overflow-hidden rounded-xl"
234 onLayout={(event) => setHeaderHeight(event.nativeEvent.layout.height)}217 style={{ backgroundColor: terminalTheme.background }}
235 className="flex-row items-center justify-between gap-3 border-b border-border px-4 pb-2 pt-4"218 >
236 >219 <ScrollView
237 {terminal ? (220 horizontal
238 <SymbolView name="terminal" size={20} tintColor={terminalTheme.palette[2]} />221 showsHorizontalScrollIndicator
222 contentContainerStyle={{ padding: 12 }}
223 >
224 <Text
225 selectable
226 className="text-sm text-foreground"
227 style={{
228 fontFamily: REVIEW_MONO_FONT_FAMILY,
229 fontSize: 13,
230 lineHeight: 20,
231 color: terminalTheme.foreground,
232 }}
233 >
234 {record.text}
235 </Text>
236 </ScrollView>
237 </View>
239 ) : null}238 ) : null}
240 <View className="min-w-0 flex-1">239 {record.kind === "review-comment" ? (
241 <Text className="text-base font-t3-semibold text-foreground" numberOfLines={2}>
242 {terminal?.terminalLabel ?? props.label}
243 </Text>
244 {terminal ? (
245 <Text className="text-xs text-foreground-muted">
246 Lines {terminal.lineStart}–{terminal.lineEnd}
247 </Text>
248 ) : null}
249 </View>
250 <Pressable
251 accessibilityRole="button"
252 accessibilityLabel="Close context"
253 onPress={props.onClose}
254 className="p-3"
255 >
256 <Text className="text-foreground">Done</Text>
257 </Pressable>
258 </View>
259 <ScrollView
260 style={{ flexShrink: 1 }}
261 onContentSizeChange={(_width, height) => setBodyHeight(height)}
262 contentContainerStyle={{
263 padding: 16,
264 gap: 16,
265 paddingBottom: Math.max(20, insets.bottom),
266 }}
267 >
268 {!record ? (
269 <Text className="text-foreground">
270 Context unavailable. The reference was copied without its payload. Copy it again
271 from the original message or remove it.
272 </Text>
273 ) : "payload" in record ? (
274 <Text className="text-foreground">
275 This context type is not supported by this version of the app. Its payload will be
276 preserved when sent.
277 </Text>
278 ) : (
279 <>240 <>
280 {record.kind === "terminal" ? (241 {record.pullRequest ? (
281 <View242 <ContextField
282 className="overflow-hidden rounded-xl border border-border"243 label={`#${record.pullRequest.number} · ${record.pullRequest.isDraft ? "draft" : record.pullRequest.state}`}
283 style={{ backgroundColor: terminalTheme.background }}244 value={`${record.pullRequest.title}\n${record.pullRequest.headBranch} → ${record.pullRequest.baseBranch}`}
284 >245 />
285 <ScrollView
286 horizontal
287 showsHorizontalScrollIndicator
288 contentContainerStyle={{ padding: 12 }}
289 >
290 <Text
291 selectable
292 className="text-sm text-foreground"
293 style={{
294 fontFamily: REVIEW_MONO_FONT_FAMILY,
295 fontSize: 13,
296 lineHeight: 20,
297 }}
298 >
299 {record.text}
300 </Text>
301 </ScrollView>
302 </View>
303 ) : null}
304 {record.kind === "review-comment" ? (
305 <>
306 {record.pullRequest ? (
307 <ContextField
308 label={`#${record.pullRequest.number} · ${record.pullRequest.isDraft ? "draft" : record.pullRequest.state}`}
309 value={`${record.pullRequest.title}\n${record.pullRequest.headBranch} → ${record.pullRequest.baseBranch}`}
310 />
311 ) : null}
312 {!record.sectionId.startsWith("pull-request:") ? (
313 <>
314 <ReviewCommentCard
315 comment={{ ...record, id: record.contextId }}
316 colors={reviewColors}
317 />
318 </>
319 ) : null}
320 </>
321 ) : null}246 ) : null}
322 {record.kind === "preview-annotation" ? (247 {!record.sectionId.startsWith("pull-request:") ? (
323 <>248 <>
324 <ContextField label="Page" value={record.pageTitle ?? record.pageUrl} />249 <ReviewCommentCard
325 <ContextField label="URL" value={record.pageUrl} />250 comment={{ ...record, id: record.contextId }}
326 <ContextField label="Comment" value={record.comment} />251 colors={reviewColors}
327 <ContextField label="Selection" value={record.targetSummary} />
328 <ContextField
329 label="Requested changes"
330 value={record.styleChanges.join("\n")}
331 />252 />
332 {record.elements?.map((element, index) => (
333 <View
334 key={`${element.selector ?? element.tagName}:${index}`}
335 className="gap-3"
336 >
337 <ContextField
338 label="Element"
339 value={element.componentName ?? element.tagName}
340 />
341 <ContextField label="Selector" value={element.selector} code />
342 <ContextSource source={element.source} />
343 <ContextField label="HTML" value={element.htmlPreview} code />
344 <ContextField label="Styles" value={element.styles} code />
345 </View>
346 ))}
347 </>
348 ) : null}
349 {record.kind === "element" ? (
350 <>
351 <ContextField label="Page" value={record.pageUrl} />
352 <ContextField label="Element" value={record.componentName ?? record.tagName} />
353 <ContextField label="Selector" value={record.selector} code />
354 <ContextSource source={record.source} />
355 <ContextField label="HTML" value={record.htmlPreview} code />
356 <ContextField label="Styles" value={record.styles} code />
357 </>253 </>
358 ) : null}254 ) : null}
359 {record.kind === "image" ? (255 </>
360 <ContextField256 ) : null}
361 label="File"257 {record.kind === "preview-annotation" ? (
362 value={`${record.mimeType} · ${formatAttachmentSize(record.sizeBytes)}`}258 <>
363 />259 <ContextField label="Page" value={record.pageTitle ?? record.pageUrl} />
364 ) : null}260 <ContextField label="URL" value={record.pageUrl} />
365 {record.kind === "mention" ? (261 <ContextField label="Comment" value={record.comment} />
366 <ContextField label="Path" value={record.path} code />262 <ContextField label="Selection" value={record.targetSummary} />
367 ) : null}263 <ContextField label="Requested changes" value={record.styleChanges.join("\n")} />
368 {record.kind === "thread" ? (264 {record.elements?.map((element, index) => (
369 <View className="gap-3">265 <View key={`${element.selector ?? element.tagName}:${index}`} className="gap-3">
370 <ContextField label="Thread" value={record.title} />
371 {props.onOpenThread ? (
372 <Pressable
373 accessibilityRole="button"
374 onPress={() =>
375 props.onOpenThread?.({
376 environmentId: record.environmentId,
377 threadId: record.threadId,
378 })
379 }
380 className="rounded-xl bg-subtle p-4"
381 >
382 <Text className="text-foreground">Open thread</Text>
383 </Pressable>
384 ) : null}
385 </View>
386 ) : null}
387 {record.kind === "skill" ? (
388 <View className="gap-3">
389 <ContextField label="Skill" value={record.name} />
390 <ContextField266 <ContextField
391 label="Description"267 label="Element"
392 value={268 value={element.componentName ?? element.tagName}
393 props.skillDescription ?? "No description is available for this skill."
394 }
395 />269 />
396 {props.onOpenSkill ? (270 <ContextField label="Selector" value={element.selector} code />
397 <Pressable271 <ContextSource source={element.source} />
398 accessibilityRole="button"272 <ContextField label="HTML" value={element.htmlPreview} code />
399 onPress={props.onOpenSkill}273 <ContextField label="Styles" value={element.styles} code />
400 className="rounded-xl bg-subtle p-4"
401 >
402 <Text className="text-foreground">View instructions</Text>
403 </Pressable>
404 ) : null}
405 </View>274 </View>
406 ) : null}275 ))}
407 </>276 </>
408 )}
409 {attachmentRecord && "attachmentId" in attachmentRecord ? (
410 <ComposerContextAttachment
411 key={JSON.stringify([
412 props.environmentId,
413 attachmentRecord.attachmentId,
414 props.attachments?.find((entry) => entry.id === attachmentRecord.attachmentId)
415 ?.fileUri,
416 ])}
417 record={attachmentRecord}
418 environmentId={props.environmentId}
419 attachment={props.attachments?.find(
420 (entry) => entry.id === attachmentRecord.attachmentId,
421 )}
422 />
423 ) : null}277 ) : null}
424 {pullRequestUrl && /^https?:\/\//i.test(pullRequestUrl) ? (278 {record.kind === "element" ? (
425 <Pressable279 <>
426 accessibilityRole="link"280 <ContextField label="Page" value={record.pageUrl} />
427 onPress={() => {281 <ContextField label="Element" value={record.componentName ?? record.tagName} />
428 props.onClose();282 <ContextField label="Selector" value={record.selector} code />
429 void openPullRequest(pullRequestUrl).catch(() =>283 <ContextSource source={record.source} />
430 Alert.alert("Could not open pull request", "Try again when connected."),284 <ContextField label="HTML" value={record.htmlPreview} code />
431 );285 <ContextField label="Styles" value={record.styles} code />
432 }}286 </>
433 className="rounded-xl bg-subtle p-4"
434 >
435 <Text className="text-foreground">Open pull request</Text>
436 </Pressable>
437 ) : null}287 ) : null}
438 {props.onOpenAttachment ? (288 {record.kind === "image" ? (
439 <Pressable289 <ContextField
440 accessibilityRole="button"290 label="File"
441 onPress={props.onOpenAttachment}291 value={`${record.mimeType} · ${formatAttachmentSize(record.sizeBytes)}`}
442 className="rounded-xl bg-subtle p-4"292 />
443 >
444 <Text className="text-foreground">Open attachment</Text>
445 </Pressable>
446 ) : null}293 ) : null}
447 {props.onOpenPullRequest && !pullRequestUrl ? (294 {record.kind === "mention" ? (
448 <Pressable295 <ContextField label="Path" value={record.path} code />
449 accessibilityRole="button"
450 onPress={props.onOpenPullRequest}
451 className="rounded-xl bg-subtle p-4"
452 >
453 <Text className="text-foreground">Open pull request</Text>
454 </Pressable>
455 ) : null}296 ) : null}
456 {props.onRemove ? (297 {record.kind === "thread" ? (
457 <Pressable298 <View className="gap-3">
458 accessibilityRole="button"299 <ContextField label="Thread" value={record.title} />
459 onPress={props.onRemove}300 {props.onOpenThread ? (
460 className="rounded-xl bg-subtle p-4"301 <Pressable
461 >302 accessibilityRole="button"
462 <Text className="text-foreground">Remove from draft</Text>303 onPress={() =>
463 </Pressable>304 props.onOpenThread?.({
305 environmentId: record.environmentId,
306 threadId: record.threadId,
307 })
308 }
309 className="rounded-xl bg-subtle p-4"
310 >
311 <Text className="text-foreground">Open thread</Text>
312 </Pressable>
313 ) : null}
314 </View>
464 ) : null}315 ) : null}
465 </ScrollView>316 {record.kind === "skill" ? (
466 </View>317 <View className="gap-3">
467 </View>318 <ContextField label="Skill" value={record.name} />
468 </Modal>319 <ContextField
320 label="Description"
321 value={props.skillDescription ?? "No description is available for this skill."}
322 />
323 {props.onOpenSkill ? (
324 <Pressable
325 accessibilityRole="button"
326 onPress={props.onOpenSkill}
327 className="rounded-xl bg-subtle p-4"
328 >
329 <Text className="text-foreground">View instructions</Text>
330 </Pressable>
331 ) : null}
332 </View>
333 ) : null}
334 </>
335 )}
336 {attachmentRecord && "attachmentId" in attachmentRecord ? (
337 <ComposerContextAttachment
338 key={JSON.stringify([
339 props.environmentId,
340 attachmentRecord.attachmentId,
341 props.attachments?.find((entry) => entry.id === attachmentRecord.attachmentId)
342 ?.fileUri,
343 ])}
344 record={attachmentRecord}
345 environmentId={props.environmentId}
346 attachment={props.attachments?.find(
347 (entry) => entry.id === attachmentRecord.attachmentId,
348 )}
349 />
350 ) : null}
351 {pullRequestUrl && /^https?:\/\//i.test(pullRequestUrl) ? (
352 <Pressable
353 accessibilityRole="link"
354 onPress={() => {
355 props.onClose();
356 void openPullRequest(pullRequestUrl).catch(() =>
357 Alert.alert("Could not open pull request", "Try again when connected."),
358 );
359 }}
360 className="rounded-xl bg-subtle p-4"
361 >
362 <Text className="text-foreground">Open pull request</Text>
363 </Pressable>
364 ) : null}
365 {props.onOpenAttachment ? (
366 <Pressable
367 accessibilityRole="button"
368 onPress={props.onOpenAttachment}
369 className="rounded-xl bg-subtle p-4"
370 >
371 <Text className="text-foreground">Open attachment</Text>
372 </Pressable>
373 ) : null}
374 {props.onOpenPullRequest && !pullRequestUrl ? (
375 <Pressable
376 accessibilityRole="button"
377 onPress={props.onOpenPullRequest}
378 className="rounded-xl bg-subtle p-4"
379 >
380 <Text className="text-foreground">Open pull request</Text>
381 </Pressable>
382 ) : null}
383 {props.onRemove ? (
384 <Pressable
385 accessibilityRole="button"
386 onPress={props.onRemove}
387 className="rounded-xl bg-subtle p-4"
388 >
389 <Text className="text-foreground">Remove from draft</Text>
390 </Pressable>
391 ) : null}
392 </ScrollView>
393 </AppSheet>
469 );394 );
470}395}
apps/mobile/src/components/ContextSheetSize.ios.tsx deleted-17
...@@ -1,17 +0,0 @@
1import { requireNativeView } from "expo";
2import type { ViewProps } from "react-native";
3
4const NativeSheetSize = requireNativeView<ViewProps & { contentHeight: number }>(
5 "T3NativeControls",
6 "ContextSheetSize",
7);
8
9export function ContextSheetSize({ height }: { height: number }) {
10 return (
11 <NativeSheetSize
12 contentHeight={height}
13 pointerEvents="none"
14 style={{ position: "absolute", width: 0, height: 0 }}
15 />
16 );
17}
apps/mobile/src/components/ContextSheetSize.tsx deleted-4
...@@ -1,4 +0,0 @@
1/** Native iOS detent configuration; Android sizes the sheet through its layout. */
2export function ContextSheetSize(_props: { height: number }) {
3 return null;
4}
apps/mobile/src/components/PierreEntryIcon.tsx+2-2
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import { SymbolView } from "../components/AppSymbol";1import { SymbolView } from "../components/AppSymbol";
2import { Image, type ImageStyle, type StyleProp } from "react-native";2import { Image, type ImageStyle, type StyleProp } from "react-native";
33
4import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";4import { markdownFileIconSource } from "../lib/markdownFileIcons";
5import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";5import { resolveMarkdownFileIcon } from "../lib/markdownLinks";
66
7export function PierreEntryIcon(props: {7export function PierreEntryIcon(props: {
8 readonly path: string;8 readonly path: string;
apps/mobile/src/features/files/FileMarkdownPreview.tsx+102-147
...@@ -1,29 +1,26 @@...@@ -1,29 +1,26 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { resolveMediaSource } from "@t3tools/client-runtime/media-source";2import { resolveMobileMarkdownMediaSource } from "../../lib/markdownMediaSource";
3import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects";3import { getBrowseDirectoryPath } from "@t3tools/client-runtime/state/projects";
4import { useCallback, useMemo, useState } from "react";4import { useCallback, useMemo, useState } from "react";
5import {5import { RefreshControl, ScrollView, View } from "react-native";
6 Markdown,
7 type CustomRenderers,
8 type NodeStyleOverrides,
9 type PartialMarkdownTheme,
10} from "react-native-nitro-markdown";
11import { RefreshControl, ScrollView, Text as NativeText, View } from "react-native";
126
13import { tryOpenExternalUrl } from "../../lib/openExternalUrl";7import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
14import { useFontFamily } from "../../lib/useFontFamily";8import { useFontFamily } from "../../lib/useFontFamily";
15import {9import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences";
16 resolveMarkdownFontSizes,
17 resolveNativeMarkdownTypography,
18} from "../../lib/appearancePreferences";
19import { useUniwindTheme } from "../../lib/useUniwindTheme";10import { useUniwindTheme } from "../../lib/useUniwindTheme";
20import {11import {
21 ThreadMarkdownImage,12 ThreadMarkdownImage,
13 ThreadMarkdownImageView,
22 ThreadMarkdownImageUnavailable,14 ThreadMarkdownImageUnavailable,
23} from "../threads/ThreadMarkdownImage";15} from "../threads/ThreadMarkdownImage";
16import { ThreadMarkdownVideo } from "../threads/ThreadMarkdownVideo";
17import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia";
18import { normalizeNativeMarkdownUrl } from "../../lib/markdownLinks";
19import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
20import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
21import { useMarkdownImageSource } from "../../native/useMarkdownImageSource";
24import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";22import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
25import {23import {
26 hasNativeSelectableMarkdownText,
27 SelectableMarkdownText,24 SelectableMarkdownText,
28 type MarkdownImageRenderer,25 type MarkdownImageRenderer,
29 type NativeMarkdownTextStyle,26 type NativeMarkdownTextStyle,
...@@ -31,20 +28,11 @@ import {...@@ -31,20 +28,11 @@ import {
31import { resolveWorkspaceFilePath } from "./filePath";28import { resolveWorkspaceFilePath } from "./filePath";
3229
33interface MarkdownPreviewStyles {30interface MarkdownPreviewStyles {
34 readonly theme: PartialMarkdownTheme;
35 readonly styles: NodeStyleOverrides;
36 readonly renderers: CustomRenderers;
37 readonly nativeTextStyle: NativeMarkdownTextStyle;31 readonly nativeTextStyle: NativeMarkdownTextStyle;
38}32}
3933
40export function useMarkdownPreviewStyles(34export function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
41 renderImage?: MarkdownImageRenderer,
42): MarkdownPreviewStyles {
43 const { appearance } = useAppearancePreferences();35 const { appearance } = useAppearancePreferences();
44 const markdownFontSizes = useMemo(
45 () => resolveMarkdownFontSizes(appearance.baseFontSize),
46 [appearance.baseFontSize],
47 );
48 const nativeMarkdownTypography = useMemo(36 const nativeMarkdownTypography = useMemo(
49 () => resolveNativeMarkdownTypography(appearance.baseFontSize),37 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
50 [appearance.baseFontSize],38 [appearance.baseFontSize],
...@@ -54,104 +42,14 @@ export function useMarkdownPreviewStyles(...@@ -54,104 +42,14 @@ export function useMarkdownPreviewStyles(
54 const strong = theme["--color-md-strong"];42 const strong = theme["--color-md-strong"];
55 const link = theme["--color-md-link"];43 const link = theme["--color-md-link"];
56 const blockquoteBorder = theme["--color-md-blockquote-border"];44 const blockquoteBorder = theme["--color-md-blockquote-border"];
57 const blockquoteBackground = theme["--color-md-blockquote-bg"];
58 const codeBackground = theme["--color-md-code-bg"];45 const codeBackground = theme["--color-md-code-bg"];
59 const codeText = theme["--color-md-code-text"];46 const codeText = theme["--color-md-code-text"];
60 const horizontalRule = theme["--color-md-hr"];47 const horizontalRule = theme["--color-md-hr"];
61 const regularFontFamily = useFontFamily("regular");48 const regularFontFamily = useFontFamily("regular");
62 const mediumFontFamily = useFontFamily("medium");
63 const boldFontFamily = useFontFamily("bold");49 const boldFontFamily = useFontFamily("bold");
6450
65 return useMemo(() => {51 return useMemo(() => {
66 const renderers: CustomRenderers = {
67 link: ({ href, children }) => (
68 <NativeText
69 className="font-t3-medium"
70 onPress={() => {
71 if (href) {
72 void tryOpenExternalUrl(href, "markdown-link");
73 }
74 }}
75 style={{
76 color: link,
77 textDecorationLine: "none",
78 }}
79 >
80 {children}
81 </NativeText>
82 ),
83 image: ({ node }) =>
84 node.href && renderImage
85 ? (renderImage({
86 href: node.href,
87 alt: node.alt ?? null,
88 title: node.title ?? null,
89 }) ?? undefined)
90 : undefined,
91 };
92
93 return {52 return {
94 theme: {
95 colors: {
96 text: body,
97 heading: strong,
98 link,
99 blockquote: blockquoteBorder,
100 border: horizontalRule,
101 surface: "transparent",
102 surfaceLight: blockquoteBackground,
103 accent: link,
104 tableBorder: horizontalRule,
105 tableHeader: blockquoteBackground,
106 tableHeaderText: strong,
107 tableRowOdd: blockquoteBackground,
108 tableRowEven: "transparent",
109 code: codeText,
110 codeBackground,
111 },
112 },
113 styles: {
114 text: {
115 color: body,
116 fontFamily: regularFontFamily,
117 fontSize: markdownFontSizes.m,
118 lineHeight: markdownFontSizes.bodyLineHeight,
119 },
120 heading: {
121 color: strong,
122 fontFamily: boldFontFamily,
123 },
124 strong: {
125 color: strong,
126 fontFamily: boldFontFamily,
127 },
128 link: {
129 color: link,
130 fontFamily: mediumFontFamily,
131 },
132 blockquote: {
133 backgroundColor: blockquoteBackground,
134 borderLeftColor: blockquoteBorder,
135 borderLeftWidth: 3,
136 paddingLeft: 12,
137 },
138 code: {
139 backgroundColor: codeBackground,
140 color: codeText,
141 fontFamily: "ui-monospace",
142 },
143 codeBlock: {
144 backgroundColor: codeBackground,
145 borderRadius: 12,
146 color: codeText,
147 fontFamily: "ui-monospace",
148 padding: 12,
149 },
150 hr: {
151 backgroundColor: horizontalRule,
152 },
153 },
154 renderers,
155 nativeTextStyle: {53 nativeTextStyle: {
156 color: body,54 color: body,
157 strongColor: strong,55 strongColor: strong,
...@@ -174,18 +72,14 @@ export function useMarkdownPreviewStyles(...@@ -174,18 +72,14 @@ export function useMarkdownPreviewStyles(
174 },72 },
175 };73 };
176 }, [74 }, [
177 blockquoteBackground,
178 blockquoteBorder,75 blockquoteBorder,
179 body,76 body,
180 codeBackground,77 codeBackground,
181 codeText,78 codeText,
182 horizontalRule,79 horizontalRule,
183 link,80 link,
184 markdownFontSizes,
185 mediumFontFamily,
186 nativeMarkdownTypography,81 nativeMarkdownTypography,
187 regularFontFamily,82 regularFontFamily,
188 renderImage,
189 strong,83 strong,
190 boldFontFamily,84 boldFontFamily,
191 ]);85 ]);
...@@ -202,6 +96,8 @@ export function FileMarkdownPreview(props: {...@@ -202,6 +96,8 @@ export function FileMarkdownPreview(props: {
202 readonly onRefresh?: () => Promise<void> | void;96 readonly onRefresh?: () => Promise<void> | void;
203}) {97}) {
204 const [isPullRefreshing, setIsPullRefreshing] = useState(false);98 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
99 const [expandedFile, setExpandedFile] = useState<FilePreviewSource | null>(null);
100 const [expandedVideo, setExpandedVideo] = useState<VideoPreviewSource | null>(null);
205 const handlePullToRefresh = useCallback(async () => {101 const handlePullToRefresh = useCallback(async () => {
206 if (!props.onRefresh) {102 if (!props.onRefresh) {
207 return;103 return;
...@@ -219,42 +115,108 @@ export function FileMarkdownPreview(props: {...@@ -219,42 +115,108 @@ export function FileMarkdownPreview(props: {
219 );115 );
220 const renderImage = useCallback<MarkdownImageRenderer>(116 const renderImage = useCallback<MarkdownImageRenderer>(
221 (image) => {117 (image) => {
222 const media = resolveMediaSource(image.href, {118 const media = resolveMobileMarkdownMediaSource(image.href, {
223 threadId: props.threadId ?? undefined,119 threadId: props.threadId ?? undefined,
224 workspaceRoot: markdownDirectory,120 workspaceRoot: markdownDirectory,
225 imageEmbed: true,121 imageEmbed: true,
226 });122 });
123 const preview = resolveMarkdownMediaPreview(image.href, {
124 environmentId: props.environmentId,
125 threadId: props.threadId ?? undefined,
126 workspaceRoot: markdownDirectory,
127 captured: props.captured,
128 imageEmbed: true,
129 });
227 if (media?.access === "direct") {130 if (media?.access === "direct") {
228 return null;131 if (media.kind === "video") {
132 return (
133 <ThreadMarkdownVideo
134 source={{
135 type: "media",
136 name: media.name,
137 mimeType: media.mimeType,
138 uri: normalizeNativeMarkdownUrl(media.uri),
139 actionsSource: preview?.source.actionsSource,
140 }}
141 thumbnailVisible
142 />
143 );
144 }
145 return (
146 <ThreadMarkdownImageView
147 uri={normalizeNativeMarkdownUrl(media.uri)}
148 sourceKey={media.uri}
149 unavailable={false}
150 alt={image.alt}
151 format={media.mimeType === "image/svg+xml" ? "svg" : undefined}
152 actionsSource={preview?.source.actionsSource}
153 onPressPreview={setExpandedFile}
154 />
155 );
229 }156 }
230 if (157 if (props.captured || media === null || media.access === "unavailable") {
231 props.captured ||
232 media === null ||
233 media.kind !== "image" ||
234 media.access === "unavailable"
235 ) {
236 return <ThreadMarkdownImageUnavailable alt={image.alt} />;158 return <ThreadMarkdownImageUnavailable alt={image.alt} />;
237 }159 }
160 if (media.kind === "video") {
161 return preview?.kind === "video" ? (
162 <ThreadMarkdownVideo source={preview.source} thumbnailVisible />
163 ) : (
164 <ThreadMarkdownImageUnavailable alt={image.alt} />
165 );
166 }
238 return (167 return (
239 <ThreadMarkdownImage168 <ThreadMarkdownImage
240 environmentId={props.environmentId}169 environmentId={props.environmentId}
241 resource={media.resource}170 resource={media.resource}
242 alt={image.alt}171 alt={image.alt}
243 srcFragment={media.srcFragment}172 srcFragment={media.srcFragment}
244 onPressPreview={() => undefined}173 actionsSource={preview?.source.actionsSource}
174 onPressPreview={setExpandedFile}
245 />175 />
246 );176 );
247 },177 },
248 [markdownDirectory, props.environmentId, props.threadId, props.captured],178 [markdownDirectory, props.environmentId, props.threadId, props.captured],
249 );179 );
250 const styles = useMarkdownPreviewStyles(renderImage);180 const styles = useMarkdownPreviewStyles();
251 const onLinkPress = useCallback((href: string) => {181 const resolveImageSource = useMarkdownImageSource({
252 void tryOpenExternalUrl(href, "markdown-link");182 environmentId: props.environmentId,
253 }, []);183 threadId: props.threadId,
184 workspaceRoot: markdownDirectory,
185 captured: props.captured,
186 });
187 const openMedia = useCallback(
188 (href: string, imageEmbed = false) => {
189 const media = resolveMarkdownMediaPreview(href, {
190 environmentId: props.environmentId,
191 threadId: props.threadId ?? undefined,
192 workspaceRoot: markdownDirectory,
193 captured: props.captured,
194 imageEmbed,
195 });
196 if (!media) return false;
197 if (media.kind === "video") setExpandedVideo(media.source);
198 else setExpandedFile(media.source);
199 return true;
200 },
201 [markdownDirectory, props.environmentId, props.threadId, props.captured],
202 );
203 const onImagePress = useCallback(
204 (href: string) => {
205 openMedia(href, true);
206 },
207 [openMedia],
208 );
209 const onLinkPress = useCallback(
210 (href: string) => {
211 if (!openMedia(href)) void tryOpenExternalUrl(href, "markdown-link");
212 },
213 [openMedia],
214 );
254215
255 return (216 return (
256 <ScrollView217 <ScrollView
257 className="flex-1 bg-sheet"218 className="flex-1 bg-sheet"
219 contentInsetAdjustmentBehavior="automatic"
258 contentContainerStyle={{ padding: 18 }}220 contentContainerStyle={{ padding: 18 }}
259 refreshControl={221 refreshControl={
260 props.onRefresh ? (222 props.onRefresh ? (
...@@ -266,23 +228,16 @@ export function FileMarkdownPreview(props: {...@@ -266,23 +228,16 @@ export function FileMarkdownPreview(props: {
266 }228 }
267 >229 >
268 <View className="mx-auto w-full max-w-[760px]">230 <View className="mx-auto w-full max-w-[760px]">
269 {hasNativeSelectableMarkdownText() ? (231 <SelectableMarkdownText
270 <SelectableMarkdownText232 markdown={props.markdown}
271 markdown={props.markdown}233 onLinkPress={onLinkPress}
272 onLinkPress={onLinkPress}234 renderImage={renderImage}
273 renderImage={renderImage}235 resolveImageSource={resolveImageSource}
274 textStyle={styles.nativeTextStyle}236 onImagePress={onImagePress}
275 />237 textStyle={styles.nativeTextStyle}
276 ) : (238 />
277 <Markdown239 <FilePreviewModal source={expandedFile} onRequestClose={() => setExpandedFile(null)} />
278 options={{ gfm: true }}240 <VideoPreviewModal source={expandedVideo} onRequestClose={() => setExpandedVideo(null)} />
279 renderers={styles.renderers}
280 styles={styles.styles}
281 theme={styles.theme}
282 >
283 {props.markdown}
284 </Markdown>
285 )}
286 </View>241 </View>
287 </ScrollView>242 </ScrollView>
288 );243 );
apps/mobile/src/features/files/SourceFileSurface.tsx+7-8
...@@ -32,7 +32,7 @@ import {...@@ -32,7 +32,7 @@ import {
32 NATIVE_SOURCE_CONTENT_WIDTH,32 NATIVE_SOURCE_CONTENT_WIDTH,
33 nativeSourceRowId,33 nativeSourceRowId,
34} from "./nativeSourceFileAdapter";34} from "./nativeSourceFileAdapter";
35import { MarkdownTextPrimitive } from "@t3tools/mobile-markdown-text/primitive";35import { SourceText } from "@t3tools/mobile-source-text";
3636
37import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document";37import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document";
38import { sourceHighlightAtom } from "./sourceHighlightingState";38import { sourceHighlightAtom } from "./sourceHighlightingState";
...@@ -287,8 +287,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {...@@ -287,8 +287,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
287 // way "select all" takes the file rather than a line, which a `FlatList` row can never do287 // way "select all" takes the file rather than a line, which a `FlatList` row can never do
288 // because each row is its own selection scope.288 // because each row is its own selection scope.
289 const selectableBlock = props.selectable ? (289 const selectableBlock = props.selectable ? (
290 <MarkdownTextPrimitive290 <SourceText
291 uiTextView
292 selectable291 selectable
293 selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined}292 selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined}
294 style={{293 style={{
...@@ -304,7 +303,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {...@@ -304,7 +303,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
304 const body =303 const body =
305 lineTokens && lineTokens.length > 0304 lineTokens && lineTokens.length > 0
306 ? lineTokens.map((token, tokenIndex) => (305 ? lineTokens.map((token, tokenIndex) => (
307 <MarkdownTextPrimitive306 <SourceText
308 key={`${index}:${tokenIndex}`}307 key={`${index}:${tokenIndex}`}
309 style={{308 style={{
310 color: token.color ?? foreground,309 color: token.color ?? foreground,
...@@ -317,18 +316,18 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {...@@ -317,18 +316,18 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
317 }}316 }}
318 >317 >
319 {token.content}318 {token.content}
320 </MarkdownTextPrimitive>319 </SourceText>
321 ))320 ))
322 : line;321 : line;
323 return (322 return (
324 <MarkdownTextPrimitive key={index}>323 <SourceText key={index}>
325 {body}324 {body}
326 {index < lines.length - 1 ? "\n" : ""}325 {index < lines.length - 1 ? "\n" : ""}
327 </MarkdownTextPrimitive>326 </SourceText>
328 );327 );
329 })328 })
330 : normalizedContents}329 : normalizedContents}
331 </MarkdownTextPrimitive>330 </SourceText>
332 ) : null;331 ) : null;
333332
334 const list = (333 const list = (
apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx+8-1
...@@ -303,9 +303,13 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean...@@ -303,9 +303,13 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean
303 const primaryDescriptor = props.descriptors[primaryRoute.key]!;303 const primaryDescriptor = props.descriptors[primaryRoute.key]!;
304 const descriptors = props.descriptors;304 const descriptors = props.descriptors;
305 const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" };305 const baseRoute = { key: `${props.state.key}:workspace`, name: "Workspace" };
306 const visibleRoute = projection.detail.at(-1) ?? primaryRoute;
306 const baseDescriptor: Descriptor = {307 const baseDescriptor: Descriptor = {
307 ...primaryDescriptor,308 ...primaryDescriptor,
308 route: baseRoute,309 navigation: props.descriptors[visibleRoute.key]!.navigation,
310 // The native envelope keeps a stable key, but its NavigationProvider must
311 // identify the real visible route or useIsFocused closes media viewers.
312 route: visibleRoute,
309 options: { headerShown: false },313 options: { headerShown: false },
310 render: () => (314 render: () => (
311 <WorkspaceNativeTabs315 <WorkspaceNativeTabs
...@@ -359,10 +363,13 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean...@@ -359,10 +363,13 @@ function WorkspaceStackView(props: ViewProps & { readonly nativeColumns: boolean
359 overlays.map((group) => {363 overlays.map((group) => {
360 const route = group[0]!;364 const route = group[0]!;
361 const descriptor = props.descriptors[route.key]!;365 const descriptor = props.descriptors[route.key]!;
366 const visibleRoute = group.at(-1)!;
362 return [367 return [
363 route.key,368 route.key,
364 {369 {
365 ...descriptor,370 ...descriptor,
371 navigation: props.descriptors[visibleRoute.key]!.navigation,
372 route: visibleRoute,
366 options: modalEnvelopeOptions(descriptor.options),373 options: modalEnvelopeOptions(descriptor.options),
367 render: () => (374 render: () => (
368 <V5CardStackView375 <V5CardStackView
apps/mobile/src/features/pull-requests/PullRequestDescription.tsx+2-6
...@@ -9,8 +9,7 @@ import { useContext, useMemo, useState } from "react";...@@ -9,8 +9,7 @@ import { useContext, useMemo, useState } from "react";
9import { Pressable, ScrollView, StyleSheet, View } from "react-native";9import { Pressable, ScrollView, StyleSheet, View } from "react-native";
10import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";10import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
11import { pullRequestImageSource } from "./pullRequestMediaSource";11import { pullRequestImageSource } from "./pullRequestMediaSource";
12import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";12import { parseMarkdownDocument } from "../../lib/markdownDocument";
13import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown";
14import { SymbolView } from "../../components/AppSymbol";13import { SymbolView } from "../../components/AppSymbol";
15import { AppText as Text } from "../../components/AppText";14import { AppText as Text } from "../../components/AppText";
16import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";15import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
...@@ -32,10 +31,7 @@ export function PullRequestDescription(props: {...@@ -32,10 +31,7 @@ export function PullRequestDescription(props: {
32 const mediaContext = useContext(PullRequestMediaContext);31 const mediaContext = useContext(PullRequestMediaContext);
33 const { themeVariables: colors } = useAppearancePreferences();32 const { themeVariables: colors } = useAppearancePreferences();
34 const content = useMemo(() => {33 const content = useMemo(() => {
35 const document = nativeMarkdownWithHtml(34 const document = parseMarkdownDocument(props.markdown);
36 parseMarkdownWithOptions(props.markdown, { gfm: true, html: true, math: false }),
37 (markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }),
38 );
39 const content = pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown));35 const content = pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown));
40 return {36 return {
41 ...content,37 ...content,
apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx+9-71
...@@ -11,19 +11,9 @@ import {...@@ -11,19 +11,9 @@ import {
11import { videoMimeType } from "@t3tools/shared/video";11import { videoMimeType } from "@t3tools/shared/video";
12import { createContext, useContext, useCallback, useMemo, useState } from "react";12import { createContext, useContext, useCallback, useMemo, useState } from "react";
13import { githubMarkdownLinks } from "@t3tools/shared/githubMarkdownLinks";13import { githubMarkdownLinks } from "@t3tools/shared/githubMarkdownLinks";
14import { Text as NativeText } from "react-native";
15import {
16 Markdown,
17 type CustomRenderers,
18 type CustomRendererProps,
19} from "react-native-nitro-markdown";
20import { markdownHtmlContent } from "@t3tools/mobile-markdown-text/markdown";
2114
22import { useOpenPullRequest } from "../../native/useOpenPullRequest";15import { useOpenPullRequest } from "../../native/useOpenPullRequest";
23import {16import { SelectableMarkdownText } from "../../native/SelectableMarkdownText";
24 hasNativeSelectableMarkdownText,
25 SelectableMarkdownText,
26} from "../../native/SelectableMarkdownText";
27import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";17import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";
28import { markdownImageSources } from "../../lib/markdownImageSources";18import { markdownImageSources } from "../../lib/markdownImageSources";
29import { FilePreviewGallery, useFilePreviewGallery } from "../../components/FilePreviewGallery";19import { FilePreviewGallery, useFilePreviewGallery } from "../../components/FilePreviewGallery";
...@@ -109,40 +99,6 @@ export function PullRequestMarkdown(props: {...@@ -109,40 +99,6 @@ export function PullRequestMarkdown(props: {
109 );99 );
110}100}
111101
112function renderLink(
113 { href, children }: CustomRendererProps,
114 styles: ReturnType<typeof useMarkdownPreviewStyles>,
115 open: (href: string) => void,
116) {
117 return (
118 <NativeText
119 className="font-t3-medium"
120 onPress={() => href && open(href)}
121 style={{ color: styles.theme.colors?.link }}
122 >
123 {children}
124 </NativeText>
125 );
126}
127
128function renderHtmlBlock(
129 { node }: CustomRendererProps,
130 styles: ReturnType<typeof useMarkdownPreviewStyles>,
131 renderers: CustomRenderers,
132) {
133 const content = markdownHtmlContent(node.content ?? "").trim();
134 return content ? (
135 <Markdown
136 options={{ gfm: true, html: false }}
137 renderers={renderers}
138 styles={styles.styles}
139 theme={styles.theme}
140 >
141 {content}
142 </Markdown>
143 ) : null;
144}
145
146function PullRequestMarkdownContent(props: {102function PullRequestMarkdownContent(props: {
147 readonly markdown: string;103 readonly markdown: string;
148 readonly imageGroupMarkdown?: string;104 readonly imageGroupMarkdown?: string;
...@@ -176,34 +132,16 @@ function PullRequestMarkdownContent(props: {...@@ -176,34 +132,16 @@ function PullRequestMarkdownContent(props: {
176 (image) => <MarkdownImage href={image.href} alt={image.alt} onPreview={setPreview} />,132 (image) => <MarkdownImage href={image.href} alt={image.alt} onPreview={setPreview} />,
177 [],133 [],
178 );134 );
179 const styles = useMarkdownPreviewStyles(renderImage);135 const styles = useMarkdownPreviewStyles();
180 const renderers = useMemo<CustomRenderers>(() => {
181 const renderers: CustomRenderers = {
182 ...styles.renderers,
183 link: (props) => renderLink(props, styles, openLink),
184 };
185 renderers.html_block = (props) => renderHtmlBlock(props, styles, renderers);
186 return renderers;
187 }, [styles, openLink]);
188 return (136 return (
189 <FilePreviewGallery value={gallery}>137 <FilePreviewGallery value={gallery}>
190 {hasNativeSelectableMarkdownText() ? (138 <SelectableMarkdownText
191 <SelectableMarkdownText139 markdown={markdown}
192 markdown={markdown}140 textStyle={styles.nativeTextStyle}
193 textStyle={styles.nativeTextStyle}141 onLinkPress={openLink}
194 onLinkPress={openLink}142 renderImage={renderImage}
195 renderImage={renderImage}143 onImagePress={(href) => setPreview(pullRequestImageSource(href, mediaContext))}
196 />144 />
197 ) : (
198 <Markdown
199 options={{ gfm: true, html: true }}
200 renderers={renderers}
201 styles={styles.styles}
202 theme={styles.theme}
203 >
204 {markdown}
205 </Markdown>
206 )}
207 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />145 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
208 </FilePreviewGallery>146 </FilePreviewGallery>
209 );147 );
apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx+2-13
...@@ -6,8 +6,7 @@ import type { PullRequestActivity, PullRequestComment } from "@t3tools/contracts...@@ -6,8 +6,7 @@ import type { PullRequestActivity, PullRequestComment } from "@t3tools/contracts
6import { StackActions, type NavigationProp } from "@react-navigation/native";6import { StackActions, type NavigationProp } from "@react-navigation/native";
7import { useMemo, useState, type ReactNode } from "react";7import { useMemo, useState, type ReactNode } from "react";
8import { Pressable, StyleSheet, View } from "react-native";8import { Pressable, StyleSheet, View } from "react-native";
9import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown";9import { parseMarkdownDocument } from "../../lib/markdownDocument";
10import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
11import { AppText as Text } from "../../components/AppText";10import { AppText as Text } from "../../components/AppText";
12import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";11import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
13import { relativeTime } from "../../lib/time";12import { relativeTime } from "../../lib/time";
...@@ -63,17 +62,7 @@ function TimelineComment({ comment }: { comment: PullRequestComment }) {...@@ -63,17 +62,7 @@ function TimelineComment({ comment }: { comment: PullRequestComment }) {
63 const { themeVariables: colors } = useAppearancePreferences();62 const { themeVariables: colors } = useAppearancePreferences();
64 const long = comment.body.length > 450 || comment.body.split("\n").length > 8;63 const long = comment.body.length > 450 || comment.body.split("\n").length > 8;
65 const content = useMemo(64 const content = useMemo(
66 () =>65 () => pullRequestDescriptionContent(parseMarkdownDocument(comment.body)),
67 pullRequestDescriptionContent(
68 nativeMarkdownWithHtml(
69 parseMarkdownWithOptions(comment.body.replace(/<!--[\s\S]*?-->/g, ""), {
70 gfm: true,
71 html: true,
72 math: false,
73 }),
74 (markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }),
75 ),
76 ),
77 [comment.body],66 [comment.body],
78 );67 );
79 const preview = content.preview;68 const preview = content.preview;
apps/mobile/src/features/pull-requests/pullRequestDescription.test.ts+21-36
...@@ -1,19 +1,21 @@...@@ -1,19 +1,21 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { parseMarkdownDocument } from "../../lib/markdownDocument";
2import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent";3import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent";
34
4describe("PR description overview", () => {5describe("pull request description content", () => {
5 it("keeps screenshots in source order, deduplicates embeds, and ignores code and local paths", () => {6 it("keeps screenshot attachments and does not turn code examples or local paths into media", () => {
6 const result = pullRequestDescriptionContent({7 const result = pullRequestDescriptionContent(
7 type: "document",8 parseMarkdownDocument(
8 children: [9 [
9 { type: "paragraph", children: [{ type: "text", content: "Fixes reconnects." }] },10 "Fixes reconnects.",
10 { type: "code_block", content: "![Example](https://example.com/code.png)" },11 "```markdown\n![Example](https://example.com/code.png)\n```",
11 { type: "image", href: "https://example.com/before.png", alt: "Before" },12 "![Before](https://example.com/before.png)",
12 { type: "image", href: "https://example.com/after.png", alt: "After" },13 "![After](https://example.com/after.png)",
13 { type: "image", href: "https://example.com/before.png", alt: "Duplicate" },14 "![Duplicate](https://example.com/before.png)",
14 { type: "image", href: "screenshots/local.png" },15 "![Local](screenshots/local.png)",
15 ],16 ].join("\n\n"),
16 });17 ),
18 );
17 expect(result.attachments).toEqual([19 expect(result.attachments).toEqual([
18 { uri: "https://example.com/before.png", caption: "Before", kind: "image" },20 { uri: "https://example.com/before.png", caption: "Before", kind: "image" },
19 { uri: "https://example.com/after.png", caption: "After", kind: "image" },21 { uri: "https://example.com/after.png", caption: "After", kind: "image" },
...@@ -23,28 +25,11 @@ describe("PR description overview", () => {...@@ -23,28 +25,11 @@ describe("PR description overview", () => {
23 });25 });
2426
25 it("reads formatted prose and list items without leaking hidden HTML metadata", () => {27 it("reads formatted prose and list items without leaking hidden HTML metadata", () => {
26 const result = pullRequestDescriptionContent({28 const result = pullRequestDescriptionContent(
27 type: "document",29 parseMarkdownDocument(
28 children: [30 "<!-- bot metadata -->\n\nKeeps **sessions**\nalive.\n\n- Reconnect\n- Restore output",
29 { type: "html_block", content: "<!-- bot metadata -->" },31 ),
30 {32 );
31 type: "paragraph",33 expect(result.preview).toBe("Keeps sessions\nalive.\nReconnect\n\nRestore output");
32 children: [
33 { type: "text", content: "Keeps " },
34 { type: "bold", children: [{ type: "text", content: "sessions" }] },
35 { type: "soft_break" },
36 { type: "text", content: "alive." },
37 ],
38 },
39 {
40 type: "list",
41 children: [
42 { type: "list_item", children: [{ type: "text", content: "Reconnect" }] },
43 { type: "list_item", children: [{ type: "text", content: "Restore output" }] },
44 ],
45 },
46 ],
47 });
48 expect(result.preview).toBe("Keeps sessions alive.\nReconnect\nRestore output");
49 });34 });
50});35});
apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.test.ts+5-5
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import type { MarkdownNode } from "react-native-nitro-markdown/headless";2import type { MarkdownNode } from "../../lib/markdownDocument";
3import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown";3import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown";
4import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent";4import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent";
55
...@@ -7,10 +7,10 @@ describe("PR attachments", () => {...@@ -7,10 +7,10 @@ describe("PR attachments", () => {
7 it("combines embedded images and detected media without duplicate URLs", () => {7 it("combines embedded images and detected media without duplicate URLs", () => {
8 const uri = "https://drop.example.com/shot.png";8 const uri = "https://drop.example.com/shot.png";
9 const document = {9 const document = {
10 type: "document",10 type: "root",
11 children: [11 children: [
12 { type: "image", href: uri, alt: "Screenshot" },12 { type: "image", url: uri, alt: "Screenshot" },
13 { type: "image", href: uri, alt: "Duplicate" },13 { type: "image", url: uri, alt: "Duplicate" },
14 ],14 ],
15 } as MarkdownNode;15 } as MarkdownNode;
16 const media = splitPullRequestBody(16 const media = splitPullRequestBody(
...@@ -32,7 +32,7 @@ describe("PR attachments", () => {...@@ -32,7 +32,7 @@ describe("PR attachments", () => {
32 "https://example.com/docs\n\n```\nhttps://drop.example.com/demo.mp4\n```",32 "https://example.com/docs\n\n```\nhttps://drop.example.com/demo.mp4\n```",
33 );33 );
34 expect(34 expect(
35 pullRequestDescriptionContent({ type: "document" } as MarkdownNode, media).attachments,35 pullRequestDescriptionContent({ type: "root" } as MarkdownNode, media).attachments,
36 ).toEqual([]);36 ).toEqual([]);
37 });37 });
38});38});
apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.ts+9-9
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { PullRequestBodySegment } from "@t3tools/client-runtime/pull-request-markdown";1import type { PullRequestBodySegment } from "@t3tools/client-runtime/pull-request-markdown";
2import type { MarkdownNode } from "react-native-nitro-markdown/headless";2import type { MarkdownNode } from "../../lib/markdownDocument";
33
4/** Read media from the parsed document so code examples never become attachments. */4/** Read media from the parsed document so code examples never become attachments. */
5export function pullRequestDescriptionContent(5export function pullRequestDescriptionContent(
...@@ -13,20 +13,20 @@ export function pullRequestDescriptionContent(...@@ -13,20 +13,20 @@ export function pullRequestDescriptionContent(
13 const text: string[] = [];13 const text: string[] = [];
14 const visit = (node: MarkdownNode) => {14 const visit = (node: MarkdownNode) => {
15 if (node.type === "image") {15 if (node.type === "image") {
16 if (node.href && /^https?:\/\//i.test(node.href) && !attachments.has(node.href)) {16 if (node.url && /^https?:\/\//i.test(node.url) && !attachments.has(node.url)) {
17 attachments.set(node.href, {17 attachments.set(node.url, {
18 uri: node.href,18 uri: node.url,
19 caption: node.alt || node.title || "Image",19 caption: node.alt || node.title || "Image",
20 kind: "image",20 kind: "image",
21 });21 });
22 }22 }
23 return;23 return;
24 }24 }
25 if (node.type === "html_block" || node.type === "html_inline") return;25 if (node.type === "html") return;
26 if (node.content) text.push(node.content);26 if ("value" in node) text.push(node.value);
27 node.children?.forEach(visit);27 if ("children" in node) node.children.forEach(visit);
28 if (["paragraph", "heading", "list_item", "code_block"].includes(node.type)) text.push("\n");28 if (["paragraph", "heading", "listItem", "code"].includes(node.type)) text.push("\n");
29 if (node.type === "soft_break" || node.type === "line_break") text.push(" ");29 if (node.type === "break") text.push(" ");
30 };30 };
31 visit(document);31 visit(document);
32 for (const segment of media) {32 for (const segment of media) {
apps/mobile/src/features/terminal/TerminalContextSheet.tsx+36-29
...@@ -6,10 +6,12 @@ import {...@@ -6,10 +6,12 @@ import {
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";7import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
8import { useState } from "react";8import { useState } from "react";
9import { Alert, Modal, Platform, Pressable, ScrollView, View } from "react-native";9import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
10import { useSafeAreaInsets } from "react-native-safe-area-context";10import { useSafeAreaInsets } from "react-native-safe-area-context";
11import { REVIEW_MONO_FONT_FAMILY } from "../review/reviewDiffRendering";11import { REVIEW_MONO_FONT_FAMILY } from "../review/reviewDiffRendering";
12import { AppText as Text } from "../../components/AppText";12import { AppText as Text } from "../../components/AppText";
13import { AppSheet } from "../../components/AppSheet";
14import { MaterialButton } from "../../components/MaterialButton";
13import { uuidv4 } from "../../lib/uuid";15import { uuidv4 } from "../../lib/uuid";
14import { insertComposerDraftContext } from "../../state/use-composer-drafts";16import { insertComposerDraftContext } from "../../state/use-composer-drafts";
1517
...@@ -54,25 +56,16 @@ export function TerminalContextSheet(props: {...@@ -54,25 +56,16 @@ export function TerminalContextSheet(props: {
54 props.onAttach();56 props.onAttach();
55 };57 };
56 return (58 return (
57 <Modal presentationStyle="pageSheet" animationType="slide" onRequestClose={props.onClose}>59 <AppSheet title="Terminal output" onClose={props.onClose}>
58 <View60 <ScrollView
59 className="flex-1 bg-sheet-solid"61 className="flex-1"
60 style={62 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
61 Platform.OS === "android"63 contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 12, paddingBottom: 20 }}
62 ? { paddingTop: insets.top, paddingBottom: insets.bottom }
63 : undefined
64 }
65 >64 >
66 <View className="flex-row items-center justify-between p-4">65 <Text className="pb-4 text-sm text-foreground-muted">
67 <Text className="text-lg text-foreground">Visible terminal output</Text>
68 <Pressable accessibilityRole="button" onPress={props.onClose} className="p-3">
69 <Text className="text-foreground">Cancel</Text>
70 </Pressable>
71 </View>
72 <Text className="px-4 pb-3 text-foreground-muted">
73 Tap the first and last line to select a range.66 Tap the first and last line to select a range.
74 </Text>67 </Text>
75 <ScrollView className="flex-1" contentContainerStyle={{ padding: 16 }}>68 <View className="overflow-hidden rounded-xl bg-card-alt">
76 {lines.map((line, index) => (69 {lines.map((line, index) => (
77 <Pressable70 <Pressable
78 key={index}71 key={index}
...@@ -88,31 +81,45 @@ export function TerminalContextSheet(props: {...@@ -88,31 +81,45 @@ export function TerminalContextSheet(props: {
88 setAnchor(null);81 setAnchor(null);
89 }82 }
90 }}83 }}
91 className={index >= range.start && index <= range.end ? "bg-subtle py-1" : "py-1"}84 className={
85 index >= range.start && index <= range.end
86 ? "flex-row gap-3 bg-primary/10 px-3 py-2"
87 : "flex-row gap-3 px-3 py-2"
88 }
92 >89 >
93 <Text90 <Text
94 className="text-sm text-foreground"91 className="w-8 text-right text-sm text-foreground-muted"
95 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}92 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
96 >93 >
97 {index + 1} {line || " "}94 {index + 1}
95 </Text>
96 <Text
97 className="flex-1 text-sm text-foreground"
98 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
99 >
100 {line || " "}
98 </Text>101 </Text>
99 </Pressable>102 </Pressable>
100 ))}103 ))}
101 </ScrollView>104 </View>
105 </ScrollView>
106 <View
107 className="gap-3 border-t border-border px-5 pt-4"
108 style={{ paddingBottom: Math.max(16, insets.bottom) }}
109 >
102 {tooLarge ? (110 {tooLarge ? (
103 <Text className="px-4 text-foreground-muted">111 <Text className="text-sm text-foreground-muted">
104 Select fewer lines to fit the context limit.112 Select fewer lines to fit the context limit.
105 </Text>113 </Text>
106 ) : null}114 ) : null}
107 <Pressable115 <MaterialButton
108 accessibilityRole="button"116 label="Attach selected output"
117 tone="primary"
118 fullWidth
109 disabled={!selectedText.trim() || tooLarge}119 disabled={!selectedText.trim() || tooLarge}
110 onPress={attach}120 onPress={attach}
111 className="m-4 mb-10 rounded-xl bg-subtle p-4"121 />
112 >
113 <Text className="text-center text-foreground">Attach selected output</Text>
114 </Pressable>
115 </View>122 </View>
116 </Modal>123 </AppSheet>
117 );124 );
118}125}
apps/mobile/src/features/threads/ThreadFeed.tsx+113-689
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import { useOpenPullRequest } from "../../native/useOpenPullRequest";1import { useOpenPullRequest } from "../../native/useOpenPullRequest";
2import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo";
3import { useMarkdownImageSource } from "../../native/useMarkdownImageSource";
4import { enrichedContextLinkPresentation } from "../../lib/enrichedLinkPresentation";
2import { ThreadContextDivider } from "./thread-context-divider";5import { ThreadContextDivider } from "./thread-context-divider";
3import { ThreadHandoffRow } from "./thread-handoff-row";6import { ThreadHandoffRow } from "./thread-handoff-row";
4import { SecretRequestCard } from "./SecretRequestCard";7import { SecretRequestCard } from "./SecretRequestCard";
...@@ -32,6 +35,7 @@ import {...@@ -32,6 +35,7 @@ import {
32 parseComposerContextHref,35 parseComposerContextHref,
33 collectComposerContextReferences,36 collectComposerContextReferences,
34 replaceComposerContextReferences,37 replaceComposerContextReferences,
38 formatComposerContextReference,
35} from "@t3tools/shared/composerContextReferences";39} from "@t3tools/shared/composerContextReferences";
36import { ComposerContextSheet } from "../../components/ComposerContextSheet";40import { ComposerContextSheet } from "../../components/ComposerContextSheet";
37import { writeComposerContextClipboard } from "../../lib/composerContextClipboard";41import { writeComposerContextClipboard } from "../../lib/composerContextClipboard";
...@@ -41,7 +45,6 @@ import {...@@ -41,7 +45,6 @@ import {
41} from "@t3tools/shared/codexArtifactTemplates";45} from "@t3tools/shared/codexArtifactTemplates";
42import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";46import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
43import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";47import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
44import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless";
45import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";48import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
46import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";49import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
47import {50import {
...@@ -75,12 +78,7 @@ import {...@@ -75,12 +78,7 @@ import {
75 type RefObject,78 type RefObject,
76} from "react";79} from "react";
77import {80import {
78 Markdown,81 ActivityIndicator,
79 type CustomRenderers,
80 type NodeStyleOverrides,
81 type PartialMarkdownTheme,
82} from "react-native-nitro-markdown";
83import {
84 Alert,82 Alert,
85 Image,83 Image,
86 Platform,84 Platform,
...@@ -114,10 +112,11 @@ import { downloadAndShareAttachment } from "../../lib/attachmentDownload";...@@ -114,10 +112,11 @@ import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
114import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";112import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
115import { faviconUrlForOrigin } from "@t3tools/shared/favicon";113import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
116import {114import {
117 hasNativeSelectableMarkdownText,
118 SelectableMarkdownText,115 SelectableMarkdownText,
119 type MarkdownFileContextMenu,116 type MarkdownFileContextMenu,
120 type MarkdownImageRenderer,117 type MarkdownImageRenderer,
118 type MarkdownImageSourceResolver,
119 type MarkdownLinkCustomization,
121 type NativeMarkdownTextStyle,120 type NativeMarkdownTextStyle,
122 type SelectableMarkdownSkill,121 type SelectableMarkdownSkill,
123} from "../../native/SelectableMarkdownText";122} from "../../native/SelectableMarkdownText";
...@@ -154,15 +153,15 @@ import {...@@ -154,15 +153,15 @@ import {
154 resolveNativeMarkdownTypography,153 resolveNativeMarkdownTypography,
155} from "../../lib/appearancePreferences";154} from "../../lib/appearancePreferences";
156import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";155import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
157import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";156import { markdownFileIconSource } from "../../lib/markdownFileIcons";
158import { PierreEntryIcon } from "../../components/PierreEntryIcon";157import { PierreEntryIcon } from "../../components/PierreEntryIcon";
159import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons";158import { markdownLinkIconSource } from "../../lib/markdownLinkIcons";
160import {159import {
161 normalizeNativeMarkdownUrl,160 normalizeNativeMarkdownUrl,
162 resolveMarkdownInlineCodePresentation,161 resolveMarkdownInlineCodePresentation,
163 resolveMarkdownLinkIcon,162 resolveMarkdownLinkIcon,
164 resolveMarkdownLinkPresentation,163 resolveMarkdownLinkPresentation,
165} from "@t3tools/mobile-markdown-text/links";164} from "../../lib/markdownLinks";
166import {165import {
167 failedFeedRunIds,166 failedFeedRunIds,
168 deriveThreadFeedPresentation,167 deriveThreadFeedPresentation,
...@@ -212,7 +211,6 @@ import { environmentThreadShells, threadEnvironment } from "../../state/threads"...@@ -212,7 +211,6 @@ import { environmentThreadShells, threadEnvironment } from "../../state/threads"
212import { useAtomCommand } from "../../state/use-atom-command";211import { useAtomCommand } from "../../state/use-atom-command";
213import { useV2ItemSupport } from "../../state/v2-item-support";212import { useV2ItemSupport } from "../../state/v2-item-support";
214import {213import {
215 basename,
216 fileRoutePathSegments,214 fileRoutePathSegments,
217 isAbsolutePath,215 isAbsolutePath,
218 resolveWorkspaceRelativeFilePath,216 resolveWorkspaceRelativeFilePath,
...@@ -677,7 +675,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) {...@@ -677,7 +675,6 @@ function MessageAttachmentUnknown(props: { readonly name: string }) {
677 );675 );
678}676}
679677
680const ThreadMediaVisibleContext = createContext(false);
681// LegendList only computes hook visibility when the list has a viewability config.678// LegendList only computes hook visibility when the list has a viewability config.
682const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };679const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 };
683680
...@@ -689,164 +686,24 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) {...@@ -689,164 +686,24 @@ function ThreadMediaVisibility(props: { readonly children: ReactNode }) {
689 return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>;686 return <ThreadMediaVisibleContext value={visible}>{props.children}</ThreadMediaVisibleContext>;
690}687}
691688
692function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource }) {689interface MarkdownLinkHandlers {
693 const { source } = props;690 readonly onLinkPress: (href: string) => void;
694 const visible = useContext(ThreadMediaVisibleContext);691 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
695 const thumbnailKey = mediaVideoThumbnailKey(source);692 readonly onFileContextMenuAction: (href: string, actionId: string) => void;
696 const asset = useAssetUrlState(693 readonly onImagePress?: (href: string) => void;
697 "environmentId" in source ? source.environmentId : null,694 readonly resolveImageSource?: MarkdownImageSourceResolver;
698 "resource" in source ? source.resource : null,695 readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined;
699 );
700 const refreshAssetUrl = useRefreshAssetUrl(
701 "environmentId" in source ? source.environmentId : null,
702 "resource" in source ? source.resource : null,
703 );
704 const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
705 return (
706 <MediaVideoPlayer
707 key={thumbnailKey}
708 uri={uri}
709 resolvePlaybackUri={
710 "resource" in source
711 ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl())
712 : undefined
713 }
714 name={source.name}
715 thumbnailKey={thumbnailKey}
716 thumbnailVisible={visible}
717 unavailable={"resource" in source && asset._tag === "Failure"}
718 actionsSource={source.actionsSource}
719 />
720 );
721}696}
722697
723const MARKDOWN_MONO_FONT = Platform.select({
724 ios: "ui-monospace",
725 android: "monospace",
726 default: "monospace",
727});
728
729interface MarkdownStyleSets {698interface MarkdownStyleSets {
730 readonly user: MarkdownStyleSet;699 readonly user: MarkdownStyleSet;
731 readonly assistant: MarkdownStyleSet;700 readonly assistant: MarkdownStyleSet;
732}701}
733702
734interface MarkdownStyleSet {703interface MarkdownStyleSet {
735 readonly theme: PartialMarkdownTheme;
736 readonly styles: NodeStyleOverrides;
737 readonly renderers: CustomRenderers;
738 readonly nativeTextStyle: NativeMarkdownTextStyle;704 readonly nativeTextStyle: NativeMarkdownTextStyle;
739}705}
740706
741const failedMarkdownFaviconHosts = new Set<string>();
742const MarkdownLinkLabelContext = createContext<"file" | "other" | null>(null);
743const markdownLinkStyles = StyleSheet.create({
744 inlineIcon: {
745 width: 14,
746 height: 14,
747 marginHorizontal: 3,
748 transform: [{ translateY: 2 }],
749 flexShrink: 0,
750 },
751 favicon: {
752 borderRadius: 3,
753 },
754});
755
756const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
757 readonly children: ReactNode;
758 readonly color: string;
759 readonly host: string;
760 readonly href: string;
761 readonly onPress: (href: string) => void;
762}) {
763 const [failedHost, setFailedHost] = useState<string | null>(null);
764 const linkIcon = resolveMarkdownLinkIcon(props.host);
765 const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`);
766
767 return (
768 <NativeText
769 className="font-sans"
770 onPress={() => props.onPress(props.href)}
771 style={{
772 color: props.color,
773 textDecorationLine: "none",
774 }}
775 >
776 {linkIcon ? (
777 <Image
778 source={markdownLinkIconSource(linkIcon)}
779 style={markdownLinkStyles.inlineIcon}
780 tintColor={props.color}
781 />
782 ) : faviconUrl !== null &&
783 failedHost !== props.host &&
784 !failedMarkdownFaviconHosts.has(props.host) ? (
785 <Image
786 source={{
787 uri: faviconUrl,
788 }}
789 style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]}
790 onError={() => {
791 failedMarkdownFaviconHosts.add(props.host);
792 setFailedHost(props.host);
793 }}
794 />
795 ) : (
796 <NativeText style={{ color: props.color }}>{" ◉ "}</NativeText>
797 )}
798 {props.children}
799 </NativeText>
800 );
801});
802
803function MarkdownInlineCode(props: {
804 readonly content: string;
805 readonly textColor: string;
806 readonly codeColor: string;
807 readonly fontSize: number;
808 readonly lineHeight: number;
809 readonly onLinkPress: (href: string) => void;
810}) {
811 const insideLink = useContext(MarkdownLinkLabelContext);
812 const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content);
813 return (
814 <NativeText
815 className={presentation ? "font-t3-bold" : "font-mono"}
816 onPress={presentation ? () => props.onLinkPress(presentation.href) : undefined}
817 style={{
818 color: presentation ? props.textColor : props.codeColor,
819 fontSize: props.fontSize,
820 lineHeight: props.lineHeight,
821 }}
822 >
823 {presentation ? (
824 <Image
825 source={markdownFileIconSource(presentation.icon)}
826 style={markdownLinkStyles.inlineIcon}
827 />
828 ) : null}
829 {presentation?.label ?? props.content}
830 </NativeText>
831 );
832}
833
834function MarkdownImage(props: {
835 readonly node: MarkdownNode;
836 readonly renderImage: MarkdownImageRenderer;
837}) {
838 const insideLink = useContext(MarkdownLinkLabelContext);
839 if (insideLink === "file")
840 return <NativeText>{props.node.alt ?? props.node.title ?? ""}</NativeText>;
841 return props.node.href
842 ? props.renderImage({
843 href: props.node.href,
844 alt: props.node.alt ?? null,
845 title: props.node.title ?? null,
846 })
847 : null;
848}
849
850const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<707const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record<
851 CodexArtifactTemplate["artifactKind"],708 CodexArtifactTemplate["artifactKind"],
852 AppSymbolName709 AppSymbolName
...@@ -907,13 +764,6 @@ function ArtifactTemplateCard(props: {...@@ -907,13 +764,6 @@ function ArtifactTemplateCard(props: {
907 );764 );
908}765}
909766
910/** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */
911interface MarkdownLinkHandlers {
912 readonly onLinkPress: (href: string) => void;
913 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
914 readonly onFileContextMenuAction: (href: string, actionId: string) => void;
915}
916
917const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {767const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
918 readonly markdown: string;768 readonly markdown: string;
919 readonly environmentId: EnvironmentId;769 readonly environmentId: EnvironmentId;
...@@ -939,7 +789,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {...@@ -939,7 +789,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
939 if (segment.markdown.trim().length === 0) return null;789 if (segment.markdown.trim().length === 0) return null;
940790
941 const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown);791 const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown);
942 return hasNativeSelectableMarkdownText() ? (792 return (
943 <SelectableMarkdownText793 <SelectableMarkdownText
944 key={`markdown:${segment.sourceOffset}`}794 key={`markdown:${segment.sourceOffset}`}
945 markdown={markdown}795 markdown={markdown}
...@@ -948,156 +798,20 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {...@@ -948,156 +798,20 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
948 {...props.linkHandlers}798 {...props.linkHandlers}
949 renderImage={props.renderImage}799 renderImage={props.renderImage}
950 />800 />
951 ) : (
952 <Markdown
953 key={`markdown:${segment.sourceOffset}`}
954 options={{ gfm: true }}
955 renderers={props.markdownStyles.renderers}
956 styles={props.markdownStyles.styles}
957 theme={props.markdownStyles.theme}
958 >
959 {markdown}
960 </Markdown>
961 );801 );
962 });802 });
963});803});
964804
965function MarkdownCodeBlock(props: {805function useMarkdownStyles(): MarkdownStyleSets {
966 readonly backgroundColor: string;806 const { appearance } = useAppearancePreferences();
967 readonly borderColor: string;
968 readonly content: string;
969 readonly copyTintColor: ColorValue;
970 readonly headerTextColor: string;
971 readonly fontSize: number;
972 readonly highlightCode: boolean;
973 readonly language?: string | null;
974 readonly lineHeight: number;
975 readonly textColor: string;
976 readonly theme: ReviewDiffTheme;
977}) {
978 const content = props.content.replace(/\n$/, "");
979 const languageLabel = props.language?.trim() || "text";
980 const highlighted = useMarkdownCodeHighlight({
981 code: content,
982 enabled: props.highlightCode && Boolean(props.language?.trim()),
983 language: props.language,
984 theme: props.theme,
985 });
986 let tokenOffset = 0;
987
988 return (
989 <View
990 className="my-3 min-w-0 max-w-full self-stretch overflow-hidden rounded-lg border"
991 style={{ backgroundColor: props.backgroundColor, borderColor: props.borderColor }}
992 >
993 <View
994 className="flex-row items-center justify-between gap-2 border-b py-1 pr-1.5 pl-3.5"
995 style={{ borderBottomColor: props.borderColor }}
996 >
997 <NativeText
998 className="flex-1 font-mono uppercase opacity-70"
999 numberOfLines={1}
1000 style={{
1001 color: props.headerTextColor,
1002 fontSize: props.fontSize,
1003 ...(Platform.OS === "android" ? { includeFontPadding: false } : null),
1004 }}
1005 >
1006 {languageLabel}
1007 </NativeText>
1008 <CopyTextButton
1009 accessibilityLabel="Copy code"
1010 text={content}
1011 tintColor={props.copyTintColor}
1012 buttonSize={32}
1013 iconSize={16}
1014 />
1015 </View>
1016 <ScrollView
1017 horizontal
1018 bounces={false}
1019 nestedScrollEnabled={Platform.OS === "android"}
1020 showsHorizontalScrollIndicator={false}
1021 contentContainerClassName="px-3.5 py-3"
1022 >
1023 <NativeText
1024 selectable
1025 selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined}
1026 className="font-mono"
1027 style={{
1028 color: props.textColor,
1029 fontSize: props.fontSize,
1030 lineHeight: props.lineHeight,
1031 ...(Platform.OS === "android" ? { includeFontPadding: false } : null),
1032 }}
1033 >
1034 {highlighted
1035 ? highlighted.map((line, lineIndex) => {
1036 const lineStartOffset = tokenOffset;
1037 const lineText = line.map((token) => token.content).join("");
1038 const renderedLine = (
1039 <NativeText key={`line:${lineStartOffset}:${lineText}`}>
1040 {line.map((token) => {
1041 const startOffset = tokenOffset;
1042 tokenOffset += token.content.length;
1043 const fontStyle =
1044 token.fontStyle !== null && (token.fontStyle & 1) === 1
1045 ? ("italic" as const)
1046 : ("normal" as const);
1047 const fontWeight =
1048 token.fontStyle !== null && (token.fontStyle & 2) === 2
1049 ? ("700" as const)
1050 : ("400" as const);
1051
1052 return (
1053 <NativeText
1054 key={`${startOffset}:${token.content}:${token.color ?? ""}:${
1055 token.fontStyle ?? ""
1056 }`}
1057 style={{
1058 color: token.color ?? props.textColor,
1059 fontStyle,
1060 fontWeight,
1061 }}
1062 >
1063 {token.content}
1064 </NativeText>
1065 );
1066 })}
1067 {lineIndex + 1 < highlighted.length ? "\n" : ""}
1068 </NativeText>
1069 );
1070 if (lineIndex + 1 < highlighted.length) {
1071 tokenOffset += 1;
1072 }
1073 return renderedLine;
1074 })
1075 : content}
1076 </NativeText>
1077 </ScrollView>
1078 </View>
1079 );
1080}
1081
1082function useMarkdownStyles(
1083 onLinkPress: (href: string) => void,
1084 renderImage: MarkdownImageRenderer,
1085): MarkdownStyleSets {
1086 const { appearance, themeAppearance } = useAppearancePreferences();
1087 const markdownFontSizes = useMemo(
1088 () => resolveMarkdownFontSizes(appearance.baseFontSize),
1089 [appearance.baseFontSize],
1090 );
1091 const nativeMarkdownTypography = useMemo(807 const nativeMarkdownTypography = useMemo(
1092 () => resolveNativeMarkdownTypography(appearance.baseFontSize),808 () => resolveNativeMarkdownTypography(appearance.baseFontSize),
1093 [appearance.baseFontSize],809 [appearance.baseFontSize],
1094 );810 );
1095 const themeMode = themeAppearance;
1096 const theme = useUniwindTheme();811 const theme = useUniwindTheme();
1097 const markdownBodyColor = theme["--color-md-body"];812 const markdownBodyColor = theme["--color-md-body"];
1098 const markdownStrongColor = theme["--color-md-strong"];813 const markdownStrongColor = theme["--color-md-strong"];
1099 const markdownLinkColor = theme["--color-md-link"];814 const markdownLinkColor = theme["--color-md-link"];
1100 const markdownBlockquoteBg = theme["--color-md-blockquote-bg"];
1101 const markdownBlockquoteBorder = theme["--color-md-blockquote-border"];815 const markdownBlockquoteBorder = theme["--color-md-blockquote-border"];
1102 const markdownCodeBg = theme["--color-md-code-bg"];816 const markdownCodeBg = theme["--color-md-code-bg"];
1103 const markdownCodeText = theme["--color-md-code-text"];817 const markdownCodeText = theme["--color-md-code-text"];
...@@ -1113,291 +827,14 @@ function useMarkdownStyles(...@@ -1113,291 +827,14 @@ function useMarkdownStyles(
1113 const markdownUserCodeText = theme["--color-md-user-code-text"];827 const markdownUserCodeText = theme["--color-md-user-code-text"];
1114 const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"];828 const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"];
1115 const markdownUserFenceBg = theme["--color-md-user-fence-bg"];829 const markdownUserFenceBg = theme["--color-md-user-fence-bg"];
1116 const markdownUserFenceText = theme["--color-md-user-fence-text"];
1117 const iconSubtleColor = theme["--color-icon-subtle"];
1118 const inlineSkillForeground = theme["--color-inline-skill-foreground"];830 const inlineSkillForeground = theme["--color-inline-skill-foreground"];
1119 const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"];831 const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"];
1120 const userBubbleForegroundMuted = theme["--color-user-bubble-foreground-muted"];
1121 const regularFontFamily = useFontFamily("regular");832 const regularFontFamily = useFontFamily("regular");
1122 const boldFontFamily = useFontFamily("bold");833 const boldFontFamily = useFontFamily("bold");
1123834
1124 return useMemo(() => {835 return useMemo(() => {
1125 const baseTheme: PartialMarkdownTheme = {
1126 colors: {
1127 text: markdownBodyColor,
1128 heading: markdownStrongColor,
1129 link: markdownLinkColor,
1130 blockquote: markdownBlockquoteBorder,
1131 border: markdownHrColor,
1132 surface: "transparent",
1133 surfaceLight: markdownBlockquoteBg,
1134 accent: markdownLinkColor,
1135 tableBorder: markdownHrColor,
1136 tableHeader: markdownBlockquoteBg,
1137 tableHeaderText: markdownStrongColor,
1138 tableRowOdd: "transparent",
1139 tableRowEven: "transparent",
1140 },
1141 spacing: {
1142 xs: 4,
1143 s: 4,
1144 m: 8,
1145 l: 8,
1146 xl: 16,
1147 },
1148 fontSizes: {
1149 s: markdownFontSizes.s,
1150 m: markdownFontSizes.m,
1151 h1: markdownFontSizes.h1,
1152 h2: markdownFontSizes.h2,
1153 h3: markdownFontSizes.h3,
1154 h4: markdownFontSizes.h4,
1155 h5: markdownFontSizes.h5,
1156 h6: markdownFontSizes.h6,
1157 },
1158 fontFamilies: {
1159 regular: regularFontFamily,
1160 heading: boldFontFamily,
1161 mono: MARKDOWN_MONO_FONT,
1162 },
1163 headingWeight: "700",
1164 borderRadius: {
1165 s: 4,
1166 m: 8,
1167 l: 12,
1168 },
1169 showCodeLanguage: false,
1170 };
1171
1172 const baseStyles: NodeStyleOverrides = {
1173 document: { flexShrink: 1 },
1174 paragraph: { marginTop: 0, marginBottom: 10 },
1175 list: { marginTop: 4, marginBottom: 8 },
1176 list_item: { marginTop: 0, marginBottom: 4 },
1177 task_list_item: { marginTop: 0, marginBottom: 4 },
1178 text: { lineHeight: markdownFontSizes.bodyLineHeight },
1179 bold: {
1180 fontWeight: "700",
1181 color: markdownStrongColor,
1182 fontFamily: boldFontFamily,
1183 },
1184 italic: { fontStyle: "italic" },
1185 link: {
1186 color: markdownLinkColor,
1187 textDecorationLine: "underline" as const,
1188 },
1189 blockquote: {
1190 borderLeftWidth: 2,
1191 borderLeftColor: markdownBlockquoteBorder,
1192 paddingLeft: 11,
1193 paddingVertical: 2,
1194 marginLeft: 0,
1195 marginVertical: 10,
1196 },
1197 heading: {
1198 fontFamily: boldFontFamily,
1199 color: markdownStrongColor,
1200 marginTop: 18,
1201 marginBottom: 8,
1202 },
1203 horizontal_rule: {
1204 backgroundColor: markdownHrColor,
1205 height: 1,
1206 marginVertical: 12,
1207 },
1208 };
1209
1210 const createMarkdownRenderers = (
1211 inlineTextColor: string,
1212 inlineCodeTextColor: string,
1213 blockBackgroundColor: string,
1214 blockTextColor: string,
1215 copyTintColor: ColorValue,
1216 preserveSoftBreaks: boolean,
1217 highlightCode: boolean,
1218 ): CustomRenderers => ({
1219 link: ({ children, node, href = "" }) => {
1220 const presentation = resolveMarkdownLinkPresentation(href);
1221 if (presentation.kind === "file") {
1222 return (
1223 <MarkdownLinkLabelContext.Provider value="file">
1224 <NativeText onPress={() => onLinkPress(href)} style={{ color: inlineTextColor }}>
1225 {!isMarkdownFileLinkLabel(getTextContent(node), href) && <>{children} </>}
1226 <NativeText
1227 className="font-t3-bold"
1228 onPress={() => onLinkPress(href)}
1229 style={{ color: inlineTextColor }}
1230 >
1231 <Image
1232 source={markdownFileIconSource(presentation.icon)}
1233 style={markdownLinkStyles.inlineIcon}
1234 />
1235 {presentation.label}
1236 </NativeText>
1237 </NativeText>
1238 </MarkdownLinkLabelContext.Provider>
1239 );
1240 }
1241 if (presentation.kind === "external") {
1242 return (
1243 <MarkdownLinkLabelContext.Provider value="other">
1244 <MarkdownExternalLink
1245 href={presentation.href}
1246 host={presentation.host}
1247 color={markdownLinkColor}
1248 onPress={onLinkPress}
1249 >
1250 {children}
1251 </MarkdownExternalLink>
1252 </MarkdownLinkLabelContext.Provider>
1253 );
1254 }
1255 const linkHref = presentation.href;
1256 // A thread link opens the thread in the app, through the feed's link handler.
1257 const isThreadLink = parseThreadLinkHref(href) !== null;
1258 return (
1259 <MarkdownLinkLabelContext.Provider value="other">
1260 <NativeText
1261 className="underline"
1262 onPress={
1263 isThreadLink
1264 ? () => onLinkPress(href)
1265 : linkHref
1266 ? () => {
1267 void tryOpenExternalUrl(linkHref, "markdown-link");
1268 }
1269 : undefined
1270 }
1271 style={{ color: markdownLinkColor }}
1272 >
1273 {children}
1274 </NativeText>
1275 </MarkdownLinkLabelContext.Provider>
1276 );
1277 },
1278 list: ({ node, Renderer, ordered = false, start = 1 }) => (
1279 <View className="mt-0.5 mb-2">
1280 {node.children?.map((child, index) => {
1281 const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`;
1282 if (child.type === "task_list_item") {
1283 return (
1284 <Renderer key={childKey} node={child} depth={1} inListItem parentIsText={false} />
1285 );
1286 }
1287 return (
1288 <View className="mb-[3px] flex-row items-start" key={childKey}>
1289 <NativeText
1290 className="font-sans"
1291 style={{
1292 width: ordered ? 22 : 12,
1293 marginRight: 5,
1294 color: inlineTextColor,
1295 fontSize: markdownFontSizes.m,
1296 lineHeight: markdownFontSizes.bodyLineHeight,
1297 textAlign: ordered ? "right" : "center",
1298 }}
1299 >
1300 {ordered ? `${start + index}.` : "•"}
1301 </NativeText>
1302 <View className="min-w-0 flex-1 shrink overflow-hidden">
1303 <Renderer node={child} depth={1} inListItem parentIsText={false} />
1304 </View>
1305 </View>
1306 );
1307 })}
1308 </View>
1309 ),
1310 image: ({ node }) => <MarkdownImage node={node} renderImage={renderImage} />,
1311 code_inline: ({ content }) => (
1312 <MarkdownInlineCode
1313 content={content ?? ""}
1314 textColor={inlineTextColor}
1315 codeColor={inlineCodeTextColor}
1316 fontSize={markdownFontSizes.codeBlockFontSize}
1317 lineHeight={markdownFontSizes.bodyLineHeight}
1318 onLinkPress={onLinkPress}
1319 />
1320 ),
1321 ...(preserveSoftBreaks
1322 ? {
1323 soft_break: () => <NativeText>{"\n"}</NativeText>,
1324 }
1325 : {}),
1326 code_block: ({ content = "", language }) => (
1327 <MarkdownCodeBlock
1328 backgroundColor={blockBackgroundColor}
1329 borderColor={markdownHrColor}
1330 content={content}
1331 copyTintColor={copyTintColor}
1332 fontSize={markdownFontSizes.codeBlockFontSize}
1333 headerTextColor={blockTextColor}
1334 highlightCode={highlightCode}
1335 language={language}
1336 lineHeight={markdownFontSizes.codeBlockLineHeight}
1337 textColor={blockTextColor}
1338 theme={themeMode}
1339 />
1340 ),
1341 });
1342
1343 const userTheme: PartialMarkdownTheme = {
1344 ...baseTheme,
1345 colors: {
1346 ...baseTheme.colors,
1347 text: markdownUserBodyColor,
1348 heading: markdownUserBodyColor,
1349 link: markdownUserBodyColor,
1350 code: markdownUserCodeText,
1351 codeBackground: markdownUserCodeBg,
1352 border: markdownUserFenceBg,
1353 },
1354 };
1355 const userStyles: NodeStyleOverrides = {
1356 ...baseStyles,
1357 paragraph: { marginTop: 0, marginBottom: 0 },
1358 bold: {
1359 fontWeight: "700",
1360 color: markdownUserBodyColor,
1361 fontFamily: boldFontFamily,
1362 },
1363 heading: {
1364 ...baseStyles.heading,
1365 color: markdownUserBodyColor,
1366 marginTop: 8,
1367 marginBottom: 4,
1368 },
1369 link: {
1370 color: markdownUserBodyColor,
1371 textDecorationLine: "underline" as const,
1372 },
1373 };
1374
1375 const assistantTheme: PartialMarkdownTheme = {
1376 ...baseTheme,
1377 colors: {
1378 ...baseTheme.colors,
1379 code: markdownCodeText,
1380 codeBackground: markdownCodeBg,
1381 border: markdownCodeBg,
1382 },
1383 };
1384 const assistantStyles: NodeStyleOverrides = {
1385 ...baseStyles,
1386 };
1387
1388 return {836 return {
1389 user: {837 user: {
1390 theme: userTheme,
1391 styles: userStyles,
1392 renderers: createMarkdownRenderers(
1393 markdownUserCodeText,
1394 markdownUserInlineCodeText,
1395 markdownUserFenceBg,
1396 markdownUserFenceText,
1397 userBubbleForegroundMuted,
1398 true,
1399 false,
1400 ),
1401 nativeTextStyle: {838 nativeTextStyle: {
1402 color: markdownUserBodyColor,839 color: markdownUserBodyColor,
1403 strongColor: markdownUserBodyColor,840 strongColor: markdownUserBodyColor,
...@@ -1421,17 +858,6 @@ function useMarkdownStyles(...@@ -1421,17 +858,6 @@ function useMarkdownStyles(
1421 },858 },
1422 },859 },
1423 assistant: {860 assistant: {
1424 theme: assistantTheme,
1425 styles: assistantStyles,
1426 renderers: createMarkdownRenderers(
1427 markdownCodeText,
1428 markdownInlineCodeText,
1429 markdownCodeBg,
1430 markdownCodeText,
1431 iconSubtleColor,
1432 false,
1433 true,
1434 ),
1435 nativeTextStyle: {861 nativeTextStyle: {
1436 color: markdownBodyColor,862 color: markdownBodyColor,
1437 strongColor: markdownStrongColor,863 strongColor: markdownStrongColor,
...@@ -1458,14 +884,11 @@ function useMarkdownStyles(...@@ -1458,14 +884,11 @@ function useMarkdownStyles(
1458 }, [884 }, [
1459 boldFontFamily,885 boldFontFamily,
1460 contextChipBorderColor,886 contextChipBorderColor,
1461 iconSubtleColor,
1462 inlineSkillForeground,887 inlineSkillForeground,
1463 markdownBlockquoteBg,
1464 markdownBlockquoteBorder,888 markdownBlockquoteBorder,
1465 markdownBodyColor,889 markdownBodyColor,
1466 markdownCodeBg,890 markdownCodeBg,
1467 markdownCodeText,891 markdownCodeText,
1468 markdownFontSizes,
1469 markdownHrColor,892 markdownHrColor,
1470 markdownInlineCodeText,893 markdownInlineCodeText,
1471 markdownLinkColor,894 markdownLinkColor,
...@@ -1474,14 +897,9 @@ function useMarkdownStyles(...@@ -1474,14 +897,9 @@ function useMarkdownStyles(
1474 markdownUserCodeBg,897 markdownUserCodeBg,
1475 markdownUserCodeText,898 markdownUserCodeText,
1476 markdownUserFenceBg,899 markdownUserFenceBg,
1477 markdownUserFenceText,
1478 markdownUserInlineCodeText,900 markdownUserInlineCodeText,
1479 nativeMarkdownTypography,901 nativeMarkdownTypography,
1480 onLinkPress,
1481 renderImage,
1482 regularFontFamily,902 regularFontFamily,
1483 themeMode,
1484 userBubbleForegroundMuted,
1485 userBubbleSkillForeground,903 userBubbleSkillForeground,
1486 ]);904 ]);
1487}905}
...@@ -2077,10 +1495,58 @@ function UserMessageContent(props: UserMessageContentProps) {...@@ -2077,10 +1495,58 @@ function UserMessageContent(props: UserMessageContentProps) {
2077 };1495 };
2078 return (1496 return (
2079 <>1497 <>
2080 <LegacyUserMessageContent1498 <UserMessageMarkdownContent
2081 {...props}1499 {...props}
2082 text={text}1500 text={text}
2083 linkHandlers={{ ...props.linkHandlers, onLinkPress }}1501 linkHandlers={{
1502 ...props.linkHandlers,
1503 onLinkPress,
1504 fileContextMenu: (href) => {
1505 const reference = parseComposerContextHref(href);
1506 if (!reference) return props.linkHandlers.fileContextMenu(href);
1507 const record = props.context?.records.find(
1508 (item) => item.contextId === reference.contextId,
1509 );
1510 return {
1511 title: record?.label ?? "Context unavailable",
1512 actions: [
1513 { id: "open-context", title: "Open context" },
1514 { id: "copy-context", title: "Copy context", disabled: !record },
1515 ],
1516 };
1517 },
1518 onFileContextMenuAction: (href, actionId) => {
1519 const reference = parseComposerContextHref(href);
1520 if (!reference) return props.linkHandlers.onFileContextMenuAction(href, actionId);
1521 if (actionId === "open-context") return onLinkPress(href);
1522 const record = props.context?.records.find(
1523 (item) => item.contextId === reference.contextId,
1524 );
1525 if (actionId === "copy-context" && record) {
1526 void writeComposerContextClipboard(
1527 formatComposerContextReference({
1528 ...reference,
1529 label: record.label,
1530 }),
1531 {
1532 version: 1,
1533 source: { environmentId: props.environmentId },
1534 records: [record],
1535 },
1536 )
1537 .then(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success))
1538 .catch(() => {
1539 Alert.alert("Could not copy context", "Try copying this context again.");
1540 });
1541 }
1542 },
1543 linkCustomization: (href) => {
1544 const presentation = enrichedContextLinkPresentation(href, props.context?.records);
1545 return presentation
1546 ? { color: presentation.color, icon: markdownFileIconSource(presentation.icon) }
1547 : props.linkHandlers.linkCustomization?.(href);
1548 },
1549 }}
2084 />1550 />
2085 {selected ? (1551 {selected ? (
2086 <ComposerContextSheet1552 <ComposerContextSheet
...@@ -2095,12 +1561,10 @@ function UserMessageContent(props: UserMessageContentProps) {...@@ -2095,12 +1561,10 @@ function UserMessageContent(props: UserMessageContentProps) {
2095 );1561 );
2096}1562}
20971563
2098function LegacyUserMessageContent(props: UserMessageContentProps) {1564function UserMessageMarkdownContent(props: UserMessageContentProps) {
2099 const text = props.text;1565 const text = props.text;
2100 const segments = parseReviewCommentMessageSegments(text);1566 const segments = parseReviewCommentMessageSegments(text);
2101 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");1567 const hasReviewComment = segments.some((segment) => segment.kind === "review-comment");
2102 // A message can hold both a review comment and context chips. The fragment travels with every
2103 // text run, so copying from the segmented branch carries the same context as the plain one.
2104 const contextClipboardFragment = props.context1568 const contextClipboardFragment = props.context
2105 ? (encodeComposerContextFragment({1569 ? (encodeComposerContextFragment({
2106 version: 1,1570 version: 1,
...@@ -2109,28 +1573,16 @@ function LegacyUserMessageContent(props: UserMessageContentProps) {...@@ -2109,28 +1573,16 @@ function LegacyUserMessageContent(props: UserMessageContentProps) {
2109 }) ?? undefined)1573 }) ?? undefined)
2110 : undefined;1574 : undefined;
2111 if (!hasReviewComment) {1575 if (!hasReviewComment) {
2112 if (hasNativeSelectableMarkdownText()) {
2113 return (
2114 <SelectableMarkdownText
2115 markdown={text}
2116 contextClipboardFragment={contextClipboardFragment}
2117 skills={props.skills}
2118 textStyle={props.markdownStyles.nativeTextStyle}
2119 preserveSoftBreaks
2120 {...props.linkHandlers}
2121 renderImage={props.renderImage}
2122 />
2123 );
2124 }
2125 return (1576 return (
2126 <Markdown1577 <SelectableMarkdownText
2127 options={{ gfm: true }}1578 markdown={text}
2128 renderers={props.markdownStyles.renderers}1579 contextClipboardFragment={contextClipboardFragment}
2129 styles={props.markdownStyles.styles}1580 skills={props.skills}
2130 theme={props.markdownStyles.theme}1581 textStyle={props.markdownStyles.nativeTextStyle}
2131 >1582 preserveSoftBreaks
2132 {text}1583 {...props.linkHandlers}
2133 </Markdown>1584 renderImage={props.renderImage}
1585 />
2134 );1586 );
2135 }1587 }
21361588
...@@ -2152,7 +1604,7 @@ function LegacyUserMessageContent(props: UserMessageContentProps) {...@@ -2152,7 +1604,7 @@ function LegacyUserMessageContent(props: UserMessageContentProps) {
2152 return null;1604 return null;
2153 }1605 }
21541606
2155 return hasNativeSelectableMarkdownText() ? (1607 return (
2156 <SelectableMarkdownText1608 <SelectableMarkdownText
2157 key={segment.id}1609 key={segment.id}
2158 markdown={text}1610 markdown={text}
...@@ -2163,16 +1615,6 @@ function LegacyUserMessageContent(props: UserMessageContentProps) {...@@ -2163,16 +1615,6 @@ function LegacyUserMessageContent(props: UserMessageContentProps) {
2163 {...props.linkHandlers}1615 {...props.linkHandlers}
2164 renderImage={props.renderImage}1616 renderImage={props.renderImage}
2165 />1617 />
2166 ) : (
2167 <Markdown
2168 key={segment.id}
2169 options={{ gfm: true }}
2170 renderers={props.markdownStyles.renderers}
2171 styles={props.markdownStyles.styles}
2172 theme={props.markdownStyles.theme}
2173 >
2174 {text}
2175 </Markdown>
2176 );1618 );
2177 })}1619 })}
2178 </View>1620 </View>
...@@ -2333,7 +1775,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2333,7 +1775,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2333 const screenColor = theme["--color-screen"];1775 const screenColor = theme["--color-screen"];
2334 const userBubbleColor = theme["--color-user-bubble"];1776 const userBubbleColor = theme["--color-user-bubble"];
2335 const onMarkdownLinkPress = useCallback(1777 const onMarkdownLinkPress = useCallback(
2336 (href: string) => {1778 (href: string, imageEmbed = false) => {
2337 // A thread link names a thread in this feed's environment.1779 // A thread link names a thread in this feed's environment.
2338 const linkedThreadId = parseThreadLinkHref(href);1780 const linkedThreadId = parseThreadLinkHref(href);
2339 if (linkedThreadId) {1781 if (linkedThreadId) {
...@@ -2343,6 +1785,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2343,6 +1785,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2343 });1785 });
2344 return;1786 return;
2345 }1787 }
1788 const media = resolveMarkdownMediaPreview(href, {
1789 environmentId: props.environmentId,
1790 threadId: props.threadId,
1791 workspaceRoot: props.workspaceRoot,
1792 imageEmbed,
1793 });
1794 if (media) {
1795 void Haptics.selectionAsync();
1796 if (media.kind === "video") {
1797 setExpandedVideo((current) => current ?? media.source);
1798 } else {
1799 setExpandedFile((current) => current ?? media.source);
1800 }
1801 return;
1802 }
1803
2346 const presentation = resolveMarkdownLinkPresentation(href);1804 const presentation = resolveMarkdownLinkPresentation(href);
2347 if (presentation.kind === "file") {1805 if (presentation.kind === "file") {
2348 const relativePath = resolveWorkspaceRelativeFilePath(1806 const relativePath = resolveWorkspaceRelativeFilePath(
...@@ -2351,22 +1809,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2351,22 +1809,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2351 );1809 );
2352 if (relativePath) {1810 if (relativePath) {
2353 void Haptics.selectionAsync();1811 void Haptics.selectionAsync();
2354 if (isPdfFile({ name: relativePath })) {
2355 setExpandedFile(
2356 (current) =>
2357 current ?? {
2358 kind: "pdf",
2359 name: relativePath.split("/").at(-1),
2360 environmentId: props.environmentId,
2361 resource: {
2362 _tag: "workspace-file",
2363 threadId: props.threadId,
2364 path: relativePath,
2365 },
2366 },
2367 );
2368 return;
2369 }
2370 navigation.navigate("ThreadFile", {1812 navigation.navigate("ThreadFile", {
2371 environmentId: String(props.environmentId),1813 environmentId: String(props.environmentId),
2372 threadId: String(props.threadId),1814 threadId: String(props.threadId),
...@@ -2377,41 +1819,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2377,41 +1819,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2377 }1819 }
2378 }1820 }
23791821
2380 const media = resolveMarkdownMediaPreview(href, {
2381 environmentId: props.environmentId,
2382 threadId: props.threadId,
2383 workspaceRoot: props.workspaceRoot,
2384 });
2385 if (media) {
2386 void Haptics.selectionAsync();
2387 if (media.kind === "video") {
2388 setExpandedVideo((current) => current ?? media.source);
2389 } else {
2390 setExpandedFile((current) => current ?? media.source);
2391 }
2392 return;
2393 }
2394
2395 // A host file outside the workspace, such as a report an agent wrote to1822 // A host file outside the workspace, such as a report an agent wrote to
2396 // a temp directory, opens read-only in the file screen.1823 // a temp directory, opens read-only in the file screen.
2397 if (presentation.kind === "file" && isAbsolutePath(presentation.path)) {1824 if (presentation.kind === "file" && isAbsolutePath(presentation.path)) {
2398 void Haptics.selectionAsync();1825 void Haptics.selectionAsync();
2399 if (isPdfFile({ name: presentation.path })) {
2400 setExpandedFile(
2401 (current) =>
2402 current ?? {
2403 kind: "pdf",
2404 name: basename(presentation.path),
2405 environmentId: props.environmentId,
2406 resource: {
2407 _tag: "media-file",
2408 threadId: props.threadId,
2409 path: presentation.path,
2410 },
2411 },
2412 );
2413 return;
2414 }
2415 navigation.navigate("ThreadFile", {1826 navigation.navigate("ThreadFile", {
2416 environmentId: String(props.environmentId),1827 environmentId: String(props.environmentId),
2417 threadId: String(props.threadId),1828 threadId: String(props.threadId),
...@@ -2433,9 +1844,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2433,9 +1844,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2433 },1844 },
2434 [props.environmentId, props.threadId, props.workspaceRoot, navigation, openPullRequest],1845 [props.environmentId, props.threadId, props.workspaceRoot, navigation, openPullRequest],
2435 );1846 );
1847 const resolveImageSource = useMarkdownImageSource({
1848 environmentId: props.environmentId,
1849 threadId: props.threadId,
1850 workspaceRoot: props.workspaceRoot ?? null,
1851 });
2436 const markdownLinkHandlers = useMemo<MarkdownLinkHandlers>(1852 const markdownLinkHandlers = useMemo<MarkdownLinkHandlers>(
2437 () => ({1853 () => ({
2438 onLinkPress: onMarkdownLinkPress,1854 onLinkPress: onMarkdownLinkPress,
1855 onImagePress: (href) => onMarkdownLinkPress(href, true),
1856 resolveImageSource,
2439 fileContextMenu: (href) => {1857 fileContextMenu: (href) => {
2440 const target = resolveFileChipTarget(href, props.workspaceRoot);1858 const target = resolveFileChipTarget(href, props.workspaceRoot);
2441 return target ? fileChipMenu(target) : undefined;1859 return target ? fileChipMenu(target) : undefined;
...@@ -2459,7 +1877,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2459,7 +1877,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2459 }1877 }
2460 },1878 },
2461 }),1879 }),
2462 [onMarkdownLinkPress, props.workspaceRoot, shareFileChip],1880 [onMarkdownLinkPress, props.workspaceRoot, shareFileChip, resolveImageSource],
2463 );1881 );
2464 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(1882 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
2465 (image) => {1883 (image) => {
...@@ -2483,6 +1901,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2483,6 +1901,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2483 <ThreadMarkdownImageView1901 <ThreadMarkdownImageView
2484 uri={normalizeNativeMarkdownUrl(imageSource.uri)}1902 uri={normalizeNativeMarkdownUrl(imageSource.uri)}
2485 sourceKey={imageSource.uri}1903 sourceKey={imageSource.uri}
1904 format={
1905 /\.svg(?:[?#]|$)/i.test(imageSource.uri) ||
1906 imageSource.uri.startsWith("data:image/svg+xml")
1907 ? "svg"
1908 : undefined
1909 }
2486 unavailable={false}1910 unavailable={false}
2487 alt={image.alt}1911 alt={image.alt}
2488 actionsSource={media?.source.actionsSource}1912 actionsSource={media?.source.actionsSource}
...@@ -2542,7 +1966,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2542,7 +1966,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2542 },1966 },
2543 [props.environmentId, props.threadId, props.workspaceRoot],1967 [props.environmentId, props.threadId, props.workspaceRoot],
2544 );1968 );
2545 const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage);1969 const markdownStyles = useMarkdownStyles();
2546 const renderReasoning = useCallback(1970 const renderReasoning = useCallback(
2547 (text: string) => (1971 (text: string) => (
2548 <AssistantMarkdownContent1972 <AssistantMarkdownContent
apps/mobile/src/features/threads/ThreadMarkdownImage.tsx+81-16
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2import { Image } from "expo-image";2import { Image } from "expo-image";
3import { createContext, useContext, useEffect, useId, useState } from "react";3import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react";
4import { Pressable, StyleSheet, View, type ViewStyle } from "react-native";4import { fetch } from "expo/fetch";
5import { parse, SvgAst, type JsxAST } from "react-native-svg";
6import { ActivityIndicator, Pressable, StyleSheet, View, type ViewStyle } from "react-native";
57
6import { AppActivityIndicator } from "../../components/AppActivityIndicator";8import { AppActivityIndicator } from "../../components/AppActivityIndicator";
7import { useFilePreviewGallery } from "../../components/FilePreviewGallery";9import { useFilePreviewGallery } from "../../components/FilePreviewGallery";
...@@ -10,12 +12,13 @@ import type { FilePreviewSource } from "../../components/FilePreviewModal";...@@ -10,12 +12,13 @@ import type { FilePreviewSource } from "../../components/FilePreviewModal";
10import { MediaActionsMenu } from "../../components/MediaActionsMenu";12import { MediaActionsMenu } from "../../components/MediaActionsMenu";
11import { PresentationSource } from "../../components/NativePresentation";13import { PresentationSource } from "../../components/NativePresentation";
12import { useMediaActions } from "../../state/mediaActions";14import { useMediaActions } from "../../state/mediaActions";
13import { type MediaActionsSource } from "../../lib/mediaActionsSource";15import type { MediaActionsSource } from "../../lib/mediaActionsSource";
14import { useAssetUrlState } from "../../state/assets";16import { useAssetUrlState } from "../../state/assets";
15import {17import {
16 MARKDOWN_IMAGE_MAX_WIDTH,18 MARKDOWN_IMAGE_MAX_WIDTH,
17 type MarkdownImageDisplaySize,19 type MarkdownImageDisplaySize,
18 resolveMarkdownImageDisplaySize,20 resolveMarkdownImageDisplaySize,
21 resolveSvgImageSize,
19} from "./markdownImageSize";22} from "./markdownImageSize";
2023
21/**24/**
...@@ -35,6 +38,7 @@ export function ThreadMarkdownImageView(props: {...@@ -35,6 +38,7 @@ export function ThreadMarkdownImageView(props: {
35 readonly alt: string | null;38 readonly alt: string | null;
36 /** Pixel size from the server, when it could read the header; the frame is final from the first render. */39 /** Pixel size from the server, when it could read the header; the frame is final from the first render. */
37 readonly knownSize?: { readonly width: number; readonly height: number } | undefined;40 readonly knownSize?: { readonly width: number; readonly height: number } | undefined;
41 readonly format?: "svg";
38 readonly actionsSource?: MediaActionsSource;42 readonly actionsSource?: MediaActionsSource;
39 readonly onPressPreview: (source: FilePreviewSource) => void;43 readonly onPressPreview: (source: FilePreviewSource) => void;
40}) {44}) {
...@@ -126,6 +130,7 @@ export function ThreadMarkdownImageView(props: {...@@ -126,6 +130,7 @@ export function ThreadMarkdownImageView(props: {
126 <ThreadMarkdownImageRequest130 <ThreadMarkdownImageRequest
127 key={props.uri}131 key={props.uri}
128 uri={props.uri}132 uri={props.uri}
133 format={props.format}
129 onLoad={setDecodedSize}134 onLoad={setDecodedSize}
130 onError={() => setFailedUri(props.uri)}135 onError={() => setFailedUri(props.uri)}
131 />136 />
...@@ -147,23 +152,35 @@ function ThreadMarkdownImageRequest(props: {...@@ -147,23 +152,35 @@ function ThreadMarkdownImageRequest(props: {
147 readonly uri: string;152 readonly uri: string;
148 readonly onLoad: (sourceSize: { width: number; height: number }) => void;153 readonly onLoad: (sourceSize: { width: number; height: number }) => void;
149 readonly onError: () => void;154 readonly onError: () => void;
155 readonly format?: "svg";
150}) {156}) {
151 const [loaded, setLoaded] = useState(false);157 const [loaded, setLoaded] = useState(false);
152158
153 return (159 return (
154 <>160 <>
155 <Image161 {props.format === "svg" ? (
156 source={{ uri: props.uri }}162 <ThreadMarkdownSvg
157 contentFit="contain"163 uri={props.uri}
158 cachePolicy="memory-disk"164 onLoad={(size) => {
159 accessible={false}165 setLoaded(true);
160 onLoad={(event) => {166 if (size) props.onLoad(size);
161 setLoaded(true);167 }}
162 props.onLoad(event.source);168 onError={props.onError}
163 }}169 />
164 onError={props.onError}170 ) : (
165 style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}171 <Image
166 />172 source={{ uri: props.uri }}
173 contentFit="contain"
174 cachePolicy="memory-disk"
175 accessible={false}
176 onLoad={(event) => {
177 setLoaded(true);
178 props.onLoad(event.source);
179 }}
180 onError={props.onError}
181 style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }}
182 />
183 )}
167 {loaded ? null : (184 {loaded ? null : (
168 <View185 <View
169 pointerEvents="none"186 pointerEvents="none"
...@@ -176,12 +193,54 @@ function ThreadMarkdownImageRequest(props: {...@@ -176,12 +193,54 @@ function ThreadMarkdownImageRequest(props: {
176 );193 );
177}194}
178195
196function ThreadMarkdownSvg(props: {
197 readonly uri: string;
198 readonly onLoad: (size: { width: number; height: number } | null) => void;
199 readonly onError: () => void;
200}) {
201 const [ast, setAst] = useState<JsxAST | null>(null);
202 const onLoad = useEffectEvent(props.onLoad);
203 const onError = useEffectEvent(props.onError);
204 useEffect(() => {
205 const controller = new AbortController();
206 const load = async () => {
207 if (props.uri.startsWith("data:image/svg+xml")) {
208 const comma = props.uri.indexOf(",");
209 if (comma < 0) throw new Error("SVG data URI has no content");
210 const content = decodeURIComponent(props.uri.slice(comma + 1));
211 return props.uri.slice(0, comma).includes(";base64")
212 ? new TextDecoder().decode(
213 Uint8Array.from(atob(content), (character) => character.charCodeAt(0)),
214 )
215 : content;
216 }
217 const response = await fetch(props.uri, { signal: controller.signal });
218 if (!response.ok && !(response.status === 0 && props.uri.startsWith("file://")))
219 throw new Error(`SVG request failed: ${response.status}`);
220 return response.text();
221 };
222 void load()
223 .then((xml) => {
224 const parsed = parse(xml);
225 if (!parsed) throw new Error("SVG has no root element");
226 if (controller.signal.aborted) return;
227 setAst(parsed);
228 onLoad(resolveSvgImageSize(parsed.props));
229 })
230 .catch(() => {
231 if (!controller.signal.aborted) onError();
232 });
233 return () => controller.abort();
234 }, [props.uri]);
235 return <SvgAst ast={ast} override={{ width: "100%", height: "100%", accessible: false }} />;
236}
237
179/** Environment-hosted image that loads through a signed asset URL. */238/** Environment-hosted image that loads through a signed asset URL. */
180export function ThreadMarkdownImage(props: {239export function ThreadMarkdownImage(props: {
181 readonly environmentId: EnvironmentId;240 readonly environmentId: EnvironmentId;
182 readonly resource: Extract<241 readonly resource: Extract<
183 AssetResource,242 AssetResource,
184 { readonly _tag: "attachment" | "media-file" | "tool-output-image" }243 { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "tool-output-image" }
185 >;244 >;
186 readonly alt: string | null;245 readonly alt: string | null;
187 readonly srcFragment?: string;246 readonly srcFragment?: string;
...@@ -203,6 +262,12 @@ export function ThreadMarkdownImage(props: {...@@ -203,6 +262,12 @@ export function ThreadMarkdownImage(props: {
203 }262 }
204 unavailable={assetUrl._tag === "Failure"}263 unavailable={assetUrl._tag === "Failure"}
205 knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}264 knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined}
265 format={
266 (props.resource._tag === "media-file" || props.resource._tag === "draft-workspace-file") &&
267 /\.svg$/i.test(props.resource.path)
268 ? "svg"
269 : undefined
270 }
206 alt={props.alt}271 alt={props.alt}
207 actionsSource={props.actionsSource}272 actionsSource={props.actionsSource}
208 onPressPreview={(source) => {273 onPressPreview={(source) => {
apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx created+45
...@@ -0,0 +1,45 @@
1import { createContext, useContext } from "react";
2import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
3import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
4import {
5 mediaVideoPreviewUri,
6 mediaVideoThumbnailKey,
7 type MediaVideoPreviewSource,
8} from "../../lib/videoPreviewSource";
9
10export const ThreadMediaVisibleContext = createContext(false);
11
12export function ThreadMarkdownVideo(props: {
13 readonly source: MediaVideoPreviewSource;
14 readonly thumbnailVisible?: boolean;
15}) {
16 const { source } = props;
17 const feedVisible = useContext(ThreadMediaVisibleContext);
18 const visible = props.thumbnailVisible ?? feedVisible;
19 const thumbnailKey = mediaVideoThumbnailKey(source);
20 const asset = useAssetUrlState(
21 "environmentId" in source ? source.environmentId : null,
22 "resource" in source ? source.resource : null,
23 );
24 const refreshAssetUrl = useRefreshAssetUrl(
25 "environmentId" in source ? source.environmentId : null,
26 "resource" in source ? source.resource : null,
27 );
28 const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
29 return (
30 <MediaVideoPlayer
31 key={thumbnailKey}
32 uri={uri}
33 resolvePlaybackUri={
34 "resource" in source
35 ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl())
36 : undefined
37 }
38 name={source.name}
39 thumbnailKey={thumbnailKey}
40 thumbnailVisible={visible}
41 unavailable={"resource" in source && asset._tag === "Failure"}
42 actionsSource={source.actionsSource}
43 />
44 );
45}
apps/mobile/src/features/threads/fileChipMenu.ts+2-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { fileBasename } from "@t3tools/shared/path";1import { fileBasename } from "@t3tools/shared/path";
2import type { ThreadId } from "@t3tools/contracts";2import type { ThreadId } from "@t3tools/contracts";
3import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";3import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks";
4import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types";4import type { MarkdownFileContextMenu } from "../../native/SelectableMarkdownText.types";
5import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview";5import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview";
66
7import {7import {
apps/mobile/src/features/threads/markdownImageSize.test.ts+18
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 MARKDOWN_IMAGE_MAX_HEIGHT,4 MARKDOWN_IMAGE_MAX_HEIGHT,
5 MARKDOWN_IMAGE_MAX_WIDTH,5 MARKDOWN_IMAGE_MAX_WIDTH,
6 resolveMarkdownImageDisplaySize,6 resolveMarkdownImageDisplaySize,
7 resolveSvgImageSize,
7} from "./markdownImageSize";8} from "./markdownImageSize";
89
9describe("resolveMarkdownImageDisplaySize", () => {10describe("resolveMarkdownImageDisplaySize", () => {
...@@ -60,3 +61,20 @@ describe("resolveMarkdownImageDisplaySize", () => {...@@ -60,3 +61,20 @@ describe("resolveMarkdownImageDisplaySize", () => {
60 ).toBeNull();61 ).toBeNull();
61 });62 });
62});63});
64
65describe("resolveSvgImageSize", () => {
66 it("uses pixel dimensions before the viewBox", () => {
67 expect(resolveSvgImageSize({ width: "32px", height: "48", viewBox: "0 0 100 100" })).toEqual({
68 width: 32,
69 height: 48,
70 });
71 });
72
73 it("uses the viewBox for responsive SVGs", () => {
74 expect(
75 resolveSvgImageSize({ width: "100%", height: "100%", viewBox: "-10, -20, 120, 240" }),
76 ).toEqual({ width: 120, height: 240 });
77 expect(resolveSvgImageSize({ viewBox: "0 0 100 NaN" })).toBeNull();
78 expect(resolveSvgImageSize({ viewBox: "0 0 0 100" })).toBeNull();
79 });
80});
apps/mobile/src/features/threads/markdownImageSize.ts+28
...@@ -6,6 +6,34 @@ export interface MarkdownImageDisplaySize {...@@ -6,6 +6,34 @@ export interface MarkdownImageDisplaySize {
6 readonly height: number;6 readonly height: number;
7}7}
88
9/** SVG percentages need a viewport; use the intrinsic viewBox in that case. */
10export function resolveSvgImageSize(props: Readonly<Record<string, unknown>>) {
11 const dimension = (value: unknown) => {
12 if (typeof value === "number") return value;
13 if (typeof value !== "string" || !/^\s*\d+(?:\.\d+)?(?:px)?\s*$/.test(value)) return NaN;
14 return Number.parseFloat(value);
15 };
16 const width = dimension(props.width);
17 const height = dimension(props.height);
18 if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0)
19 return { width, height };
20 const viewBox =
21 typeof props.viewBox === "string"
22 ? props.viewBox
23 .trim()
24 .split(/[\s,]+/)
25 .map(Number)
26 : [];
27 if (
28 viewBox.length !== 4 ||
29 !viewBox.every(Number.isFinite) ||
30 viewBox[2]! <= 0 ||
31 viewBox[3]! <= 0
32 )
33 return null;
34 return { width: viewBox[2]!, height: viewBox[3]! };
35}
36
9/** Keeps small images intrinsic while fitting larger images inside the chat viewport. */37/** Keeps small images intrinsic while fitting larger images inside the chat viewport. */
10export function resolveMarkdownImageDisplaySize(input: {38export function resolveMarkdownImageDisplaySize(input: {
11 readonly sourceWidth: number;39 readonly sourceWidth: number;
apps/mobile/src/features/threads/worktree-setup-card.tsx+1
...@@ -217,6 +217,7 @@ function SetupDetailsSheet({...@@ -217,6 +217,7 @@ function SetupDetailsSheet({
217 return (217 return (
218 <WorktreeSetupSheet height={bodyHeight} onClose={onClose}>218 <WorktreeSetupSheet height={bodyHeight} onClose={onClose}>
219 <ScrollView219 <ScrollView
220 contentInsetAdjustmentBehavior="automatic"
220 bounces={false}221 bounces={false}
221 onContentSizeChange={(_width, height) => setBodyHeight(height)}222 onContentSizeChange={(_width, height) => setBodyHeight(height)}
222 contentContainerStyle={{223 contentContainerStyle={{
apps/mobile/src/features/threads/worktree-setup-sheet.tsx+6-56
...@@ -1,15 +1,5 @@...@@ -1,15 +1,5 @@
1import { useState, type ReactElement } from "react";1import type { ReactElement } from "react";
2import { Modal, View } from "react-native";2import { AppSheet } from "../../components/AppSheet";
3import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
4import { withUniwind } from "uniwind";
5import { ContextSheetSize } from "../../components/ContextSheetSize";
6
7const NativeScreen = withUniwind(Screen);
8const NativeHeader = withUniwind(ScreenStackHeaderConfig, {
9 backgroundColor: { fromClassName: "backgroundColorClassName", styleProperty: "backgroundColor" },
10 color: { fromClassName: "tintColorClassName", styleProperty: "accentColor" },
11 titleColor: { fromClassName: "titleColorClassName", styleProperty: "accentColor" },
12});
133
14export interface WorktreeSetupSheetProps {4export interface WorktreeSetupSheetProps {
15 children: ReactElement;5 children: ReactElement;
...@@ -17,50 +7,10 @@ export interface WorktreeSetupSheetProps {...@@ -17,50 +7,10 @@ export interface WorktreeSetupSheetProps {
17 onClose: () => void;7 onClose: () => void;
18}8}
199
20export function WorktreeSetupSheet({ children, height, onClose }: WorktreeSetupSheetProps) {10export function WorktreeSetupSheet({ children, onClose }: WorktreeSetupSheetProps) {
21 const [headerHeight, setHeaderHeight] = useState(44);
22 return (11 return (
23 <Modal12 <AppSheet title="Worktree setup" onClose={onClose}>
24 animationType="slide"13 {children}
25 presentationStyle="pageSheet"14 </AppSheet>
26 allowSwipeDismissal
27 onRequestClose={onClose}
28 >
29 <View collapsable={false} className="flex-1 bg-sheet-solid">
30 <ContextSheetSize height={height + headerHeight} />
31 {/* The nested stack supplies UIKit's navigation bar inside the sheet. */}
32 <ScreenStack style={{ flex: 1 }}>
33 <NativeScreen
34 activityState={2}
35 enabled
36 isNativeStack
37 screenId="worktree-setup-details"
38 onHeaderHeightChange={(event) => setHeaderHeight(event.nativeEvent.headerHeight)}
39 className="flex-1 bg-sheet-solid"
40 >
41 {children}
42 <NativeHeader
43 title="Worktree setup"
44 titleColorClassName="accent-foreground"
45 tintColorClassName="accent-foreground"
46 backgroundColorClassName="bg-sheet-solid"
47 hideBackButton
48 hideShadow
49 translucent={false}
50 headerRightBarButtonItems={[
51 {
52 type: "button",
53 title: "Done",
54 variant: "done",
55 accessibilityLabel: "Close setup details",
56 identifier: "worktree-setup-done",
57 onPress: onClose,
58 },
59 ]}
60 />
61 </NativeScreen>
62 </ScreenStack>
63 </View>
64 </Modal>
65 );15 );
66}16}
apps/mobile/src/lib/composerChipPresentation.test.ts created+85
...@@ -0,0 +1,85 @@
1import { describe, expect, it } from "vite-plus/test";
2
3describe("composerChipSizeSuffix", () => {
4 it("labels attachment records with a human size, matching web's chip", async () => {
5 const { composerChipSizeSuffix } = await import("./composerChipPresentation");
6 expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 1024 })).toBe("1 KB");
7 expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 3_700_000 })).toBe("3.5 MB");
8 expect(composerChipSizeSuffix({ kind: "image", sizeBytes: 2048 })).toBe("2 KB");
9 });
10
11 it("adds nothing for records that carry no bytes", async () => {
12 const { composerChipSizeSuffix } = await import("./composerChipPresentation");
13 // Terminal/review/PR chips have no size to show.
14 expect(composerChipSizeSuffix({ kind: "terminal" })).toBe("");
15 expect(composerChipSizeSuffix({ kind: "file" })).toBe("");
16 expect(composerChipSizeSuffix(undefined)).toBe("");
17 });
18});
19
20describe("contextChipPresentation image detection", () => {
21 it("treats a picture attached through the file picker as an image", async () => {
22 const { contextChipPresentation } = await import("./composerChipPresentation");
23 // The document picker types every pick as `file`, so the name has to carry the intent.
24 expect(
25 contextChipPresentation("file", { kind: "file", name: "IMG_4997.PNG", mimeType: "" }),
26 ).toEqual({ accent: "#d55665", symbol: "photo" });
27 expect(
28 contextChipPresentation("file", {
29 kind: "file",
30 name: "shot",
31 mimeType: "image/jpeg",
32 }),
33 ).toEqual({ accent: "#d55665", symbol: "photo" });
34 });
35
36 it("leaves genuine documents and videos alone", async () => {
37 const { contextChipPresentation } = await import("./composerChipPresentation");
38 expect(
39 contextChipPresentation("file", { kind: "file", name: "notes.txt", mimeType: "text/plain" }),
40 ).toEqual({ accent: "#0090cd", symbol: "doc" });
41 expect(
42 contextChipPresentation("file", { kind: "file", name: "clip.mp4", mimeType: "video/mp4" }),
43 ).toEqual({ accent: "#d06217", symbol: "play.rectangle" });
44 });
45});
46
47describe("pull request chip status", () => {
48 const chip = async (state: string, isDraft = false) => {
49 const { contextChipPresentation } = await import("./composerChipPresentation");
50 return contextChipPresentation("review-comment", {
51 kind: "review-comment",
52 sectionId: "pull-request:10978",
53 pullRequest: { state, isDraft },
54 });
55 };
56
57 it("colours a pull request by its state, the way web and the forge do", async () => {
58 expect((await chip("open")).accent).toBe("#009f6e");
59 expect((await chip("open", true)).accent).toBe("#7f8793");
60 expect((await chip("merged")).accent).toBe("#8a70dd");
61 expect((await chip("closed")).accent).toBe("#d55665");
62 });
63
64 it("keeps one glyph across every state, so only colour carries the status", async () => {
65 // Web draws a fixed `git-pull-request` and encodes state in colour alone. A per-state glyph
66 // here would put mobile out of step with it.
67 const symbols = await Promise.all(
68 [chip("open"), chip("open", true), chip("merged"), chip("closed")].map(
69 async (pending) => (await pending).symbol,
70 ),
71 );
72 expect(new Set(symbols)).toEqual(new Set(["git-pull-request"]));
73 });
74
75 it("falls back to the generic pull request chip when the state is unknown", async () => {
76 const { contextChipPresentation } = await import("./composerChipPresentation");
77 // An older server may send no metadata at all; the chip still has to render.
78 expect(
79 contextChipPresentation("review-comment", {
80 kind: "review-comment",
81 sectionId: "pull-request:1",
82 }).accent,
83 ).toBe("#7079e4");
84 });
85});
apps/mobile/src/lib/composerChipPresentation.ts created+87
...@@ -0,0 +1,87 @@
1import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
2import { imageMimeType } from "@t3tools/shared/image";
3import { videoMimeType } from "@t3tools/shared/video";
4/**
5 * Every accent shares a lightness so no kind reads heavier than another; only hue carries
6 * identity. These are the sRGB form of the same OKLCH set web uses, so a chip looks the
7 * same on every surface. See `composerInlineChip.ts`.
8 */
9const CONTEXT_CHIP_PRESENTATIONS = {
10 image: { accent: "#d55665", symbol: "photo" },
11 video: { accent: "#d06217", symbol: "play.rectangle" },
12 file: { accent: "#0090cd", symbol: "doc" },
13 mention: { accent: "#0096af", symbol: "doc" },
14 terminal: { accent: "#009f6e", symbol: "terminal" },
15 element: { accent: "#b87501", symbol: "cursorarrow.click" },
16 "preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" },
17 "review-comment": { accent: "#8a70dd", symbol: "text.bubble" },
18 "pull-request": { accent: "#7079e4", symbol: "git-pull-request" },
19 skill: { accent: "#b261be", symbol: "cube" },
20} as const;
21
22/**
23 * The size an attachment chip reports beside its name, matching web. Rendered as its own
24 * smaller run, so it carries no separator. Only attachment-backed records have bytes.
25 */
26export function composerChipSizeSuffix(record?: {
27 readonly kind?: string;
28 readonly sizeBytes?: number;
29}): string {
30 if (record?.kind !== "file" && record?.kind !== "image") return "";
31 return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : "";
32}
33
34/**
35 * A pull request chip is coloured by what the pull request *is*, the way web colours it and
36 * the way the forge itself does: green open, grey draft, purple merged, red closed. The glyph
37 * stays the same across all four, as it does on web — state is carried by colour alone.
38 */
39const PULL_REQUEST_CHIP_PRESENTATIONS = {
40 open: { accent: "#009f6e", symbol: "git-pull-request" },
41 draft: { accent: "#7f8793", symbol: "git-pull-request" },
42 merged: { accent: "#8a70dd", symbol: "git-pull-request" },
43 closed: { accent: "#d55665", symbol: "git-pull-request" },
44} as const;
45
46export function contextChipPresentation(
47 kind: string,
48 record?: {
49 readonly kind?: string;
50 readonly name?: string;
51 readonly mimeType?: string;
52 readonly sectionId?: string;
53 readonly pullRequest?: {
54 readonly state?: string;
55 readonly isDraft?: boolean;
56 };
57 },
58) {
59 const presentationKind =
60 kind === "file" &&
61 videoMimeType({
62 name: record?.name ?? "",
63 mimeType: record?.mimeType ?? "",
64 })
65 ? "video"
66 : // A picture chosen through the file picker is typed `file`, but it is still a
67 // picture: it reads as one to the user and should not wear the generic file chip.
68 kind === "file" &&
69 imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null
70 ? "image"
71 : kind === "review-comment" && record?.sectionId?.startsWith("pull-request:")
72 ? "pull-request"
73 : kind;
74 if (presentationKind === "pull-request") {
75 const pullRequest = record?.pullRequest;
76 const state =
77 pullRequest?.state === "open" && pullRequest.isDraft === true
78 ? "draft"
79 : (pullRequest?.state ?? "");
80 if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) {
81 return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS];
82 }
83 }
84 return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind)
85 ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS]
86 : CONTEXT_CHIP_PRESENTATIONS.file;
87}
apps/mobile/src/lib/enrichedLinkPresentation.test.ts created+112
...@@ -0,0 +1,112 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { ComposerContextId, ReviewCommentContextRecord } from "@t3tools/contracts";
3import {
4 enrichedContextLinkPresentation,
5 enrichedLinkVariantPattern,
6 enrichedSkillLinkRegex,
7 enrichedSkillReference,
8 ENRICHED_INLINE_FILE_LINK_REGEX,
9} from "./enrichedLinkPresentation";
10
11describe("Enriched link presentation", () => {
12 it("recognizes file-shaped code without turning commands or URLs into links", () => {
13 for (const path of [
14 "src/app.tsx",
15 "./README.md",
16 "/tmp/report.md",
17 "C:\\repo\\app.tsx",
18 "app.tsx:12:3",
19 ]) {
20 expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(path), path).toBe(true);
21 }
22 for (const text of [
23 "npm run dev",
24 "port:3000",
25 "TODO:12",
26 "example.com",
27 "https://example.com/app.tsx",
28 "x + y",
29 "README.md",
30 "/app/settings",
31 ]) {
32 expect(ENRICHED_INLINE_FILE_LINK_REGEX.test(text), text).toBe(false);
33 }
34 });
35
36 it("only recognizes complete known skill names and escapes their punctuation", () => {
37 const regex = enrichedSkillLinkRegex([{ name: "check" }, { name: "check.mobile" }]);
38 expect(regex?.exec("Try $check.mobile next")?.[0]).toBe("$check.mobile");
39 expect(regex?.exec("Try $check-mobile next")).toBeNull();
40 expect(regex?.exec("Try $checkXmobile next")).toBeNull();
41 expect(regex?.exec("prefix$check next")).toBeNull();
42 expect(enrichedSkillLinkRegex([])).toBeNull();
43 });
44 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
45 "recognizes and presents known skills with %s while rejecting adjacent tokens",
46 (sigil) => {
47 const skills = [{ name: "check", displayName: "Check" }];
48 const regex = enrichedSkillLinkRegex(skills)!;
49 expect(regex.exec(`Try ${sigil}check now`)?.[0]).toBe(`${sigil}check`);
50 expect(enrichedSkillReference(`${sigil}check`, skills)).toBe(skills[0]);
51 expect(regex.test(`prefix${sigil}check`)).toBe(false);
52 expect(regex.test(`${sigil}check-extra`)).toBe(false);
53 expect(regex.test(`${sigil}unknown`)).toBe(false);
54 expect(regex.test(`${sigil}${sigil}check`)).toBe(false);
55 },
56 );
57 it("matches only the complete URL, including literal regex metacharacters", () => {
58 const url = "file:///workspace/app[1].tsx?line=3+4";
59 const pattern = new RegExp(enrichedLinkVariantPattern(url));
60 expect(pattern.test(url)).toBe(true);
61 expect(pattern.test(`${url}/child`)).toBe(false);
62 expect(pattern.test("file:///workspace/app1Xtsx?line=34")).toBe(false);
63 });
64
65 it("uses context identity for PR status even when labels are identical", () => {
66 const record: ReviewCommentContextRecord = {
67 version: 1,
68 contextId: "review_1" as ComposerContextId,
69 kind: "review-comment",
70 label: "Review",
71 sectionId: "pull-request:7",
72 sectionTitle: "Review",
73 filePath: "app.tsx",
74 startIndex: 0,
75 endIndex: 1,
76 rangeLabel: "line 1",
77 text: "Comment",
78 diff: "",
79 pullRequest: {
80 number: 7,
81 title: "Review",
82 url: "https://github.com/example/repo/pull/7",
83 headBranch: "feature",
84 baseBranch: "main",
85 state: "merged",
86 isDraft: false,
87 },
88 };
89 expect(
90 enrichedContextLinkPresentation("t3-context://v1/review-comment/review_1", [record]),
91 ).toEqual({ color: "#8a70dd", icon: "git" });
92 expect(
93 enrichedContextLinkPresentation("t3-context://v1/review-comment/review_2", [record]),
94 ).toEqual({ color: "#8a70dd", icon: "markdown" });
95 });
96
97 it("retains recognizable presentation for unavailable and future context kinds", () => {
98 expect(enrichedContextLinkPresentation("t3-context://v1/terminal/missing")).toEqual({
99 color: "#009f6e",
100 icon: "bash",
101 });
102 expect(enrichedContextLinkPresentation("t3-context://v1/skill/missing")).toEqual({
103 color: "#b261be",
104 icon: "mcp",
105 });
106 expect(enrichedContextLinkPresentation("t3-context://v1/future/missing")).toEqual({
107 color: "#0090cd",
108 icon: "default",
109 });
110 expect(enrichedContextLinkPresentation("https://example.com/terminal/missing")).toBeUndefined();
111 });
112});
apps/mobile/src/lib/enrichedLinkPresentation.ts created+69
...@@ -0,0 +1,69 @@
1import type { ComposerContextRecord } from "@t3tools/contracts";
2import { contextChipPresentation } from "./composerChipPresentation";
3import type { MarkdownFileIcon } from "./markdownLinks";
4import type { SelectableMarkdownSkill } from "../native/SelectableMarkdownText.types";
5import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
6
7const CONTEXT_ICONS: Readonly<Record<string, MarkdownFileIcon>> = {
8 photo: "image",
9 "play.rectangle": "video",
10 terminal: "bash",
11 "cursorarrow.click": "react",
12 "text.bubble": "markdown",
13 "git-pull-request": "git",
14 cube: "mcp",
15 doc: "default",
16};
17
18/** Context identity comes from the URL, even when different chips share a label. */
19export function enrichedContextLinkPresentation(
20 href: string,
21 records?: ReadonlyArray<ComposerContextRecord>,
22) {
23 const reference = parseComposerContextHref(href);
24 if (!reference) return undefined;
25 const record = records?.find((candidate) => candidate.contextId === reference.contextId);
26 const presentation = contextChipPresentation(reference.kind, record);
27 return {
28 color: presentation.accent,
29 icon: CONTEXT_ICONS[presentation.symbol] ?? "default",
30 };
31}
32
33export function enrichedLinkVariantPattern(url: string) {
34 return `^${url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`;
35}
36
37/** Conservative recognition for Enriched's whole-span inline-code link hook. */
38export const ENRICHED_INLINE_FILE_LINK_REGEX =
39 /^(?!\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+)?)$/;
40
41export function enrichedSkillLinkRegex(skills?: ReadonlyArray<SelectableMarkdownSkill>) {
42 const names = [...new Set(skills?.map((skill) => skill.name) ?? [])].filter(Boolean);
43 if (names.length === 0) return null;
44 names.sort((a, b) => b.length - a.length);
45 const alternatives = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
46 return new RegExp(
47 `(?<![A-Za-z0-9_-])(?<!\\p{Sc})\\p{Sc}(?:${alternatives.join("|")})(?![A-Za-z0-9_-])`,
48 "u",
49 );
50}
51
52export function enrichedSkillDisplayName(skill: SelectableMarkdownSkill) {
53 return (
54 skill.displayName?.trim() ||
55 skill.name
56 .split(/[\s:_-]+/)
57 .filter(Boolean)
58 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
59 .join(" ")
60 );
61}
62
63export function enrichedSkillReference(
64 url: string,
65 skills?: ReadonlyArray<SelectableMarkdownSkill>,
66) {
67 const match = /^\p{Sc}(.+)$/u.exec(url);
68 return match ? skills?.find((skill) => skill.name === match[1]) : undefined;
69}
apps/mobile/src/lib/markdownDocument.ts created+146
...@@ -0,0 +1,146 @@
1import type { Root, RootContent } from "mdast";
2import { unified } from "unified";
3import remarkParse from "remark-parse";
4import remarkGfm from "remark-gfm";
5
6const parser = unified().use(remarkParse).use(remarkGfm);
7export type MarkdownNode = Root | RootContent;
8
9function decodeCodePoint(codePoint: number, entity: string): string {
10 if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
11 return entity;
12 }
13 return String.fromCodePoint(codePoint);
14}
15
16function decodeHtmlEntities(value: string): string {
17 return value.replace(
18 /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi,
19 (entity, decimal: string | undefined, hexadecimal: string | undefined) => {
20 if (decimal) {
21 return decodeCodePoint(Number.parseInt(decimal, 10), entity);
22 }
23 if (hexadecimal) {
24 return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity);
25 }
26 switch (entity.toLowerCase()) {
27 case "&amp;":
28 return "&";
29 case "&apos;":
30 return "'";
31 case "&gt;":
32 return ">";
33 case "&lt;":
34 return "<";
35 case "&nbsp;":
36 return "\u00a0";
37 case "&quot;":
38 return '"';
39 default:
40 return entity;
41 }
42 },
43 );
44}
45
46const HTML_TAG = /<!--[\s\S]*?-->|<\/?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi;
47
48function attribute(tag: string, name: string): string | null {
49 const attributes = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
50 for (const match of tag.matchAll(attributes)) {
51 if (match[1]?.toLowerCase() === name) {
52 return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? "");
53 }
54 }
55 return null;
56}
57
58/** Called only for parsed HTML nodes, so Markdown code examples keep their literal tags. */
59export function markdownHtmlContent(content: string): string {
60 // An HTML block such as <details> can also contain Markdown fences and code spans.
61 const codeRanges: Array<{ start: number; end: number }> = [];
62 const tags = [...content.matchAll(HTML_TAG)];
63 let tagIndex = 0;
64 const delimiters = [...content.matchAll(/`+|~{3,}/g)].filter((delimiter) => {
65 while (tags[tagIndex] && tags[tagIndex]!.index + tags[tagIndex]![0].length <= delimiter.index)
66 tagIndex += 1;
67 const tag = tags[tagIndex];
68 return !tag || delimiter.index < tag.index;
69 });
70 for (let index = 0; index < delimiters.length; index += 1) {
71 const opening = delimiters[index]!;
72 const lineStart = content.lastIndexOf("\n", opening.index - 1) + 1;
73 const fenced =
74 opening[0].length >= 3 && /^ {0,3}$/.test(content.slice(lineStart, opening.index));
75 if (opening[0][0] === "~" && !fenced) continue;
76 let closingIndex = -1;
77 for (let candidate = index + 1; candidate < delimiters.length; candidate += 1) {
78 const closing = delimiters[candidate]!;
79 if (!fenced && closing[0] === opening[0]) {
80 closingIndex = candidate;
81 break;
82 }
83 if (!fenced || closing[0][0] !== opening[0][0] || closing[0].length < opening[0].length)
84 continue;
85 const start = content.lastIndexOf("\n", closing.index - 1) + 1;
86 const nextLine = content.indexOf("\n", closing.index);
87 const end = nextLine === -1 ? content.length : nextLine;
88 if (
89 /^ {0,3}$/.test(content.slice(start, closing.index)) &&
90 /^\s*$/.test(content.slice(closing.index + closing[0].length, end))
91 ) {
92 closingIndex = candidate;
93 break;
94 }
95 }
96 if (closingIndex === -1) {
97 if (fenced) codeRanges.push({ start: opening.index, end: content.length });
98 continue;
99 }
100 const closing = delimiters[closingIndex]!;
101 codeRanges.push({ start: opening.index, end: closing.index + closing[0].length });
102 index = closingIndex;
103 }
104 return content.replace(HTML_TAG, (tag, offset: number) => {
105 if (codeRanges.some((range) => offset >= range.start && offset < range.end)) return tag;
106 if (tag.startsWith("<!--")) return "";
107 if (/^<img\b/i.test(tag)) {
108 const src = attribute(tag, "src");
109 if (!src) return "";
110 const alt = (attribute(tag, "alt") ?? "").replace(/[\\[\]&]/g, "\\$&");
111 const href = src
112 .replace(/&/g, "\\&")
113 .replace(/</g, "%3C")
114 .replace(/>/g, "%3E")
115 .replace(/\n/g, "");
116 return `![${alt}](<${href}>)`;
117 }
118 if (/^<summary\b/i.test(tag)) return "\n\n**";
119 if (/^<\/summary\s*>/i.test(tag)) return "**\n\n";
120 if (/^<br\b/i.test(tag)) return " \n";
121 if (/^<\/?(?:p|div|details)\b/i.test(tag)) return "\n\n";
122 return "";
123 });
124}
125
126/** Convert parsed HTML fragments only, preserving Markdown code and source around them. */
127export function markdownWithHtml(markdown: string): string {
128 if (!/<(?:!--|\/?[a-z])/i.test(markdown)) return markdown;
129 const edits: Array<{ start: number; end: number; content: string }> = [];
130 const visit = (node: MarkdownNode) => {
131 if (node.type === "html") {
132 const start = node.position?.start.offset;
133 const end = node.position?.end.offset;
134 if (start !== undefined && end !== undefined)
135 edits.push({ start, end, content: markdownHtmlContent(node.value) });
136 } else if ("children" in node) node.children.forEach(visit);
137 };
138 visit(parser.parse(markdown));
139 for (const edit of edits.reverse())
140 markdown = markdown.slice(0, edit.start) + edit.content + markdown.slice(edit.end);
141 return markdown;
142}
143
144export function parseMarkdownDocument(markdown: string): Root {
145 return parser.parse(markdownWithHtml(markdown));
146}
apps/mobile/src/lib/markdownFileIcons.generated.ts created+60
...@@ -0,0 +1,60 @@
1import type { ImageSourcePropType } from "react-native";
2
3export const MARKDOWN_FILE_ICON_SOURCES = {
4 agents: require("../../assets/file-icons/pierre_agents.png"),
5 astro: require("../../assets/file-icons/pierre_astro.png"),
6 babel: require("../../assets/file-icons/pierre_babel.png"),
7 bash: require("../../assets/file-icons/pierre_bash.png"),
8 biome: require("../../assets/file-icons/pierre_biome.png"),
9 bootstrap: require("../../assets/file-icons/pierre_bootstrap.png"),
10 browserslist: require("../../assets/file-icons/pierre_browserslist.png"),
11 bun: require("../../assets/file-icons/pierre_bun.png"),
12 c: require("../../assets/file-icons/pierre_c.png"),
13 claude: require("../../assets/file-icons/pierre_claude.png"),
14 cpp: require("../../assets/file-icons/pierre_cpp.png"),
15 css: require("../../assets/file-icons/pierre_css.png"),
16 database: require("../../assets/file-icons/pierre_database.png"),
17 default: require("../../assets/file-icons/pierre_default.png"),
18 docker: require("../../assets/file-icons/pierre_docker.png"),
19 eslint: require("../../assets/file-icons/pierre_eslint.png"),
20 font: require("../../assets/file-icons/pierre_font.png"),
21 git: require("../../assets/file-icons/pierre_git.png"),
22 go: require("../../assets/file-icons/pierre_go.png"),
23 graphql: require("../../assets/file-icons/pierre_graphql.png"),
24 html: require("../../assets/file-icons/pierre_html.png"),
25 image: require("../../assets/file-icons/pierre_image.png"),
26 javascript: require("../../assets/file-icons/pierre_javascript.png"),
27 json: require("../../assets/file-icons/pierre_json.png"),
28 markdown: require("../../assets/file-icons/pierre_markdown.png"),
29 mcp: require("../../assets/file-icons/pierre_mcp.png"),
30 nextjs: require("../../assets/file-icons/pierre_nextjs.png"),
31 npm: require("../../assets/file-icons/pierre_npm.png"),
32 oxc: require("../../assets/file-icons/pierre_oxc.png"),
33 pnpm: require("../../assets/file-icons/pierre_pnpm.png"),
34 postcss: require("../../assets/file-icons/pierre_postcss.png"),
35 prettier: require("../../assets/file-icons/pierre_prettier.png"),
36 python: require("../../assets/file-icons/pierre_python.png"),
37 react: require("../../assets/file-icons/pierre_react.png"),
38 ruby: require("../../assets/file-icons/pierre_ruby.png"),
39 rust: require("../../assets/file-icons/pierre_rust.png"),
40 sass: require("../../assets/file-icons/pierre_sass.png"),
41 stylelint: require("../../assets/file-icons/pierre_stylelint.png"),
42 svelte: require("../../assets/file-icons/pierre_svelte.png"),
43 svg: require("../../assets/file-icons/pierre_svg.png"),
44 svgo: require("../../assets/file-icons/pierre_svgo.png"),
45 swift: require("../../assets/file-icons/pierre_swift.png"),
46 table: require("../../assets/file-icons/pierre_table.png"),
47 tailwind: require("../../assets/file-icons/pierre_tailwind.png"),
48 terraform: require("../../assets/file-icons/pierre_terraform.png"),
49 text: require("../../assets/file-icons/pierre_text.png"),
50 typescript: require("../../assets/file-icons/pierre_typescript.png"),
51 video: require("../../assets/file-icons/pierre_video.png"),
52 vite: require("../../assets/file-icons/pierre_vite.png"),
53 vscode: require("../../assets/file-icons/pierre_vscode.png"),
54 vue: require("../../assets/file-icons/pierre_vue.png"),
55 wasm: require("../../assets/file-icons/pierre_wasm.png"),
56 webpack: require("../../assets/file-icons/pierre_webpack.png"),
57 yml: require("../../assets/file-icons/pierre_yml.png"),
58 zig: require("../../assets/file-icons/pierre_zig.png"),
59 zip: require("../../assets/file-icons/pierre_zip.png"),
60} as const satisfies Readonly<Record<string, ImageSourcePropType>>;
apps/mobile/src/lib/markdownFileIcons.ts created+13
...@@ -0,0 +1,13 @@
1import { Image, type ImageSourcePropType } from "react-native";
2
3import type { MarkdownFileIcon } from "./markdownLinks";
4import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
5
6export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType {
7 return MARKDOWN_FILE_ICON_SOURCES[icon];
8}
9
10/** URI for bundled icons rendered by native views. */
11export function markdownIconAssetUri(source: ImageSourcePropType): string | undefined {
12 return Image.resolveAssetSource(source)?.uri;
13}
apps/mobile/src/lib/markdownHtml.test.ts+46-52
...@@ -1,10 +1,6 @@...@@ -1,10 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import type { MarkdownNode } from "react-native-nitro-markdown/headless";2import { markdownHtmlContent, markdownWithHtml, parseMarkdownDocument } from "./markdownDocument";
3import {3import { markdownImageSources } from "./markdownImageSources";
4 markdownHtmlContent,
5 nativeMarkdownWithHtml,
6 nativeMarkdownDocumentChunks,
7} from "@t3tools/mobile-markdown-text/markdown";
84
9describe("HTML in native Markdown", () => {5describe("HTML in native Markdown", () => {
10 it("leaves code inside an HTML details block literal", () => {6 it("leaves code inside an HTML details block literal", () => {
...@@ -20,7 +16,7 @@ describe("HTML in native Markdown", () => {...@@ -20,7 +16,7 @@ describe("HTML in native Markdown", () => {
20 markdownHtmlContent(16 markdownHtmlContent(
21 '<img width="900" alt="Before > after [settings]" src="https://example.com/before.png?a=1&amp;b=2" />',17 '<img width="900" alt="Before > after [settings]" src="https://example.com/before.png?a=1&amp;b=2" />',
22 ),18 ),
23 ).toBe("![Before > after \\[settings\\]](<https://example.com/before.png?a=1&b=2>)");19 ).toBe("![Before > after \\[settings\\]](<https://example.com/before.png?a=1\\&b=2>)");
24 expect(20 expect(
25 markdownHtmlContent(21 markdownHtmlContent(
26 "<a href='https://example.com'><img src='https://example.com/banner.svg' alt='Review' /></a>",22 "<a href='https://example.com'><img src='https://example.com/banner.svg' alt='Review' /></a>",
...@@ -28,52 +24,50 @@ describe("HTML in native Markdown", () => {...@@ -28,52 +24,50 @@ describe("HTML in native Markdown", () => {
28 ).toBe("![Review](<https://example.com/banner.svg>)");24 ).toBe("![Review](<https://example.com/banner.svg>)");
29 });25 });
3026
31 it("removes bot comments and empty details wrappers without leaving display chunks", () => {27 it("decodes HTML attributes once and preserves entity-shaped query values through Markdown", () => {
32 const document: MarkdownNode = {28 const markdown =
33 type: "document",29 '<img alt="&amp;lt;" src="https://example.com/shot.png?label=&amp;lt;&amp;sig=a%26b">';
34 children: [30 expect(markdownWithHtml(markdown)).toBe(
35 { type: "html_block", content: "<!-- generated bot metadata -->" },31 "![\\&lt;](<https://example.com/shot.png?label=\\&lt;\\&sig=a%26b>)",
36 { type: "html_block", content: "<details>" },32 );
37 { type: "heading", level: 2, children: [{ type: "text", content: "Walkthrough" }] },33 expect(markdownImageSources(markdown)).toEqual([
38 { type: "html_block", content: "</details>" },34 "https://example.com/shot.png?label=&lt;&sig=a%26b",
39 ],35 ]);
40 };36 const paragraph = parseMarkdownDocument(markdown).children[0];
41 const result = nativeMarkdownWithHtml(document, () => {37 expect(paragraph?.type).toBe("paragraph");
42 throw new Error("Empty HTML must not be parsed");38 if (paragraph?.type === "paragraph")
43 });39 expect(paragraph.children[0]).toMatchObject({ type: "image", alt: "&lt;" });
44 expect(result.children).toEqual([document.children![2]]);
45 expect(nativeMarkdownDocumentChunks(result)).toHaveLength(1);
46 expect(markdownHtmlContent("<summary>Walkthrough</summary>").trim()).toBe("**Walkthrough**");
47 });40 });
4841
49 it("keeps literal HTML code examples and gives HTML image fragments unique chunk keys", () => {42 it("ignores code delimiters in HTML attributes and comments when collecting screenshots", () => {
50 const code: MarkdownNode = {43 const markdown =
51 type: "code_block",44 '<details data-example="`">\n<summary>Proof</summary>\n<img src="https://example.com/shot.png" alt="Screenshot">\n<!-- ` -->\n</details>';
52 content: '<img src="literal.png">',45 expect(markdownHtmlContent(markdown)).toContain(
53 language: "html",46 "![Screenshot](<https://example.com/shot.png>)",
54 };47 );
55 const document: MarkdownNode = {48 expect(markdownImageSources(markdown)).toEqual(["https://example.com/shot.png"]);
56 type: "document",49 expect(markdownWithHtml(markdown)).not.toContain("<img");
57 children: [50 });
58 code,51
59 { type: "html_block", content: '<img src="one.png">' },52 it("removes bot metadata and details wrappers while keeping the content", () => {
60 { type: "html_block", content: '<img src="two.png">' },53 const markdown =
61 ],54 "<!-- generated bot metadata -->\n<details>\n<summary>Walkthrough</summary>\n\n## Changes\n\nText\n\n</details>";
62 };55 const result = markdownWithHtml(markdown);
63 const result = nativeMarkdownWithHtml(document, (content) => ({56 expect(result).not.toContain("<!--");
64 type: "document",57 expect(result).not.toContain("<details>");
65 children: [58 expect(result).toContain("**Walkthrough**");
66 {59 expect(result).toContain("## Changes");
67 type: "paragraph",60 expect(parseMarkdownDocument(markdown).children.some((node) => node.type === "html")).toBe(
68 beg: 0,61 false,
69 end: content.length,62 );
70 children: [{ type: "image", href: content, beg: 0 }],63 });
71 },64
72 ],65 it("collects HTML and Markdown screenshots while leaving inline and fenced code literal", () => {
73 }));66 const markdown =
74 expect(result.children![0]).toBe(code);67 '```html\n<img src="literal.png">\n```\n\nUse `<img src="inline.png">` here.\n\n<img src="one.png">\n\n![Two](two.png)\n\n<img src="one.png">';
75 const keys = nativeMarkdownDocumentChunks(result).map((chunk) => chunk.key);68 expect(markdownImageSources(markdown)).toEqual(["one.png", "two.png"]);
76 expect(new Set(keys).size).toBe(keys.length);69 const result = markdownWithHtml(markdown);
77 expect(result.children![1]?.beg).toBeUndefined();70 expect(result).toContain('```html\n<img src="literal.png">\n```');
71 expect(result).toContain('`<img src="inline.png">`');
78 });72 });
79});73});
apps/mobile/src/lib/markdownImageSources.ts+4-10
...@@ -1,18 +1,12 @@...@@ -1,18 +1,12 @@
1import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown";1import { parseMarkdownDocument, type MarkdownNode } from "./markdownDocument";
2import { parseMarkdownWithOptions, type MarkdownNode } from "react-native-nitro-markdown/headless";
32
4export function markdownImageSources(markdown: string): string[] {3export function markdownImageSources(markdown: string): string[] {
5 if (!markdown.includes("![") && !/<img[\s>]/i.test(markdown)) return [];4 if (!markdown.includes("![") && !/<img[\s>]/i.test(markdown)) return [];
6 const sources = new Set<string>();5 const sources = new Set<string>();
7 const visit = (node: MarkdownNode) => {6 const visit = (node: MarkdownNode) => {
8 if (node.type === "image" && node.href) sources.add(node.href);7 if (node.type === "image") sources.add(node.url);
9 node.children?.forEach(visit);8 if ("children" in node) node.children.forEach(visit);
10 };9 };
11 visit(10 visit(parseMarkdownDocument(markdown));
12 nativeMarkdownWithHtml(
13 parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }),
14 (text) => parseMarkdownWithOptions(text, { gfm: true, html: false, math: false }),
15 ),
16 );
17 return [...sources];11 return [...sources];
18}12}
apps/mobile/src/lib/markdownLinkIcons.ts created+12
...@@ -0,0 +1,12 @@
1import type { ImageSourcePropType } from "react-native";
2
3import type { MarkdownLinkIcon } from "./markdownLinks";
4
5// Black-on-transparent marks; callers tint them with the link color.
6const MARKDOWN_LINK_ICON_SOURCES = {
7 github: require("../../assets/link-icons/github.png"),
8} as const satisfies Readonly<Record<MarkdownLinkIcon, ImageSourcePropType>>;
9
10export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType {
11 return MARKDOWN_LINK_ICON_SOURCES[icon];
12}
apps/mobile/src/lib/markdownLinks.test.ts+6-4
...@@ -1,9 +1,6 @@...@@ -1,9 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "./markdownLinks";
4 resolveMarkdownLinkIcon,
5 resolveMarkdownLinkPresentation,
6} from "@t3tools/mobile-markdown-text/links";
74
8describe("resolveMarkdownLinkIcon", () => {5describe("resolveMarkdownLinkIcon", () => {
9 it("gives GitHub hosts the brand mark and everything else the generic glyph", () => {6 it("gives GitHub hosts the brand mark and everything else the generic glyph", () => {
...@@ -17,6 +14,11 @@ describe("resolveMarkdownLinkIcon", () => {...@@ -17,6 +14,11 @@ describe("resolveMarkdownLinkIcon", () => {
17});14});
1815
19describe("resolveMarkdownLinkPresentation", () => {16describe("resolveMarkdownLinkPresentation", () => {
17 it("keeps thread-link hrefs for in-app navigation", () => {
18 const href = "t3-thread://v1/thread-1";
19 expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "link", href });
20 });
21
20 it("treats protocol-relative media as an external URL, not a filesystem path", () => {22 it("treats protocol-relative media as an external URL, not a filesystem path", () => {
21 expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({23 expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({
22 kind: "external",24 kind: "external",
apps/mobile/src/lib/markdownLinks.ts created+313
...@@ -0,0 +1,313 @@
1import { fileBasename } from "@t3tools/shared/path";
2import { formatFilePathPosition } from "@t3tools/shared/fileLinks";
3import {
4 inlineCodeFilePathCandidate,
5 normalizeMarkdownLinkDestination,
6 parseMarkdownFileLink,
7} from "@t3tools/shared/markdownLinks";
8import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
9import { videoMimeType } from "@t3tools/shared/video";
10
11import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
12
13const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
14
15export type MarkdownLinkPresentation =
16 | {
17 readonly kind: "external";
18 readonly href: string;
19 readonly host: string;
20 }
21 | {
22 readonly kind: "file";
23 readonly href: string;
24 readonly icon: MarkdownFileIcon;
25 readonly label: string;
26 readonly path: string;
27 readonly line?: number;
28 readonly column?: number;
29 }
30 | {
31 readonly kind: "link";
32 readonly href: string | null;
33 };
34
35export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES;
36
37export type MarkdownLinkIcon = "github";
38
39/**
40 * Sites whose brand mark replaces the generic external-link glyph. The marks
41 * are monochrome and tinted with the link color, so they follow the theme.
42 */
43export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null {
44 const hostname = host.toLowerCase();
45 if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github";
46 return null;
47}
48
49const FILE_ICON_BY_NAME: Readonly<Record<string, MarkdownFileIcon>> = {
50 ".babelrc": "babel",
51 ".babelrc.json": "babel",
52 ".bash_profile": "bash",
53 ".bashrc": "bash",
54 ".browserslistrc": "browserslist",
55 ".dockerignore": "docker",
56 ".eslintignore": "eslint",
57 ".eslintrc": "eslint",
58 ".eslintrc.cjs": "eslint",
59 ".eslintrc.js": "eslint",
60 ".eslintrc.json": "eslint",
61 ".eslintrc.yaml": "eslint",
62 ".eslintrc.yml": "eslint",
63 ".gitattributes": "git",
64 ".gitignore": "git",
65 ".gitkeep": "git",
66 ".gitmodules": "git",
67 ".oxlintrc.json": "oxc",
68 ".postcssrc": "postcss",
69 ".postcssrc.json": "postcss",
70 ".postcssrc.yaml": "postcss",
71 ".postcssrc.yml": "postcss",
72 ".prettierignore": "prettier",
73 ".prettierrc": "prettier",
74 ".prettierrc.json": "prettier",
75 ".prettierrc.cjs": "prettier",
76 ".prettierrc.js": "prettier",
77 ".prettierrc.mjs": "prettier",
78 ".prettierrc.toml": "prettier",
79 ".prettierrc.yaml": "prettier",
80 ".prettierrc.yml": "prettier",
81 ".stylelintignore": "stylelint",
82 ".stylelintrc": "stylelint",
83 ".stylelintrc.cjs": "stylelint",
84 ".stylelintrc.js": "stylelint",
85 ".stylelintrc.json": "stylelint",
86 ".stylelintrc.mjs": "stylelint",
87 ".stylelintrc.yaml": "stylelint",
88 ".stylelintrc.yml": "stylelint",
89 ".terraform.lock.hcl": "terraform",
90 ".zprofile": "bash",
91 ".zshenv": "bash",
92 ".zshrc": "bash",
93 "agents.md": "agents",
94 "babel.config.js": "babel",
95 "babel.config.cjs": "babel",
96 "babel.config.json": "babel",
97 "babel.config.mjs": "babel",
98 "biome.json": "biome",
99 "biome.jsonc": "biome",
100 "bun.lock": "bun",
101 "bun.lockb": "bun",
102 "bunfig.toml": "bun",
103 "claude.md": "claude",
104 "compose.yaml": "docker",
105 "compose.yml": "docker",
106 "docker-compose.yaml": "docker",
107 "docker-compose.yml": "docker",
108 "docker-compose.override.yml": "docker",
109 dockerfile: "docker",
110 "eslint.config.js": "eslint",
111 "eslint.config.cjs": "eslint",
112 "eslint.config.mjs": "eslint",
113 "eslint.config.mts": "eslint",
114 "eslint.config.ts": "eslint",
115 gemfile: "ruby",
116 "next.config.js": "nextjs",
117 "next.config.mjs": "nextjs",
118 "next.config.mts": "nextjs",
119 "next.config.ts": "nextjs",
120 "package.json": "npm",
121 "pnpm-lock.yaml": "pnpm",
122 "pnpm-workspace.yaml": "pnpm",
123 "postcss.config.js": "postcss",
124 "postcss.config.cjs": "postcss",
125 "postcss.config.mjs": "postcss",
126 "postcss.config.ts": "postcss",
127 "prettier.config.js": "prettier",
128 "prettier.config.cjs": "prettier",
129 "prettier.config.mjs": "prettier",
130 rakefile: "ruby",
131 "readme.md": "markdown",
132 "stylelint.config.js": "stylelint",
133 "stylelint.config.cjs": "stylelint",
134 "stylelint.config.mjs": "stylelint",
135 "svgo.config.js": "svgo",
136 "svgo.config.cjs": "svgo",
137 "svgo.config.mjs": "svgo",
138 "svgo.config.ts": "svgo",
139 "tailwind.config.js": "tailwind",
140 "tailwind.config.cjs": "tailwind",
141 "tailwind.config.mjs": "tailwind",
142 "tailwind.config.ts": "tailwind",
143 "tsconfig.json": "typescript",
144 "vite.config.js": "vite",
145 "vite.config.mjs": "vite",
146 "vite.config.mts": "vite",
147 "vite.config.ts": "vite",
148 "webpack.config.js": "webpack",
149 "webpack.config.babel.js": "webpack",
150 "webpack.config.cjs": "webpack",
151 "webpack.config.mjs": "webpack",
152 "webpack.config.ts": "webpack",
153};
154
155const FILE_ICON_BY_EXTENSION: Readonly<Record<string, MarkdownFileIcon>> = {
156 "7z": "zip",
157 astro: "astro",
158 avif: "image",
159 "code-workspace": "vscode",
160 bash: "bash",
161 bmp: "image",
162 bz2: "zip",
163 c: "c",
164 cc: "cpp",
165 cpp: "cpp",
166 cxx: "cpp",
167 css: "css",
168 csv: "table",
169 cts: "typescript",
170 db: "database",
171 env: "text",
172 "env.development": "text",
173 "env.local": "text",
174 "env.production": "text",
175 eot: "font",
176 erb: "ruby",
177 fish: "bash",
178 gif: "image",
179 go: "go",
180 gql: "graphql",
181 graphql: "graphql",
182 gz: "zip",
183 h: "c",
184 hh: "cpp",
185 hpp: "cpp",
186 hxx: "cpp",
187 htm: "html",
188 html: "html",
189 ico: "image",
190 icns: "image",
191 ini: "text",
192 inl: "cpp",
193 jar: "zip",
194 jpeg: "image",
195 jpg: "image",
196 js: "javascript",
197 jsx: "react",
198 json: "json",
199 jsonc: "json",
200 less: "css",
201 md: "markdown",
202 mdx: "markdown",
203 "mdx.tsx": "markdown",
204 mjs: "javascript",
205 mts: "typescript",
206 png: "image",
207 postcss: "css",
208 py: "python",
209 pyi: "python",
210 pyw: "python",
211 pyx: "python",
212 rake: "ruby",
213 rar: "zip",
214 rb: "ruby",
215 rs: "rust",
216 sass: "sass",
217 scss: "sass",
218 sh: "bash",
219 sql: "database",
220 sqlite: "database",
221 sqlite3: "database",
222 svelte: "svelte",
223 svg: "svg",
224 swift: "swift",
225 tar: "zip",
226 tf: "terraform",
227 tfstate: "terraform",
228 tfvars: "terraform",
229 tgz: "zip",
230 ts: "typescript",
231 tsv: "table",
232 tsx: "react",
233 txt: "text",
234 woff: "font",
235 woff2: "font",
236 vue: "vue",
237 wasm: "wasm",
238 webp: "image",
239 yml: "yml",
240 yaml: "yml",
241 zig: "zig",
242 zip: "zip",
243 zsh: "bash",
244};
245
246/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */
247export function normalizeNativeMarkdownUrl(value: string): string {
248 return value.startsWith("//") ? `https:${value}` : value;
249}
250
251export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon {
252 const basename = fileBasename(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase();
253 if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video";
254 const exactIcon = FILE_ICON_BY_NAME[basename];
255 if (exactIcon) return exactIcon;
256 if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) {
257 return "typescript";
258 }
259 const segments = basename.split(".");
260 for (let index = 1; index < segments.length; index += 1) {
261 const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")];
262 if (icon) return icon;
263 }
264 return "default";
265}
266
267export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation {
268 const normalized = normalizeMarkdownLinkDestination(href);
269 try {
270 const parsed = new URL(normalizeNativeMarkdownUrl(normalized));
271 if (parsed.protocol === "http:" || parsed.protocol === "https:") {
272 return {
273 kind: "external",
274 href: parsed.toString(),
275 host: parsed.hostname,
276 };
277 }
278 } catch {
279 // Relative paths and non-URL link destinations are handled below.
280 }
281
282 const target = parseMarkdownFileLink(normalized);
283 if (target) {
284 return {
285 kind: "file",
286 href: normalized,
287 icon: resolveMarkdownFileIcon(target.path),
288 label: fileBasename(formatFilePathPosition(target)),
289 path: target.path,
290 ...(target.line ? { line: target.line } : {}),
291 ...(target.column ? { column: target.column } : {}),
292 };
293 }
294
295 return {
296 kind: "link",
297 // A thread link keeps its href so a press reaches the feed, which opens the thread.
298 href:
299 /^(?:mailto|tel):/i.test(normalized) || parseThreadLinkHref(normalized) !== null
300 ? normalized
301 : null,
302 };
303}
304
305/** Backticks become file references only when the shared path heuristic recognizes the whole span. */
306export function resolveMarkdownInlineCodePresentation(
307 content: string,
308): Extract<MarkdownLinkPresentation, { readonly kind: "file" }> | null {
309 const candidate = inlineCodeFilePathCandidate(content);
310 if (candidate === null) return null;
311 const presentation = resolveMarkdownLinkPresentation(candidate);
312 return presentation.kind === "file" ? presentation : null;
313}
apps/mobile/src/lib/markdownMedia.test.ts+55
...@@ -72,6 +72,61 @@ describe("resolveMarkdownMediaPreview", () => {...@@ -72,6 +72,61 @@ describe("resolveMarkdownMediaPreview", () => {
72 });72 });
73 });73 });
7474
75 it.each(["./image.png", "./clip.mp4", "./report.pdf"])(
76 "opens relative media through the environment: %s",
77 (href) => {
78 const preview = resolveMarkdownMediaPreview(href, input);
79 expect(preview?.source).toMatchObject({
80 environmentId: input.environmentId,
81 resource: { _tag: "media-file", threadId: input.threadId, path: `/repo/${href}` },
82 });
83 },
84 );
85
86 it("preserves a signed PDF URL and its authored name", () => {
87 const href = "//cdn.example.com/report%20one.pdf?signature=a%2fb#page=2";
88 expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({
89 kind: "pdf",
90 source: { uri: `https:${href}`, name: "report one.pdf", mimeType: "application/pdf" },
91 });
92 });
93
94 it("recognizes a URL-encoded PDF extension and filename without rewriting the URL", () => {
95 const href = "https://cdn.example.com/report%23one%2520%2Epdf?signature=a%2fb";
96 expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({
97 kind: "pdf",
98 source: { uri: href, name: "report#one%20.pdf" },
99 });
100 });
101
102 it("opens encoded host PDFs without interpreting literal filename characters twice", () => {
103 expect(resolveMarkdownMediaPreview("/tmp/report%23one.pdf:12#page=2", input)).toMatchObject({
104 kind: "pdf",
105 source: { resource: { path: "/tmp/report#one.pdf" }, srcFragment: "#page=2" },
106 });
107 });
108
109 it.each(["./image.png", "./clip.mp4", "./report.pdf"])(
110 "uses the draft workspace when no thread exists: %s",
111 (href) => {
112 expect(
113 resolveMarkdownMediaPreview(href, { ...input, threadId: undefined })?.source,
114 ).toMatchObject({
115 resource: { _tag: "draft-workspace-file", cwd: "/repo", path: `/repo/${href}` },
116 });
117 },
118 );
119
120 it("keeps captured host media unavailable while allowing direct media", () => {
121 const captured = { ...input, captured: true };
122 for (const href of ["./image.png", "./clip.mp4", "./report.pdf"]) {
123 expect(resolveMarkdownMediaPreview(href, captured)).toBeNull();
124 }
125 expect(resolveMarkdownMediaPreview("https://cdn.example.com/report.pdf", captured)?.kind).toBe(
126 "pdf",
127 );
128 });
129
75 it("resolves protocol-relative media for native APIs without rewriting its signed query", () => {130 it("resolves protocol-relative media for native APIs without rewriting its signed query", () => {
76 expect(131 expect(
77 resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input),132 resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input),
apps/mobile/src/lib/markdownMedia.ts+71-4
...@@ -1,6 +1,16 @@...@@ -1,6 +1,16 @@
1import { resolveMediaSource } from "@t3tools/client-runtime/media-source";1import {
2 classifyMarkdownImageSource,
3 markdownImageSourceFragment,
4} from "@t3tools/client-runtime/markdown-images";
5import { splitFilePathPosition } from "@t3tools/shared/fileLinks";
6import {
7 mediaFileReference,
8 mediaUrlReference,
9 mediaReferenceFileName,
10} from "@t3tools/client-runtime/media-reference";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";11import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";12import { normalizeNativeMarkdownUrl } from "./markdownLinks";
13import { resolveMobileMarkdownMediaSource } from "./markdownMediaSource";
414
5import type { FilePreviewSource } from "../components/FilePreviewModal";15import type { FilePreviewSource } from "../components/FilePreviewModal";
6import type { MediaVideoPreviewSource } from "./videoPreviewSource";16import type { MediaVideoPreviewSource } from "./videoPreviewSource";
...@@ -11,17 +21,74 @@ export function resolveMarkdownMediaPreview(...@@ -11,17 +21,74 @@ export function resolveMarkdownMediaPreview(
11 href: string,21 href: string,
12 input: {22 input: {
13 readonly environmentId: EnvironmentId;23 readonly environmentId: EnvironmentId;
14 readonly threadId: ThreadId;24 readonly threadId: ThreadId | undefined;
15 readonly workspaceRoot: string | null | undefined;25 readonly workspaceRoot: string | null | undefined;
26 /** Captured markdown may only open media with a direct URL. */
27 readonly captured?: boolean;
16 /** Image syntax can target an endpoint without a recognizable extension. */28 /** Image syntax can target an endpoint without a recognizable extension. */
17 readonly imageEmbed?: boolean;29 readonly imageEmbed?: boolean;
18 },30 },
19):31):
20 | { readonly kind: "image"; readonly source: FilePreviewSource }32 | { readonly kind: "image"; readonly source: FilePreviewSource }
33 | { readonly kind: "pdf"; readonly source: FilePreviewSource }
21 | { readonly kind: "video"; readonly source: MediaVideoPreviewSource }34 | { readonly kind: "video"; readonly source: MediaVideoPreviewSource }
22 | null {35 | null {
23 const media = resolveMediaSource(href, input);36 const classified = classifyMarkdownImageSource(href, input.workspaceRoot);
37 if (classified._tag !== "Blocked") {
38 const path =
39 classified._tag === "Direct" ? classified.uri : splitFilePathPosition(classified.path).path;
40 const reference =
41 classified._tag === "Direct"
42 ? mediaUrlReference(path)
43 : mediaFileReference(path, input.workspaceRoot);
44 const name = reference && mediaReferenceFileName(reference);
45 if (name && /\.pdf$/i.test(name)) {
46 const common = { kind: "pdf" as const, name, mimeType: "application/pdf" };
47 if (classified._tag === "Direct") {
48 const uri = normalizeNativeMarkdownUrl(path);
49 return {
50 kind: "pdf",
51 source: {
52 ...common,
53 uri,
54 actionsSource: {
55 ...(reference ? { reference } : {}),
56 uri,
57 name,
58 mimeType: common.mimeType,
59 },
60 },
61 };
62 }
63 if (input.captured) return null;
64 const resource = input.threadId
65 ? { _tag: "media-file" as const, threadId: input.threadId, path }
66 : input.workspaceRoot
67 ? { _tag: "draft-workspace-file" as const, cwd: input.workspaceRoot, path }
68 : null;
69 if (!resource) return null;
70 return {
71 kind: "pdf",
72 source: {
73 ...common,
74 environmentId: input.environmentId,
75 resource,
76 srcFragment: markdownImageSourceFragment(href),
77 actionsSource: {
78 ...(reference ? { reference } : {}),
79 environmentId: input.environmentId,
80 threadId: input.threadId,
81 resource,
82 name,
83 mimeType: common.mimeType,
84 },
85 },
86 };
87 }
88 }
89 const media = resolveMobileMarkdownMediaSource(href, input);
24 if (media === null || media.access === "unavailable") return null;90 if (media === null || media.access === "unavailable") return null;
91 if (input.captured && media.access !== "direct") return null;
25 const { kind, name, mimeType, reference, srcFragment } = media;92 const { kind, name, mimeType, reference, srcFragment } = media;
2693
27 const target =94 const target =
apps/mobile/src/lib/markdownMediaSource.test.ts created+41
...@@ -0,0 +1,41 @@
1import { ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import { resolveMobileMarkdownMediaSource } from "./markdownMediaSource";
4
5describe("mobile Markdown draft media", () => {
6 it("authorizes relative images and videos against the draft directory", () => {
7 for (const name of ["diagram.svg", "clip.mp4"]) {
8 expect(
9 resolveMobileMarkdownMediaSource(`./${name}`, {
10 threadId: undefined,
11 workspaceRoot: "/project/docs",
12 imageEmbed: true,
13 }),
14 ).toMatchObject({
15 access: "environment",
16 resource: {
17 _tag: "draft-workspace-file",
18 cwd: "/project/docs",
19 path: `/project/docs/./${name}`,
20 },
21 });
22 }
23 });
24
25 it("keeps thread authorization when a thread exists", () => {
26 const threadId = ThreadId.make("markdown-thread");
27 expect(
28 resolveMobileMarkdownMediaSource("image.png", { threadId, workspaceRoot: "/project" }),
29 ).toMatchObject({ resource: { _tag: "media-file", threadId, path: "/project/image.png" } });
30 });
31
32 it("keeps direct URLs direct and rejects unresolvable or blocked references", () => {
33 const input = { threadId: undefined, workspaceRoot: "/project" };
34 expect(resolveMobileMarkdownMediaSource("https://example.com/image.png", input)).toMatchObject({
35 access: "direct",
36 uri: "https://example.com/image.png",
37 });
38 expect(resolveMobileMarkdownMediaSource("javascript:alert(1)", input)).toBeNull();
39 expect(resolveMobileMarkdownMediaSource("image.png", { threadId: undefined })).toBeNull();
40 });
41});
apps/mobile/src/lib/markdownMediaSource.ts created+22
...@@ -0,0 +1,22 @@
1import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
2import {
3 resolveMediaSource,
4 type ResolveMediaSourceInput,
5} from "@t3tools/client-runtime/media-source";
6
7/** A project draft names its workspace explicitly because it has no thread. */
8export function resolveMobileMarkdownMediaSource(href: string, input: ResolveMediaSourceInput) {
9 const media = resolveMediaSource(href, input);
10 if (media?.access !== "unavailable" || !input.workspaceRoot) return media;
11 const source = classifyMarkdownImageSource(href, input.workspaceRoot);
12 if (source._tag !== "WorkspaceFile") return media;
13 return {
14 ...media,
15 access: "environment" as const,
16 resource: {
17 _tag: "draft-workspace-file" as const,
18 cwd: input.workspaceRoot,
19 path: source.path,
20 },
21 };
22}
apps/mobile/src/lib/mediaActions.test.ts+1-1
...@@ -26,7 +26,7 @@ vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));...@@ -26,7 +26,7 @@ vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
26vi.mock("@react-navigation/native", () => ({26vi.mock("@react-navigation/native", () => ({
27 useNavigation: () => ({ navigate: state.navigate }),27 useNavigation: () => ({ navigate: state.navigate }),
28}));28}));
29vi.mock("@t3tools/mobile-markdown-text/links", () => ({29vi.mock("./markdownLinks", () => ({
30 normalizeNativeMarkdownUrl: (uri: string) => uri,30 normalizeNativeMarkdownUrl: (uri: string) => uri,
31}));31}));
32vi.mock("../state/assets", () => ({32vi.mock("../state/assets", () => ({
apps/mobile/src/lib/nativeMarkdownText.test.ts deleted-1337
...@@ -1,1337 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { MarkdownNode } from "react-native-nitro-markdown/headless";
3
4import {
5 nativeMarkdownChunkSpacing,
6 nativeMarkdownDocumentChunks,
7 nativeMarkdownDocumentRuns,
8 nativeMarkdownListItemBlocks,
9 nativeMarkdownTextRuns,
10 nativeMarkdownWithAuthoredWindowsPaths,
11 nativeMarkdownWithPreservedSoftBreaks,
12 nativeMarkdownContextCopyRanges,
13 contextChipPresentation,
14} from "@t3tools/mobile-markdown-text/markdown";
15
16describe("nativeMarkdownTextRuns", () => {
17 it("distinguishes video and pull-request context from generic file and review chips", () => {
18 expect(
19 contextChipPresentation("file", {
20 name: "recording.webm",
21 mimeType: "application/octet-stream",
22 }),
23 ).toEqual(contextChipPresentation("video"));
24 expect(contextChipPresentation("review-comment", { sectionId: "pull-request:284" })).toEqual(
25 contextChipPresentation("pull-request"),
26 );
27 expect(contextChipPresentation("review-comment", { sectionId: "git:working-tree" })).toEqual(
28 contextChipPresentation("review-comment"),
29 );
30 });
31
32 it("renders a video-named file with a declared document MIME type as a file chip", () => {
33 expect(
34 contextChipPresentation("file", {
35 name: "recording.mp4",
36 mimeType: "application/pdf",
37 }),
38 ).toEqual(contextChipPresentation("file"));
39 });
40
41 it("maps rendered selection offsets back to canonical references without losing repeated chips", () => {
42 const href = "t3-context://v1/image/screenshot";
43 expect(
44 nativeMarkdownContextCopyRanges([
45 { run: { text: "😀 " }, text: "😀 ", inlineImageLength: 0 },
46 { run: { href, text: "Checkout" }, text: "Checkout", inlineImageLength: 1 },
47 { run: { text: " then " }, text: " then ", inlineImageLength: 0 },
48 { run: { href, text: "Checkout" }, text: "\uFFFC\u00A0Checkout", inlineImageLength: 0 },
49 ]),
50 ).toEqual([
51 { start: 3, end: 12, text: "![Checkout](t3-context://v1/image/screenshot)" },
52 { start: 18, end: 28, text: "![Checkout](t3-context://v1/image/screenshot)" },
53 ]);
54 });
55 it("restores canonical skill and context text from Android's single-image chips", () => {
56 expect(
57 nativeMarkdownContextCopyRanges([
58 { run: { text: "Use " }, text: "Use ", inlineImageLength: 0 },
59 { run: { text: "Playwright", skillName: "playwright" }, text: "", inlineImageLength: 1 },
60 { run: { text: " on " }, text: " on ", inlineImageLength: 0 },
61 {
62 run: { text: "Screenshot", href: "t3-context://v1/image/screenshot" },
63 text: "",
64 inlineImageLength: 1,
65 },
66 ]),
67 ).toEqual([
68 { start: 4, end: 5, text: "$playwright" },
69 { start: 9, end: 10, text: "![Screenshot](t3-context://v1/image/screenshot)" },
70 ]);
71 });
72 it("links a path-shaped code span without changing the same path in prose", () => {
73 expect(
74 nativeMarkdownTextRuns({
75 type: "paragraph",
76 children: [
77 { type: "text", content: "/tmp/frame.png " },
78 { type: "code_inline", content: "/tmp/frame.png" },
79 ],
80 }),
81 ).toEqual([
82 { text: "/tmp/frame.png " },
83 { text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" },
84 ]);
85 });
86
87 it("keeps a thread link's href so a press can open the thread", () => {
88 expect(
89 nativeMarkdownTextRuns({
90 type: "paragraph",
91 children: [
92 {
93 type: "link",
94 href: "t3-thread://v1/thread-1",
95 children: [{ type: "text", content: "Fix the build" }],
96 },
97 ],
98 }),
99 ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]);
100 });
101
102 it("preserves the destination of a link with a code-formatted label", () => {
103 expect(
104 nativeMarkdownTextRuns({
105 type: "paragraph",
106 children: [
107 {
108 type: "link",
109 href: "https://example.com/docs",
110 children: [{ type: "code_inline", content: "src/main.ts" }],
111 },
112 ],
113 }),
114 ).toEqual([
115 {
116 text: "src/main.ts",
117 code: true,
118 href: "https://example.com/docs",
119 externalHost: "example.com",
120 },
121 ]);
122 });
123
124 it("preserves inline emphasis and code styles", () => {
125 const node: MarkdownNode = {
126 type: "paragraph",
127 children: [
128 { type: "text", content: "plain " },
129 { type: "bold", children: [{ type: "text", content: "bold" }] },
130 { type: "text", content: " " },
131 { type: "code_inline", content: "const value = 1" },
132 ],
133 };
134
135 expect(nativeMarkdownTextRuns(node)).toEqual([
136 { text: "plain " },
137 { text: "bold", bold: true },
138 { text: " " },
139 { text: "const value = 1", code: true },
140 ]);
141 });
142
143 it("normalizes external and file links for native presentation", () => {
144 const node: MarkdownNode = {
145 type: "paragraph",
146 children: [
147 {
148 type: "link",
149 href: "https://example.com/docs",
150 children: [{ type: "text", content: "Docs" }],
151 },
152 { type: "text", content: " " },
153 {
154 type: "link",
155 href: "file:///repo/README.md#L12",
156 children: [{ type: "text", content: "validates the input" }],
157 },
158 ],
159 };
160
161 expect(nativeMarkdownTextRuns(node)).toEqual([
162 {
163 text: "Docs",
164 href: "https://example.com/docs",
165 externalHost: "example.com",
166 },
167 { text: " " },
168 {
169 text: "validates the input ",
170 href: "file:///repo/README.md#L12",
171 sourceText: "[validates the input](<file:///repo/README.md#L12>)",
172 },
173 {
174 text: "README.md:12",
175 href: "file:///repo/README.md#L12",
176 fileIcon: "markdown",
177 sourceText: "[validates the input](<file:///repo/README.md#L12>)",
178 },
179 ]);
180 });
181
182 it.each([true, false])("copies descriptive file links with collapsed chips=%s", (collapsed) => {
183 const link: MarkdownNode = {
184 type: "link",
185 href: "/repo/src/example.ts:12",
186 children: [
187 { type: "text", content: "validates " },
188 { type: "bold", children: [{ type: "text", content: "the input" }] },
189 ],
190 };
191 const runs = nativeMarkdownTextRuns({ type: "paragraph", children: [link, link] });
192 expect(runs.map((run) => run.text).join("")).toBe(
193 "validates the input example.ts:12validates the input example.ts:12",
194 );
195 expect(runs.filter((run) => run.bold).map((run) => run.text)).toEqual([
196 "the input",
197 "the input",
198 ]);
199 const ranges = nativeMarkdownContextCopyRanges(
200 runs.map((run) => ({
201 run,
202 text: collapsed && run.fileIcon ? "" : run.text,
203 inlineImageLength: run.fileIcon ? 1 : 0,
204 })),
205 );
206 const length = "validates the input ".length + (collapsed ? 1 : "example.ts:12".length + 1);
207 expect(ranges).toEqual([
208 { start: 0, end: length, text: "[validates **the input**](</repo/src/example.ts:12>)" },
209 {
210 start: length,
211 end: length * 2,
212 text: "[validates **the input**](</repo/src/example.ts:12>)",
213 },
214 ]);
215 });
216
217 it("keeps filename-labelled links as chips", () => {
218 expect(
219 nativeMarkdownTextRuns({
220 type: "paragraph",
221 children: [
222 {
223 type: "link",
224 href: "/repo/src/example.ts:12",
225 children: [{ type: "code_inline", content: "src/example.ts:12" }],
226 },
227 ],
228 }),
229 ).toEqual([{ text: "example.ts:12", href: "/repo/src/example.ts:12", fileIcon: "typescript" }]);
230 });
231
232 it.each([
233 [{ type: "italic", children: [{ type: "text", content: "details" }] }, "*details*"],
234 [{ type: "strikethrough", children: [{ type: "text", content: "details" }] }, "~~details~~"],
235 [{ type: "code_inline", content: "`details`" }, "`` `details` ``"],
236 [{ type: "code_inline", content: " details " }, "` details `"],
237 [{ type: "text", content: "[details] *literal*" }, "\\[details\\] \\*literal\\*"],
238 [{ type: "soft_break" }, "\n"],
239 [{ type: "line_break" }, " \n"],
240 [
241 { type: "image", alt: "details", href: "https://example.com/icon.png" },
242 "![details](<https://example.com/icon.png>)",
243 ],
244 ] satisfies ReadonlyArray<readonly [MarkdownNode, string]>)(
245 "keeps descriptive label markup when copying %j",
246 (child, source) => {
247 const runs = nativeMarkdownTextRuns({
248 type: "paragraph",
249 children: [
250 {
251 type: "link",
252 href: "/repo/a.ts",
253 children: [{ type: "text", content: "see " }, child],
254 },
255 ],
256 });
257 expect(
258 nativeMarkdownContextCopyRanges(
259 runs.map((run) => ({
260 run,
261 text: run.fileIcon ? "" : run.text,
262 inlineImageLength: run.fileIcon ? 1 : 0,
263 })),
264 ),
265 ).toEqual([
266 {
267 start: 0,
268 end:
269 runs
270 .filter((run) => !run.fileIcon)
271 .reduce((length, run) => length + run.text.length, 0) + 1,
272 text: `[see ${source}](</repo/a.ts>)`,
273 },
274 ]);
275 },
276 );
277
278 it("keeps hard breaks and collapses soft breaks", () => {
279 const node: MarkdownNode = {
280 type: "paragraph",
281 children: [
282 { type: "text", content: "first" },
283 { type: "soft_break" },
284 { type: "text", content: "second" },
285 { type: "line_break" },
286 { type: "text", content: "third" },
287 ],
288 };
289
290 expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]);
291 });
292
293 it("can preserve soft breaks for authored user messages", () => {
294 const node: MarkdownNode = {
295 type: "paragraph",
296 children: [
297 { type: "text", content: "first" },
298 { type: "soft_break" },
299 { type: "text", content: "second" },
300 ],
301 };
302
303 expect(nativeMarkdownTextRuns(nativeMarkdownWithPreservedSoftBreaks(node))).toEqual([
304 { text: "first\nsecond" },
305 ]);
306 });
307
308 it("keeps Windows path backslashes the parser reads as escapes", () => {
309 const markdown = [
310 String.raw`![shot](C:\Users\me\.t3\_build\shot.png "Shot")`,
311 String.raw`[settings](C:\Users\me\.claude\settings.json) and [site](https://example.com/a\.b)`,
312 "![ref][ref]",
313 String.raw`[ref]: \\wsl.localhost\Ubuntu\.t3\ref.png`,
314 ].join("\n\n");
315 // md4c drops each backslash that precedes punctuation.
316 const node: MarkdownNode = {
317 type: "document",
318 children: [
319 { type: "image", href: String.raw`C:\Users\me.t3_build\shot.png` },
320 { type: "link", href: String.raw`C:\Users\me.claude\settings.json` },
321 { type: "link", href: "https://example.com/a.b" },
322 { type: "image", href: String.raw`\wsl.localhost\Ubuntu.t3\ref.png` },
323 ],
324 };
325
326 expect(
327 nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.map(({ href }) => href),
328 ).toEqual([
329 String.raw`C:\Users\me\.t3\_build\shot.png`,
330 String.raw`C:\Users\me\.claude\settings.json`,
331 "https://example.com/a.b",
332 String.raw`\\wsl.localhost\Ubuntu\.t3\ref.png`,
333 ]);
334 });
335
336 it("leaves a Windows path as parsed when two written paths could have produced it", () => {
337 const markdown = [
338 String.raw`\`![example](C:\Users\me\.t3\shot.png)\``,
339 String.raw`![real](C:\Users\me.t3\shot.png)`,
340 ].join("\n\n");
341 const node: MarkdownNode = {
342 type: "document",
343 children: [{ type: "image", href: String.raw`C:\Users\me.t3\shot.png` }],
344 };
345
346 expect(nativeMarkdownWithAuthoredWindowsPaths(node, markdown).children?.[0]?.href).toBe(
347 String.raw`C:\Users\me.t3\shot.png`,
348 );
349 });
350
351 it("normalizes common inline HTML and entities", () => {
352 const node: MarkdownNode = {
353 type: "paragraph",
354 children: [
355 { type: "text", content: "Less than: &lt; " },
356 { type: "html_inline", content: "<kbd>" },
357 { type: "text", content: "⌘" },
358 { type: "html_inline", content: "</kbd>" },
359 { type: "html_inline", content: "<br />" },
360 { type: "html_inline", content: "<mark>highlighted</mark>" },
361 ],
362 };
363
364 expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "Less than: < ⌘\nhighlighted" }]);
365 });
366
367 it("normalizes double-encoded entities and inline tags emitted as text", () => {
368 const node: MarkdownNode = {
369 type: "paragraph",
370 children: [
371 {
372 type: "text",
373 content:
374 "Keyboard: <kbd>⌘</kbd> + <kbd>K</kbd>; Less than: &amp;lt;; Greater than: &amp;gt;",
375 },
376 ],
377 };
378
379 expect(nativeMarkdownTextRuns(node)).toEqual([
380 { text: "Keyboard: ⌘ + K; Less than: <; Greater than: >" },
381 ]);
382 });
383
384 it.each([
385 ["&#128512;", "😀"],
386 ["&#x1f680;", "🚀"],
387 ["&#9999999999;", "&#9999999999;"],
388 ["&#x110000;", "&#x110000;"],
389 ["&amp;#9999999999;", "&#9999999999;"],
390 ["&amp;#x110000;", "&#x110000;"],
391 ])("normalizes numeric entity %s without throwing", (content, expected) => {
392 const node: MarkdownNode = {
393 type: "paragraph",
394 children: [{ type: "text", content }],
395 };
396
397 expect(nativeMarkdownTextRuns(node)).toEqual([{ text: expected }]);
398 });
399
400 it("reads inline content from nested text nodes", () => {
401 const node: MarkdownNode = {
402 type: "paragraph",
403 children: [
404 {
405 type: "text",
406 children: [{ type: "text", content: "Plain text" }],
407 },
408 { type: "text", content: " and " },
409 {
410 type: "code_inline",
411 children: [{ type: "text", content: "inline code" }],
412 },
413 ],
414 };
415
416 expect(nativeMarkdownTextRuns(node)).toEqual([
417 { text: "Plain text and " },
418 { text: "inline code", code: true },
419 ]);
420 });
421});
422
423describe("nativeMarkdownDocumentRuns", () => {
424 it("renders a file mention without swallowing sentence punctuation or changing package references", () => {
425 const runs = nativeMarkdownDocumentRuns({
426 type: "document",
427 children: [
428 {
429 type: "paragraph",
430 children: [
431 { type: "text", content: "Inspect @src/Checkout.tsx. Use @t3tools/contracts." },
432 ],
433 },
434 ],
435 });
436 expect(runs).toEqual([
437 { text: "Inspect ", role: "body" },
438 {
439 text: "Checkout.tsx",
440 role: "body",
441 href: "src/Checkout.tsx",
442 fileIcon: "react",
443 sourceText: "@src/Checkout.tsx",
444 },
445 { text: ". Use @t3tools/contracts.", role: "body" },
446 ]);
447 });
448
449 it("copies collapsed skill and file chips back to their original references", () => {
450 expect(
451 nativeMarkdownContextCopyRanges([
452 { run: { text: "$ui", skillName: "ui" }, text: "\uFFFC", inlineImageLength: 0 },
453 { run: { text: " and " }, text: " and ", inlineImageLength: 0 },
454 {
455 run: {
456 text: "Checkout.tsx",
457 href: "src/Checkout.tsx",
458 fileIcon: "react",
459 sourceText: "@src/Checkout.tsx",
460 },
461 text: "\uFFFC",
462 inlineImageLength: 0,
463 },
464 ]),
465 ).toEqual([
466 { start: 0, end: 1, text: "$ui" },
467 { start: 6, end: 7, text: "@src/Checkout.tsx" },
468 ]);
469 });
470
471 it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
472 "decorates %s skill references as selectable skill links",
473 (prefix) => {
474 const node: MarkdownNode = {
475 type: "document",
476 children: [
477 {
478 type: "paragraph",
479 children: [{ type: "text", content: `Use ${prefix}ui for this.` }],
480 },
481 ],
482 };
483
484 expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([
485 { text: "Use ", role: "body" },
486 {
487 text: `${prefix}ui`,
488 role: "body",
489 skillName: "ui",
490 skillLabel: "UI",
491 },
492 { text: " for this.", role: "body" },
493 ]);
494 },
495 );
496
497 it("decorates known skill references that begin with a digit", () => {
498 const node: MarkdownNode = {
499 type: "document",
500 children: [
501 {
502 type: "paragraph",
503 children: [{ type: "text", content: "Use $2spec for this." }],
504 },
505 ],
506 };
507
508 expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([
509 { text: "Use ", role: "body" },
510 {
511 text: "$2spec",
512 role: "body",
513 skillName: "2spec",
514 skillLabel: "2Spec",
515 },
516 { text: " for this.", role: "body" },
517 ]);
518 });
519
520 it("decorates known skill references inside blockquotes", () => {
521 const node: MarkdownNode = {
522 type: "blockquote",
523 children: [
524 {
525 type: "paragraph",
526 children: [{ type: "text", content: "Use $ui for this." }],
527 },
528 ],
529 };
530
531 expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toContainEqual({
532 text: "$ui",
533 role: "body",
534 skillName: "ui",
535 skillLabel: "UI",
536 });
537 });
538
539 it("leaves unknown skill-like text unchanged", () => {
540 const node: MarkdownNode = {
541 type: "document",
542 children: [
543 {
544 type: "paragraph",
545 children: [{ type: "text", content: "Use $unknown for this." }],
546 },
547 ],
548 };
549
550 expect(nativeMarkdownDocumentRuns(node, [])).toEqual([
551 { text: "Use $unknown for this.", role: "body" },
552 ]);
553 });
554
555 it("keeps headings, paragraphs, and lists in one continuous document", () => {
556 const node: MarkdownNode = {
557 type: "document",
558 children: [
559 {
560 type: "heading",
561 level: 1,
562 children: [{ type: "text", content: "Header One" }],
563 },
564 {
565 type: "paragraph",
566 children: [
567 { type: "text", content: "A paragraph with " },
568 { type: "bold", children: [{ type: "text", content: "bold text" }] },
569 { type: "text", content: "." },
570 ],
571 },
572 {
573 type: "list",
574 ordered: false,
575 children: [
576 {
577 type: "list_item",
578 children: [
579 {
580 type: "paragraph",
581 children: [{ type: "text", content: "First item" }],
582 },
583 ],
584 },
585 {
586 type: "list_item",
587 children: [
588 {
589 type: "paragraph",
590 children: [{ type: "text", content: "Second item" }],
591 },
592 ],
593 },
594 ],
595 },
596 ],
597 };
598
599 const runs = nativeMarkdownDocumentRuns(node);
600 expect(runs.map((run) => run.text).join("")).toBe(
601 "Header One\n\nA paragraph with bold text.\n\n•\tFirst item\n•\tSecond item",
602 );
603 expect(runs).toContainEqual({
604 text: "Header One\n",
605 role: "heading",
606 headingLevel: 1,
607 });
608 expect(runs).toContainEqual({
609 text: "bold text",
610 bold: true,
611 role: "body",
612 });
613 expect(runs).toContainEqual({
614 text: "•\t",
615 role: "list-marker",
616 depth: 1,
617 firstLineHeadIndent: 0,
618 headIndent: 24,
619 paragraphSpacing: 2,
620 });
621 });
622
623 it("uses distinct section, heading-content, and body spacing", () => {
624 const node: MarkdownNode = {
625 type: "document",
626 children: [
627 {
628 type: "paragraph",
629 children: [{ type: "text", content: "Intro" }],
630 },
631 {
632 type: "heading",
633 level: 2,
634 children: [{ type: "text", content: "Section" }],
635 },
636 {
637 type: "paragraph",
638 children: [{ type: "text", content: "First paragraph" }],
639 },
640 {
641 type: "paragraph",
642 children: [{ type: "text", content: "Second paragraph" }],
643 },
644 ],
645 };
646
647 expect(
648 nativeMarkdownDocumentRuns(node)
649 .filter((run) => run.role === "spacer")
650 .map((run) => run.spacing),
651 ).toEqual([20, 10, 12]);
652 });
653
654 it("renders tight list items whose inline nodes are direct children", () => {
655 const node: MarkdownNode = {
656 type: "document",
657 children: [
658 {
659 type: "list",
660 children: [
661 {
662 type: "list_item",
663 children: [
664 {
665 type: "bold",
666 children: [{ type: "text", content: "Finding:" }],
667 },
668 { type: "text", content: " details with " },
669 { type: "code_inline", content: "inline code" },
670 { type: "text", content: "." },
671 ],
672 },
673 ],
674 },
675 ],
676 };
677
678 expect(nativeMarkdownDocumentRuns(node)).toEqual([
679 {
680 text: "•\t",
681 role: "list-marker",
682 depth: 1,
683 firstLineHeadIndent: 0,
684 headIndent: 24,
685 paragraphSpacing: 2,
686 },
687 { text: "Finding:", bold: true, role: "body", depth: 1 },
688 { text: " details with ", role: "body", depth: 1 },
689 { text: "inline code", code: true, role: "body", depth: 1 },
690 { text: ".", role: "body", depth: 1 },
691 ]);
692 });
693
694 it("preserves quotes and fenced code in document runs", () => {
695 const node: MarkdownNode = {
696 type: "document",
697 children: [
698 {
699 type: "blockquote",
700 children: [
701 {
702 type: "paragraph",
703 children: [{ type: "text", content: "Read this" }],
704 },
705 ],
706 },
707 {
708 type: "code_block",
709 language: "ts",
710 content: "const answer = 42;",
711 },
712 ],
713 };
714
715 const runs = nativeMarkdownDocumentRuns(node);
716 expect(runs.map((run) => run.text).join("")).toBe("│\u00a0Read this\n\nTS\nconst answer = 42;");
717 expect(runs).toContainEqual({
718 text: "const answer = 42;",
719 code: true,
720 role: "code-block",
721 });
722 });
723
724 it("reads fenced code content from child text nodes", () => {
725 const node: MarkdownNode = {
726 type: "document",
727 children: [
728 {
729 type: "code_block",
730 language: "bash",
731 children: [{ type: "text", content: "pnpm install\n" }],
732 },
733 ],
734 };
735
736 expect(
737 nativeMarkdownDocumentRuns(node)
738 .map((run) => run.text)
739 .join(""),
740 ).toBe("BASH\npnpm install");
741 });
742
743 it("keeps adjacent context links with the same href in separate runs", () => {
744 const href = "t3-context://v1/terminal/ctx-1";
745 const link = (content: string): MarkdownNode => ({
746 type: "link",
747 href,
748 children: [{ type: "text", content }],
749 });
750 const runs = nativeMarkdownDocumentRuns({
751 type: "document",
752 children: [{ type: "paragraph", children: [link("First"), link("Second")] }],
753 });
754
755 // Merging these would render one chip and emit one copy range with a
756 // combined label for two distinct references.
757 expect(runs).toEqual([
758 { text: "First", role: "body", href, fileIcon: "bash" },
759 { text: "Second", role: "body", href, fileIcon: "bash" },
760 ]);
761 });
762});
763
764describe("nativeMarkdownListItemBlocks", () => {
765 it("groups consecutive inline nodes into one paragraph block", () => {
766 const item: MarkdownNode = {
767 type: "list_item",
768 children: [
769 { type: "text", content: "Finding: " },
770 { type: "bold", children: [{ type: "text", content: "important" }] },
771 { type: "text", content: " details." },
772 {
773 type: "list",
774 children: [
775 {
776 type: "list_item",
777 children: [{ type: "text", content: "Nested" }],
778 },
779 ],
780 },
781 { type: "text", content: "Trailing prose." },
782 ],
783 };
784
785 expect(nativeMarkdownListItemBlocks(item)).toEqual([
786 {
787 type: "paragraph",
788 children: item.children?.slice(0, 3),
789 },
790 item.children?.[3],
791 {
792 type: "paragraph",
793 children: [item.children?.[4]],
794 },
795 ]);
796 });
797});
798
799describe("nativeMarkdownDocumentChunks", () => {
800 it("renders plain blockquotes as rich blocks so their marker spans wrapped lines", () => {
801 const blockquote: MarkdownNode = {
802 type: "blockquote",
803 beg: 0,
804 end: 120,
805 children: [
806 {
807 type: "paragraph",
808 children: [
809 {
810 type: "text",
811 content:
812 "Persistent random per-result keys are the strongest design, even when this text wraps.",
813 },
814 ],
815 },
816 ],
817 };
818
819 expect(
820 nativeMarkdownDocumentChunks({
821 type: "document",
822 children: [blockquote],
823 }),
824 ).toEqual([
825 {
826 kind: "rich",
827 key: "rich:blockquote:offset:0",
828 node: blockquote,
829 },
830 ]);
831 });
832
833 it("keeps headings and plain lists in one selectable document", () => {
834 const document: MarkdownNode = {
835 type: "document",
836 children: [
837 {
838 type: "heading",
839 level: 2,
840 children: [{ type: "text", content: "Tasks" }],
841 },
842 {
843 type: "list",
844 children: [
845 {
846 type: "task_list_item",
847 checked: true,
848 children: [
849 {
850 type: "paragraph",
851 children: [{ type: "text", content: "Completed" }],
852 },
853 ],
854 },
855 {
856 type: "list_item",
857 children: [
858 {
859 type: "paragraph",
860 children: [{ type: "text", content: "Parent" }],
861 },
862 {
863 type: "list",
864 children: [
865 {
866 type: "list_item",
867 children: [
868 {
869 type: "paragraph",
870 children: [{ type: "text", content: "Nested" }],
871 },
872 ],
873 },
874 ],
875 },
876 ],
877 },
878 ],
879 },
880 ],
881 };
882
883 const chunks = nativeMarkdownDocumentChunks(document);
884 expect(chunks).toHaveLength(1);
885 expect(chunks[0]).toMatchObject({ kind: "selectable" });
886 expect(
887 nativeMarkdownDocumentRuns(chunks[0]?.node ?? document)
888 .map((run) => run.text)
889 .join(""),
890 ).toBe("Tasks\n\n☑︎\tCompleted\n•\tParent\n◦\tNested");
891 });
892
893 it("aligns ordered markers while keeping the list in one selectable string", () => {
894 const document: MarkdownNode = {
895 type: "document",
896 children: [
897 {
898 type: "list",
899 ordered: true,
900 start: 9,
901 children: [
902 {
903 type: "list_item",
904 children: [{ type: "text", content: "Ninth" }],
905 },
906 {
907 type: "list_item",
908 children: [{ type: "text", content: "Tenth" }],
909 },
910 ],
911 },
912 ],
913 };
914
915 expect(
916 nativeMarkdownDocumentRuns(document)
917 .map((run) => run.text)
918 .join(""),
919 ).toBe("\u20079.\tNinth\n10.\tTenth");
920 });
921
922 it("keeps prose selectable while exposing rich AST blocks", () => {
923 const document: MarkdownNode = {
924 type: "document",
925 children: [
926 {
927 type: "heading",
928 level: 1,
929 beg: 0,
930 end: 9,
931 children: [{ type: "text", content: "Install" }],
932 },
933 {
934 type: "code_block",
935 language: "bash",
936 beg: 11,
937 end: 35,
938 children: [{ type: "text", content: "pnpm install\n" }],
939 },
940 {
941 type: "paragraph",
942 beg: 37,
943 end: 42,
944 children: [{ type: "text", content: "Done." }],
945 },
946 ],
947 };
948
949 const chunks = nativeMarkdownDocumentChunks(document);
950 expect(chunks).toHaveLength(3);
951 expect(chunks[0]).toMatchObject({ kind: "selectable" });
952 expect(chunks[1]).toEqual({
953 kind: "rich",
954 key: "rich:code_block:offset:11",
955 node: document.children?.[1],
956 });
957 expect(chunks[2]).toMatchObject({ kind: "selectable" });
958 });
959
960 it("keeps a list containing fenced code as one rich AST container", () => {
961 const document: MarkdownNode = {
962 type: "document",
963 children: [
964 {
965 type: "list",
966 beg: 0,
967 end: 45,
968 children: [
969 {
970 type: "list_item",
971 children: [
972 {
973 type: "paragraph",
974 children: [{ type: "text", content: "Install" }],
975 },
976 {
977 type: "code_block",
978 language: "bash",
979 children: [{ type: "text", content: "pnpm install\n" }],
980 },
981 ],
982 },
983 ],
984 },
985 ],
986 };
987
988 expect(nativeMarkdownDocumentChunks(document)).toEqual([
989 {
990 kind: "rich",
991 key: "rich:list:offset:0",
992 node: document.children?.[0],
993 },
994 ]);
995 });
996
997 it("keeps surrounding prose selectable when rich nodes have no source offsets", () => {
998 const document: MarkdownNode = {
999 type: "document",
1000 children: [
1001 {
1002 type: "heading",
1003 level: 1,
1004 children: [{ type: "text", content: "Before" }],
1005 },
1006 { type: "horizontal_rule" },
1007 {
1008 type: "paragraph",
1009 children: [{ type: "text", content: "After." }],
1010 },
1011 ],
1012 };
1013
1014 const chunks = nativeMarkdownDocumentChunks(document);
1015 expect(chunks).toHaveLength(3);
1016 expect(chunks[0]).toMatchObject({ kind: "selectable" });
1017 expect(chunks[1]).toEqual({
1018 kind: "rich",
1019 key: "rich:horizontal_rule:index:1",
1020 node: document.children?.[1],
1021 });
1022 expect(chunks[2]).toMatchObject({ kind: "selectable" });
1023 });
1024
1025 it("keeps offset-free structural lists isolated without promoting the whole document", () => {
1026 const document: MarkdownNode = {
1027 type: "document",
1028 children: [
1029 {
1030 type: "paragraph",
1031 children: [{ type: "text", content: "Before." }],
1032 },
1033 {
1034 type: "list",
1035 ordered: true,
1036 children: [
1037 {
1038 type: "list_item",
1039 children: [
1040 {
1041 type: "paragraph",
1042 children: [{ type: "text", content: "Install" }],
1043 },
1044 {
1045 type: "code_block",
1046 language: "bash",
1047 children: [{ type: "text", content: "pnpm install\n" }],
1048 },
1049 ],
1050 },
1051 ],
1052 },
1053 {
1054 type: "paragraph",
1055 children: [{ type: "text", content: "After." }],
1056 },
1057 ],
1058 };
1059
1060 const chunks = nativeMarkdownDocumentChunks(document);
1061 expect(chunks).toHaveLength(3);
1062 expect(chunks[0]).toMatchObject({ kind: "selectable" });
1063 expect(chunks[1]).toEqual({
1064 kind: "rich",
1065 key: "rich:list:index:1",
1066 node: document.children?.[1],
1067 });
1068 expect(chunks[2]).toMatchObject({ kind: "selectable" });
1069 });
1070
1071 it("never collapses a rich subtree into a second markdown parsing pass", () => {
1072 const document: MarkdownNode = {
1073 type: "document",
1074 children: [
1075 {
1076 type: "paragraph",
1077 children: [{ type: "text", content: "Before." }],
1078 },
1079 {
1080 type: "blockquote",
1081 children: [
1082 {
1083 type: "list",
1084 children: [
1085 {
1086 type: "list_item",
1087 children: [
1088 { type: "text", content: "Run this" },
1089 {
1090 type: "code_block",
1091 language: "sh",
1092 children: [{ type: "text", content: "vp check\n" }],
1093 },
1094 ],
1095 },
1096 ],
1097 },
1098 ],
1099 },
1100 {
1101 type: "paragraph",
1102 children: [{ type: "text", content: "After." }],
1103 },
1104 ],
1105 };
1106
1107 const chunks = nativeMarkdownDocumentChunks(document);
1108 expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich", "selectable"]);
1109 expect(chunks[1]).toMatchObject({
1110 kind: "rich",
1111 node: { type: "blockquote" },
1112 });
1113 });
1114
1115 it("keys positioned and offset-free siblings in separate namespaces", () => {
1116 const positioned: MarkdownNode = {
1117 type: "blockquote",
1118 beg: 1,
1119 end: 10,
1120 children: [{ type: "paragraph", children: [{ type: "text", content: "Positioned" }] }],
1121 };
1122 const offsetFree: MarkdownNode = {
1123 type: "blockquote",
1124 children: [{ type: "paragraph", children: [{ type: "text", content: "Generated" }] }],
1125 };
1126 const chunks = nativeMarkdownDocumentChunks({
1127 type: "document",
1128 children: [
1129 { type: "paragraph", beg: 0, end: 0, children: [] },
1130 offsetFree,
1131 positioned,
1132 { type: "paragraph", children: [{ type: "text", content: "Tail" }] },
1133 ],
1134 });
1135
1136 expect(chunks.map((chunk) => chunk.key)).toEqual([
1137 "selectable:offset:0",
1138 "rich:blockquote:index:1",
1139 "rich:blockquote:offset:1",
1140 "selectable:index:3",
1141 ]);
1142 });
1143
1144 it("gives every offset-free selectable group its own key", () => {
1145 const chunks = nativeMarkdownDocumentChunks({
1146 type: "document",
1147 children: [
1148 { type: "paragraph", children: [{ type: "text", content: "One" }] },
1149 { type: "horizontal_rule" },
1150 { type: "paragraph", children: [{ type: "text", content: "Two" }] },
1151 { type: "horizontal_rule" },
1152 { type: "paragraph", children: [{ type: "text", content: "Three" }] },
1153 ],
1154 });
1155
1156 expect(chunks.map((chunk) => chunk.key)).toEqual([
1157 "selectable:index:0",
1158 "rich:horizontal_rule:index:1",
1159 "selectable:index:2",
1160 "rich:horizontal_rule:index:3",
1161 "selectable:index:4",
1162 ]);
1163 });
1164
1165 it("keeps positioned chunk keys stable while text streams in", () => {
1166 const before: MarkdownNode = {
1167 type: "document",
1168 children: [
1169 { type: "paragraph", beg: 0, end: 5, children: [{ type: "text", content: "Intro" }] },
1170 {
1171 type: "code_block",
1172 language: "ts",
1173 beg: 7,
1174 end: 20,
1175 children: [{ type: "text", content: "const a" }],
1176 },
1177 ],
1178 };
1179 const after: MarkdownNode = {
1180 type: "document",
1181 children: [
1182 { type: "paragraph", beg: 0, end: 5, children: [{ type: "text", content: "Intro" }] },
1183 {
1184 type: "code_block",
1185 language: "ts",
1186 beg: 7,
1187 end: 40,
1188 children: [{ type: "text", content: "const a = 1;\nconst b" }],
1189 },
1190 ],
1191 };
1192
1193 expect(nativeMarkdownDocumentChunks(after).map((chunk) => chunk.key)).toEqual(
1194 nativeMarkdownDocumentChunks(before).map((chunk) => chunk.key),
1195 );
1196 });
1197
1198 it("keeps a plain list in one selectable native text container", () => {
1199 const list: MarkdownNode = {
1200 type: "list",
1201 ordered: false,
1202 children: [
1203 {
1204 type: "list_item",
1205 children: [{ type: "text", content: "First" }],
1206 },
1207 ],
1208 };
1209
1210 const chunks = nativeMarkdownDocumentChunks({
1211 type: "document",
1212 children: [list],
1213 });
1214
1215 expect(chunks).toHaveLength(1);
1216 expect(chunks[0]).toMatchObject({
1217 kind: "selectable",
1218 node: { type: "document", children: [list] },
1219 });
1220 });
1221
1222 it("separates sections more than related rich blocks", () => {
1223 const headingChunk = {
1224 kind: "selectable" as const,
1225 key: "heading",
1226 node: {
1227 type: "document",
1228 children: [
1229 {
1230 type: "heading",
1231 level: 2,
1232 children: [{ type: "text", content: "Section" }],
1233 },
1234 ],
1235 } satisfies MarkdownNode,
1236 };
1237 const firstList = {
1238 kind: "rich" as const,
1239 key: "list-1",
1240 node: { type: "list", children: [] } satisfies MarkdownNode,
1241 };
1242 const secondList = {
1243 kind: "rich" as const,
1244 key: "list-2",
1245 node: { type: "list", children: [] } satisfies MarkdownNode,
1246 };
1247
1248 expect(nativeMarkdownChunkSpacing(undefined, headingChunk)).toBe(0);
1249 expect(nativeMarkdownChunkSpacing(headingChunk, firstList)).toBe(10);
1250 expect(nativeMarkdownChunkSpacing(firstList, secondList)).toBe(12);
1251 expect(nativeMarkdownChunkSpacing(firstList, headingChunk)).toBe(20);
1252 });
1253});
1254
1255describe("composerChipSizeSuffix", () => {
1256 it("labels attachment records with a human size, matching web's chip", async () => {
1257 const { composerChipSizeSuffix } = await import("@t3tools/mobile-markdown-text/markdown");
1258 expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 1024 })).toBe("1 KB");
1259 expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 3_700_000 })).toBe("3.5 MB");
1260 expect(composerChipSizeSuffix({ kind: "image", sizeBytes: 2048 })).toBe("2 KB");
1261 });
1262
1263 it("adds nothing for records that carry no bytes", async () => {
1264 const { composerChipSizeSuffix } = await import("@t3tools/mobile-markdown-text/markdown");
1265 // Terminal/review/PR chips have no size to show.
1266 expect(composerChipSizeSuffix({ kind: "terminal" })).toBe("");
1267 expect(composerChipSizeSuffix({ kind: "file" })).toBe("");
1268 expect(composerChipSizeSuffix(undefined)).toBe("");
1269 });
1270});
1271
1272describe("contextChipPresentation image detection", () => {
1273 it("treats a picture attached through the file picker as an image", async () => {
1274 const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown");
1275 // The document picker types every pick as `file`, so the name has to carry the intent.
1276 expect(
1277 contextChipPresentation("file", { kind: "file", name: "IMG_4997.PNG", mimeType: "" }),
1278 ).toEqual({ accent: "#d55665", symbol: "photo" });
1279 expect(
1280 contextChipPresentation("file", {
1281 kind: "file",
1282 name: "shot",
1283 mimeType: "image/jpeg",
1284 }),
1285 ).toEqual({ accent: "#d55665", symbol: "photo" });
1286 });
1287
1288 it("leaves genuine documents and videos alone", async () => {
1289 const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown");
1290 expect(
1291 contextChipPresentation("file", { kind: "file", name: "notes.txt", mimeType: "text/plain" }),
1292 ).toEqual({ accent: "#0090cd", symbol: "doc" });
1293 expect(
1294 contextChipPresentation("file", { kind: "file", name: "clip.mp4", mimeType: "video/mp4" }),
1295 ).toEqual({ accent: "#d06217", symbol: "play.rectangle" });
1296 });
1297});
1298
1299describe("pull request chip status", () => {
1300 const chip = async (state: string, isDraft = false) => {
1301 const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown");
1302 return contextChipPresentation("review-comment", {
1303 kind: "review-comment",
1304 sectionId: "pull-request:10978",
1305 pullRequest: { state, isDraft },
1306 });
1307 };
1308
1309 it("colours a pull request by its state, the way web and the forge do", async () => {
1310 expect((await chip("open")).accent).toBe("#009f6e");
1311 expect((await chip("open", true)).accent).toBe("#7f8793");
1312 expect((await chip("merged")).accent).toBe("#8a70dd");
1313 expect((await chip("closed")).accent).toBe("#d55665");
1314 });
1315
1316 it("keeps one glyph across every state, so only colour carries the status", async () => {
1317 // Web draws a fixed `git-pull-request` and encodes state in colour alone. A per-state glyph
1318 // here would put mobile out of step with it.
1319 const symbols = await Promise.all(
1320 [chip("open"), chip("open", true), chip("merged"), chip("closed")].map(
1321 async (pending) => (await pending).symbol,
1322 ),
1323 );
1324 expect(new Set(symbols)).toEqual(new Set(["git-pull-request"]));
1325 });
1326
1327 it("falls back to the generic pull request chip when the state is unknown", async () => {
1328 const { contextChipPresentation } = await import("@t3tools/mobile-markdown-text/markdown");
1329 // An older server may send no metadata at all; the chip still has to render.
1330 expect(
1331 contextChipPresentation("review-comment", {
1332 kind: "review-comment",
1333 sectionId: "pull-request:1",
1334 }).accent,
1335 ).toBe("#7079e4");
1336 });
1337});
apps/mobile/src/native/EnrichedMarkdownText.tsx created+360
...@@ -0,0 +1,360 @@
1import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
2import {
3 collectComposerContextReferences,
4 formatComposerContextHref,
5 formatComposerContextReference,
6} from "@t3tools/shared/composerContextReferences";
7import { markdownWithHtml } from "../lib/markdownDocument";
8import { useCallback, useMemo, useState } from "react";
9import { ActionSheetIOS, Platform, Share, StyleSheet, View } from "react-native";
10import {
11 EnrichedMarkdownText,
12 type DocumentAssetsEvent,
13 type MarkdownStyle,
14 type MarkdownMediaAsset,
15} from "react-native-enriched-markdown";
16import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";
17
18import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
19import { AndroidAnchoredMenu } from "../components/AndroidAnchoredMenu";
20import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants";
21import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
22import {
23 ENRICHED_INLINE_FILE_LINK_REGEX,
24 enrichedSkillLinkRegex,
25 enrichedLinkVariantPattern,
26} from "../lib/enrichedLinkPresentation";
27
28function enrichedStyle(
29 style: SelectableMarkdownTextProps["textStyle"],
30 dark: boolean,
31): MarkdownStyle {
32 const monospace = Platform.OS === "ios" ? "Menlo" : "monospace";
33 // Keep the radius modest: iOS clips square table-cell borders without stroking the corner arcs.
34 const block = {
35 marginTop: 4,
36 marginBottom: 12,
37 borderRadius: 8,
38 borderWidth: StyleSheet.hairlineWidth,
39 borderColor: style.dividerColor,
40 };
41 const body = {
42 color: style.color,
43 fontFamily: style.fontFamily,
44 fontSize: style.fontSize,
45 lineHeight: style.lineHeight,
46 marginTop: 0,
47 marginBottom: 8,
48 };
49 const heading = (level: number) => {
50 const fontSize = style.headingFontSizes?.[level - 1] ?? style.fontSize * (1.6 - level * 0.1);
51 return {
52 ...body,
53 color: style.strongColor,
54 fontFamily: style.headingFontFamily,
55 fontSize,
56 lineHeight: fontSize * 1.35,
57 marginTop: 8,
58 };
59 };
60 return {
61 paragraph: body,
62 h1: heading(1),
63 h2: heading(2),
64 h3: heading(3),
65 h4: heading(4),
66 h5: heading(5),
67 h6: heading(6),
68 strong: { color: style.strongColor, fontFamily: style.boldFontFamily, fontWeight: "normal" },
69 link: { color: style.linkColor, underline: false },
70 code: {
71 fontFamily: monospace,
72 color: style.inlineCodeColor,
73 backgroundColor: "transparent",
74 borderColor: "transparent",
75 fontSize: style.fontSize * 0.9,
76 },
77 codeBlock: {
78 ...body,
79 color: style.codeColor,
80 backgroundColor: style.codeBlockBackgroundColor,
81 fontFamily: monospace,
82 fontSize: style.fontSize * 0.9,
83 ...block,
84 padding: 12,
85 syntaxColors: dark
86 ? {
87 keyword: "#ff7b72",
88 string: "#a5d6ff",
89 number: "#79c0ff",
90 constant: "#79c0ff",
91 comment: "#8b949e",
92 function: "#d2a8ff",
93 type: "#ffa657",
94 property: "#79c0ff",
95 tag: "#7ee787",
96 attribute: "#79c0ff",
97 }
98 : {
99 keyword: "#cf222e",
100 string: "#0a3069",
101 number: "#0550ae",
102 constant: "#0550ae",
103 comment: "#6e7781",
104 function: "#8250df",
105 type: "#953800",
106 property: "#0550ae",
107 tag: "#116329",
108 attribute: "#0550ae",
109 },
110 },
111 blockquote: {
112 ...body,
113 color: style.mutedColor,
114 backgroundColor: "transparent",
115 borderColor: style.quoteMarkerColor,
116 borderWidth: 2,
117 gapWidth: 12,
118 },
119 list: { ...body, bulletColor: style.mutedColor, markerColor: style.mutedColor, gapWidth: 6 },
120 thematicBreak: { color: style.dividerColor, height: 1, marginTop: 8, marginBottom: 8 },
121 table: {
122 ...body,
123 ...block,
124 headerFontFamily: style.boldFontFamily,
125 headerTextColor: style.strongColor,
126 headerBackgroundColor: style.codeBlockBackgroundColor,
127 rowEvenBackgroundColor: "transparent",
128 rowOddBackgroundColor: "transparent",
129 cellPaddingHorizontal: 10,
130 cellPaddingVertical: 7,
131 },
132 taskList: {
133 checkedColor: style.linkColor,
134 checkedTextColor: style.color,
135 borderColor: style.mutedColor,
136 checkedStrikethrough: false,
137 },
138 };
139}
140
141export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
142 const { renderImage, resolveImageSource } = props;
143 const markdown = useMemo(() => markdownWithHtml(props.markdown), [props.markdown]);
144 const { themeAppearance } = useAppearancePreferences();
145 const [documentAssets, setDocumentAssets] = useState<DocumentAssetsEvent["assets"]>([]);
146 const onDocumentAssets = useCallback(({ assets }: DocumentAssetsEvent) => {
147 setDocumentAssets((previous) =>
148 previous.length === assets.length &&
149 previous.every((asset, index) => {
150 const next = assets[index]!;
151 return (
152 asset.id === next.id &&
153 asset.kind === next.kind &&
154 asset.url === next.url &&
155 asset.altText === next.altText &&
156 asset.title === next.title &&
157 asset.placement === next.placement &&
158 asset.eligible === next.eligible
159 );
160 })
161 ? previous
162 : assets,
163 );
164 }, []);
165 const { linkVariants, linkPillContent } = useEnrichedLinkVariants(documentAssets, props);
166 const linkRecognition = useMemo(
167 () => ({
168 text: enrichedSkillLinkRegex(props.skills) ?? undefined,
169 inlineCode: ENRICHED_INLINE_FILE_LINK_REGEX,
170 }),
171 [props.skills],
172 );
173 const [activeMenu, setActiveMenu] = useState<{
174 url: string;
175 menu: NonNullable<ReturnType<NonNullable<typeof props.fileContextMenu>>>;
176 } | null>(null);
177 const { fileContextMenu, onLinkPress, onFileContextMenuAction } = props;
178 const performMenuAction = useCallback(
179 (url: string, actionId: string) => {
180 if (actionId === "enriched-open-link") onLinkPress?.(url);
181 else if (actionId === "enriched-copy-link") copyTextWithHaptic(url);
182 else if (actionId === "enriched-share-link") void Share.share({ message: url });
183 else onFileContextMenuAction?.(url, actionId);
184 },
185 [onLinkPress, onFileContextMenuAction],
186 );
187 const linkContextMenuItems = useMemo(
188 () =>
189 Platform.OS === "ios" && (fileContextMenu || onLinkPress)
190 ? Object.fromEntries(
191 [
192 ...new Set(
193 documentAssets.filter((asset) => asset.kind === "link").map((asset) => asset.url),
194 ),
195 ].map((url) => {
196 const menu = fileContextMenu?.(url) ?? {
197 title: url,
198 actions: [
199 { id: "enriched-open-link", title: "Open link" },
200 { id: "enriched-copy-link", title: "Copy link" },
201 { id: "enriched-share-link", title: "Share link" },
202 ],
203 };
204 return [
205 enrichedLinkVariantPattern(url),
206 menu.actions.map((action) => ({
207 text: action.title,
208 disabled: "disabled" in action && action.disabled,
209 onPress: () => performMenuAction(url, action.id),
210 })),
211 ];
212 }),
213 )
214 : undefined,
215 [documentAssets, fileContextMenu, onLinkPress, performMenuAction],
216 );
217 const selectionClipboard = useMemo(() => {
218 const fragment = decodeComposerContextFragment(props.contextClipboardFragment);
219 if (!fragment) return undefined;
220 const references = collectComposerContextReferences(props.markdown);
221 const linkTextByUrl = Object.fromEntries(
222 references.flatMap((reference) => {
223 const record = fragment.records.find((entry) => entry.contextId === reference.contextId);
224 return record
225 ? [
226 [
227 formatComposerContextHref(reference.kind, reference.contextId),
228 formatComposerContextReference({ ...reference, label: record.label }),
229 ],
230 ]
231 : [];
232 }),
233 );
234 return {
235 linkTextByUrl,
236 htmlAttributes: {
237 "data-t3-context-fragment": encodeURIComponent(props.contextClipboardFragment!),
238 },
239 mimeTypes: { "app.t3.context-fragment": props.contextClipboardFragment! },
240 };
241 }, [props.contextClipboardFragment, props.markdown]);
242 const markdownStyle = useMemo(
243 () => ({ ...enrichedStyle(props.textStyle, themeAppearance === "dark"), linkVariants }),
244 [props.textStyle, themeAppearance, linkVariants],
245 );
246 const renderMedia = useCallback(
247 (asset: MarkdownMediaAsset) =>
248 renderImage?.({
249 href: asset.url,
250 alt: asset.altText || null,
251 title: asset.title || null,
252 }) ?? null,
253 [renderImage],
254 );
255 const resolveNativeImageSource = useCallback(
256 (asset: MarkdownMediaAsset) =>
257 resolveImageSource?.({
258 href: asset.url,
259 alt: asset.altText || null,
260 title: asset.title || null,
261 }) ?? null,
262 [resolveImageSource],
263 );
264 const renderMarkdown = (openAndroidMenu?: () => void) => {
265 const onLinkLongPress = props.fileContextMenu
266 ? ({ url }: { url: string }) => {
267 const menu = props.fileContextMenu?.(url) ?? {
268 title: url,
269 actions: [
270 { id: "enriched-open-link", title: "Open link" },
271 { id: "enriched-copy-link", title: "Copy link" },
272 ],
273 };
274 const actions = menu.actions;
275 if (Platform.OS === "ios") {
276 ActionSheetIOS.showActionSheetWithOptions(
277 {
278 title: menu.title,
279 options: [...actions.map((action) => action.title), "Cancel"],
280 cancelButtonIndex: actions.length,
281 disabledButtonIndices: actions.flatMap((action, index) =>
282 action.disabled ? [index] : [],
283 ),
284 },
285 (index) => {
286 const action = actions[index];
287 if (action && !action.disabled) performMenuAction(url, action.id);
288 },
289 );
290 } else {
291 setActiveMenu({ url, menu });
292 openAndroidMenu?.();
293 }
294 }
295 : undefined;
296
297 return (
298 <View
299 style={{
300 flexShrink: 1,
301 minWidth: 0,
302 marginTop: props.marginTop,
303 marginBottom: props.marginBottom,
304 }}
305 >
306 <EnrichedMarkdownText
307 markdown={markdown}
308 markdownStyle={markdownStyle}
309 onDocumentAssets={onDocumentAssets}
310 renderMedia={renderImage ? renderMedia : undefined}
311 resolveImageSource={resolveImageSource ? resolveNativeImageSource : undefined}
312 onImagePress={
313 props.onImagePress || props.onLinkPress
314 ? ({ url }) => (props.onImagePress ?? props.onLinkPress)?.(url)
315 : undefined
316 }
317 linkRecognition={linkRecognition}
318 containerStyle={{ flexShrink: 1, minWidth: 0 }}
319 flavor="github"
320 selectable
321 selectionClipboard={selectionClipboard}
322 selectionColor={props.textStyle.selectionColor}
323 selectionHandleColor={props.textStyle.selectionHandleColor}
324 md4cFlags={{
325 latexMath: false,
326 hardSoftBreaks: props.preserveSoftBreaks ?? false,
327 admonitions: true,
328 }}
329 enableTaskListItemToggle={false}
330 spoilerOverlay="solid"
331 onLinkPress={props.onLinkPress ? ({ url }) => props.onLinkPress?.(url) : undefined}
332 onLinkLongPress={onLinkLongPress}
333 linkContextMenuItems={linkContextMenuItems}
334 linkPillContent={linkPillContent}
335 />
336 </View>
337 );
338 };
339
340 return Platform.OS === "android" && props.fileContextMenu ? (
341 <AndroidAnchoredMenu
342 title={activeMenu?.menu.title}
343 actions={
344 activeMenu?.menu.actions.map((action) => ({
345 id: action.id,
346 title: action.title,
347 attributes: { disabled: action.disabled },
348 })) ?? []
349 }
350 onPressAction={({ nativeEvent }) => {
351 if (activeMenu) performMenuAction(activeMenu.url, nativeEvent.event);
352 }}
353 style={{ flexShrink: 1, minWidth: 0 }}
354 >
355 {renderMarkdown}
356 </AndroidAnchoredMenu>
357 ) : (
358 renderMarkdown()
359 );
360}
apps/mobile/src/native/SelectableMarkdownText.android.tsx deleted-40
...@@ -1,40 +0,0 @@
1import {
2 SelectableMarkdownText as T3SelectableMarkdownText,
3 type SelectableMarkdownTextProps,
4} from "@t3tools/mobile-markdown-text/renderer";
5import { useMemo } from "react";
6
7import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter";
8import { themeColorWithAlpha } from "../lib/mobileTheme";
9import { useUniwindTheme } from "../lib/useUniwindTheme";
10
11type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
12
13export type {
14 MarkdownImageRequest,
15 NativeMarkdownTextStyle,
16 SelectableMarkdownSkill,
17} from "@t3tools/mobile-markdown-text/types";
18
19// The renderer falls back to React Native Text outside iOS, so Android can use
20// the same Markdown chunking while retaining native text selection.
21export function hasNativeSelectableMarkdownText(): boolean {
22 return true;
23}
24
25export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) {
26 const theme = useUniwindTheme();
27 const selectionColor = themeColorWithAlpha(theme["--color-focus"], 0.32);
28 const selectionHandleColor = theme["--color-focus"];
29 const textStyle = useMemo(
30 () => ({ selectionColor, selectionHandleColor, ...props.textStyle }),
31 [props.textStyle, selectionColor, selectionHandleColor],
32 );
33 return (
34 <T3SelectableMarkdownText
35 {...props}
36 textStyle={textStyle}
37 highlightCode={highlightCodeSnippet}
38 />
39 );
40}
apps/mobile/src/native/SelectableMarkdownText.ios.tsx deleted-25
...@@ -1,25 +0,0 @@
1import {
2 SelectableMarkdownText as T3SelectableMarkdownText,
3 type SelectableMarkdownTextProps,
4} from "@t3tools/mobile-markdown-text/renderer";
5
6import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter";
7
8type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
9
10export type {
11 MarkdownFileContextMenu,
12 MarkdownFileContextMenuAction,
13 MarkdownImageRenderer,
14 MarkdownImageRequest,
15 NativeMarkdownTextStyle,
16 SelectableMarkdownSkill,
17} from "@t3tools/mobile-markdown-text/types";
18
19export function hasNativeSelectableMarkdownText(): boolean {
20 return true;
21}
22
23export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) {
24 return <T3SelectableMarkdownText {...props} highlightCode={highlightCodeSnippet} />;
25}
apps/mobile/src/native/SelectableMarkdownText.tsx+21-9
...@@ -1,20 +1,32 @@...@@ -1,20 +1,32 @@
1import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer";1import { useMemo } from "react";
2import { Platform } from "react-native";
3import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";
24
3type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;5import { MobileEnrichedMarkdownText } from "./EnrichedMarkdownText";
6import { themeColorWithAlpha } from "../lib/mobileTheme";
7import { useUniwindTheme } from "../lib/useUniwindTheme";
48
5export type {9export type {
6 MarkdownFileContextMenu,10 MarkdownFileContextMenu,
7 MarkdownFileContextMenuAction,11 MarkdownFileContextMenuAction,
8 MarkdownImageRenderer,12 MarkdownImageRenderer,
9 MarkdownImageRequest,13 MarkdownImageRequest,
14 MarkdownImageSourceResolver,
15 MarkdownLinkCustomization,
10 NativeMarkdownTextStyle,16 NativeMarkdownTextStyle,
11 SelectableMarkdownSkill,17 SelectableMarkdownSkill,
12} from "@t3tools/mobile-markdown-text/types";18} from "./SelectableMarkdownText.types";
1319
14export function hasNativeSelectableMarkdownText(): boolean {20export function SelectableMarkdownText(props: SelectableMarkdownTextProps) {
15 return false;21 const theme = useUniwindTheme();
16}22 const selectionColor = themeColorWithAlpha(theme["--color-focus"], 0.32);
1723 const selectionHandleColor = theme["--color-focus"];
18export function SelectableMarkdownText(_props: MobileSelectableMarkdownTextProps) {24 const textStyle = useMemo(
19 return null;25 () =>
26 Platform.OS === "android"
27 ? { selectionColor, selectionHandleColor, ...props.textStyle }
28 : props.textStyle,
29 [props.textStyle, selectionColor, selectionHandleColor],
30 );
31 return <MobileEnrichedMarkdownText {...props} textStyle={textStyle} />;
20}32}
apps/mobile/src/native/SelectableMarkdownText.types.ts created+84
...@@ -0,0 +1,84 @@
1export interface NativeMarkdownTextStyle {
2 readonly selectionColor?: string;
3 readonly selectionHandleColor?: string;
4 readonly color: string;
5 readonly strongColor: string;
6 readonly mutedColor: string;
7 readonly linkColor: string;
8 readonly inlineCodeColor: string;
9 readonly codeColor: string;
10 readonly codeBackgroundColor: string;
11 readonly codeBlockBackgroundColor: string;
12 readonly fileTextColor: string;
13 readonly skillTextColor: string;
14 readonly quoteMarkerColor: string;
15 readonly dividerColor: string;
16 readonly contextChipBorderColor?: string;
17 readonly fontSize: number;
18 readonly lineHeight: number;
19 readonly fontFamily: string;
20 readonly headingFontFamily: string;
21 readonly boldFontFamily: string;
22 readonly headingFontSizes?: ReadonlyArray<number>;
23}
24
25export interface SelectableMarkdownSkill {
26 readonly name: string;
27 readonly displayName?: string | null;
28}
29
30export interface MarkdownImageRequest {
31 readonly href: string;
32 readonly alt: string | null;
33 readonly title: string | null;
34}
35
36/**
37 * App-supplied renderer for markdown images. The module cannot load
38 * workspace-relative image paths itself — the host app resolves them (for
39 * example through a signed asset URL) and returns the element to show.
40 * Returning null keeps Enriched's native media rendering.
41 */
42export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode;
43
44export type MarkdownImageSourceResolver = (
45 image: MarkdownImageRequest,
46) =>
47 | { uri: string; headers?: Record<string, string> }
48 | null
49 | Promise<{ uri: string; headers?: Record<string, string> } | null>;
50
51export interface MarkdownFileContextMenuAction {
52 readonly id: string;
53 readonly title: string;
54 readonly disabled?: boolean;
55}
56
57export interface MarkdownFileContextMenu {
58 readonly title?: string;
59 readonly actions: ReadonlyArray<MarkdownFileContextMenuAction>;
60}
61
62export interface MarkdownLinkCustomization {
63 readonly iconTintColor?: string;
64 readonly color?: string;
65 readonly label?: string;
66 readonly icon?: import("react-native").ImageSourcePropType;
67}
68
69export interface SelectableMarkdownTextProps {
70 readonly markdown: string;
71 readonly textStyle: NativeMarkdownTextStyle;
72 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
73 readonly preserveSoftBreaks?: boolean;
74 readonly onLinkPress?: (href: string) => void;
75 readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined;
76 readonly onFileContextMenuAction?: (href: string, actionId: string) => void;
77 readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined;
78 readonly contextClipboardFragment?: string;
79 readonly renderImage?: MarkdownImageRenderer;
80 readonly resolveImageSource?: MarkdownImageSourceResolver;
81 readonly onImagePress?: (href: string) => void;
82 readonly marginTop?: number;
83 readonly marginBottom?: number;
84}
apps/mobile/src/native/T3ComposerEditor.ios.tsx+3-9
...@@ -15,15 +15,9 @@ import {...@@ -15,15 +15,9 @@ import {
15import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native";15import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native";
16import { StyleSheet } from "react-native";16import { StyleSheet } from "react-native";
1717
18import {18import { markdownFileIconSource, markdownIconAssetUri } from "../lib/markdownFileIcons";
19 markdownFileIconSource,19import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation";
20 markdownIconAssetUri,20import { resolveMarkdownFileIcon } from "../lib/markdownLinks";
21} from "@t3tools/mobile-markdown-text/file-icons";
22import {
23 composerChipSizeSuffix,
24 contextChipPresentation,
25} from "@t3tools/mobile-markdown-text/markdown";
26import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
27import { useUniwindTheme } from "../lib/useUniwindTheme";21import { useUniwindTheme } from "../lib/useUniwindTheme";
28import { createNativeComposerTheme } from "../lib/nativeComposerTheme";22import { createNativeComposerTheme } from "../lib/nativeComposerTheme";
29import { useFontFamily } from "../lib/useFontFamily";23import { useFontFamily } from "../lib/useFontFamily";
apps/mobile/src/native/T3ComposerEditor.native.tsx+3-9
...@@ -16,15 +16,9 @@ import {...@@ -16,15 +16,9 @@ import {
16import type { NativeSyntheticEvent, ViewProps } from "react-native";16import type { NativeSyntheticEvent, ViewProps } from "react-native";
17import { Image, Platform, StyleSheet } from "react-native";17import { Image, Platform, StyleSheet } from "react-native";
1818
19import {19import { markdownFileIconSource, markdownIconAssetUri } from "../lib/markdownFileIcons";
20 markdownFileIconSource,20import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation";
21 markdownIconAssetUri,21import { resolveMarkdownFileIcon } from "../lib/markdownLinks";
22} from "@t3tools/mobile-markdown-text/file-icons";
23import {
24 composerChipSizeSuffix,
25 contextChipPresentation,
26} from "@t3tools/mobile-markdown-text/markdown";
27import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
28import { MOBILE_TYPOGRAPHY } from "../lib/typography";22import { MOBILE_TYPOGRAPHY } from "../lib/typography";
29import { useNativePaste } from "../lib/useNativePaste";23import { useNativePaste } from "../lib/useNativePaste";
30import { useFontFamily } from "../lib/useFontFamily";24import { useFontFamily } from "../lib/useFontFamily";
apps/mobile/src/native/createV5StackNavigator.ios.tsx+4
...@@ -269,10 +269,14 @@ export function V5StackView(props: V5StackViewProps) {...@@ -269,10 +269,14 @@ export function V5StackView(props: V5StackViewProps) {
269 groups.map((group) => {269 groups.map((group) => {
270 const first = group[0]!;270 const first = group[0]!;
271 const descriptor = props.descriptors[first.key]!;271 const descriptor = props.descriptors[first.key]!;
272 const visibleRoute = group.at(-1)!;
272 return [273 return [
273 first.key,274 first.key,
274 {275 {
275 ...descriptor,276 ...descriptor,
277 navigation: props.descriptors[visibleRoute.key]!.navigation,
278 // Focus belongs to the pushed page inside this presentation group.
279 route: visibleRoute,
276 options: modalEnvelopeOptions(descriptor.options),280 options: modalEnvelopeOptions(descriptor.options),
277 render: () => (281 render: () => (
278 <V5CardStackView282 <V5CardStackView
apps/mobile/src/native/useEnrichedLinkVariants.ts created+105
...@@ -0,0 +1,105 @@
1import { useMemo } from "react";
2import { Image } from "react-native";
3import type {
4 LinkPillContent,
5 MarkdownDocumentAsset,
6 MarkdownStyle,
7} from "react-native-enriched-markdown";
8import { markdownFileIconSource } from "../lib/markdownFileIcons";
9import { markdownLinkIconSource } from "../lib/markdownLinkIcons";
10import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "../lib/markdownLinks";
11import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";
12import {
13 enrichedContextLinkPresentation,
14 enrichedLinkVariantPattern,
15 enrichedSkillDisplayName,
16 enrichedSkillReference,
17} from "../lib/enrichedLinkPresentation";
18
19function presentation(
20 url: string,
21 props: Pick<SelectableMarkdownTextProps, "linkCustomization" | "skills" | "textStyle">,
22) {
23 const custom = props.linkCustomization?.(url);
24 if (custom) return custom;
25 const context = enrichedContextLinkPresentation(url);
26 if (context) return { color: context.color, icon: markdownFileIconSource(context.icon) };
27 const skill = enrichedSkillReference(url, props.skills);
28 if (skill)
29 return {
30 color: props.textStyle.skillTextColor,
31 label: enrichedSkillDisplayName(skill),
32 icon: markdownFileIconSource("mcp"),
33 };
34 const link = resolveMarkdownLinkPresentation(url);
35 if (link.kind === "file")
36 return {
37 color: props.textStyle.fileTextColor,
38 label: link.label,
39 icon: markdownFileIconSource(link.icon),
40 };
41 const icon = link.kind === "external" ? resolveMarkdownLinkIcon(link.host) : null;
42 return icon
43 ? {
44 color: props.textStyle.linkColor,
45 icon: markdownLinkIconSource(icon),
46 iconTintColor: props.textStyle.linkColor,
47 }
48 : undefined;
49}
50
51export function useEnrichedLinkVariants(
52 assets: ReadonlyArray<MarkdownDocumentAsset>,
53 props: SelectableMarkdownTextProps,
54) {
55 const { linkCustomization, skills, textStyle } = props;
56 const links = useMemo(() => {
57 const byUrl = new Map<string, NonNullable<ReturnType<typeof presentation>>>();
58 for (const asset of assets) {
59 if (asset.kind !== "link" || byUrl.has(asset.url)) continue;
60 const value = presentation(asset.url, { linkCustomization, skills, textStyle });
61 if (value) byUrl.set(asset.url, value);
62 }
63 return byUrl;
64 }, [assets, linkCustomization, skills, textStyle]);
65 const linkVariants = useMemo<MarkdownStyle["linkVariants"]>(
66 () =>
67 Object.fromEntries(
68 [...links].map(([url, link]) => {
69 const color = link.color ?? textStyle.fileTextColor;
70 return [
71 enrichedLinkVariantPattern(url),
72 {
73 pill: {
74 borderColor: "transparent",
75 borderWidth: 0,
76 borderRadius: 6,
77 paddingHorizontal: 5,
78 paddingVertical: 2,
79 },
80 fontFamily: textStyle.fontFamily,
81 color,
82 underline: false,
83 backgroundColor: "transparent",
84 },
85 ];
86 }),
87 ),
88 [links, textStyle],
89 );
90 const linkPillContent = useMemo<Record<string, LinkPillContent>>(
91 () =>
92 Object.fromEntries(
93 [...links].map(([url, link]) => [
94 url,
95 {
96 label: link.label,
97 iconUri: link.icon ? Image.resolveAssetSource(link.icon)?.uri : undefined,
98 iconTintColor: link.iconTintColor,
99 },
100 ]),
101 ),
102 [links],
103 );
104 return { linkVariants, linkPillContent };
105}
apps/mobile/src/native/useMarkdownImageSource.ts created+73
...@@ -0,0 +1,73 @@
1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { useEnvironmentPresentation } from "../state/presentation";
3import { useEnvironmentQuery } from "../state/query";
4import { resolveMobileMarkdownMediaSource } from "../lib/markdownMediaSource";
5import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
6import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
7import type { MarkdownImageSourceResolver } from "./SelectableMarkdownText.types";
8import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks";
9import { useCallback } from "react";
10import * as Option from "effect/Option";
11
12import { assetEnvironment } from "../state/assets";
13import { environmentSession, usePreparedConnection } from "../state/session";
14import { useAtomQueryRunner } from "../state/use-atom-query-runner";
15
16/** Native nested images use the same signed asset queries as app-owned media. */
17export function useMarkdownImageSource(props: {
18 readonly environmentId: EnvironmentId;
19 readonly threadId: ThreadId | null;
20 readonly workspaceRoot: string | null;
21 readonly captured?: boolean;
22}): MarkdownImageSourceResolver {
23 const { environmentId, threadId, workspaceRoot, captured } = props;
24 const connection = usePreparedConnection(environmentId);
25 const environment = useEnvironmentPresentation(environmentId);
26 const connectionPhase = environment.presentation?.connection.phase;
27 const session = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId));
28 const fileAccess = resolveFilesystemReadAccess({
29 isCatalogReady: environment.isReady,
30 connection: environment.presentation?.connection ?? null,
31 session: session.data,
32 sessionError: session.error,
33 });
34 const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null;
35 const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false });
36 return useCallback(
37 async (image) => {
38 const media = resolveMobileMarkdownMediaSource(image.href, {
39 threadId: threadId ?? undefined,
40 workspaceRoot,
41 imageEmbed: true,
42 });
43 if (media?.access === "direct") return { uri: normalizeNativeMarkdownUrl(media.uri) };
44 if (
45 captured ||
46 !fileAccess.canReadFiles ||
47 media === null ||
48 media.access === "unavailable" ||
49 httpBaseUrl === null ||
50 connectionPhase === "offline" ||
51 connectionPhase === "error" ||
52 connectionPhase === "unsupported" ||
53 connectionPhase === "reconnecting"
54 )
55 throw new Error("Markdown image is unavailable");
56 const result = await createUrl({ environmentId, input: { resource: media.resource } });
57 if (result._tag !== "Success") throw new Error("Could not authorize Markdown image");
58 const uri = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl);
59 if (uri === null) throw new Error("Markdown image URL is invalid");
60 return { uri };
61 },
62 [
63 captured,
64 fileAccess.canReadFiles,
65 connectionPhase,
66 createUrl,
67 environmentId,
68 httpBaseUrl,
69 threadId,
70 workspaceRoot,
71 ],
72 );
73}
apps/mobile/src/state/mediaActions.ts+1-1
...@@ -7,7 +7,7 @@ import {...@@ -7,7 +7,7 @@ import {
7 sessionGrantsScope,7 sessionGrantsScope,
8 type SessionGrantInput,8 type SessionGrantInput,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links";10import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks";
11import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import { AsyncResult } from "effect/reactivity";12import { AsyncResult } from "effect/reactivity";
13import { useEffect, useRef, useState } from "react";13import { useEffect, useRef, useState } from "react";
knip.jsonc+1-1
...@@ -88,7 +88,7 @@...@@ -88,7 +88,7 @@
88 "@t3tools/mobile-terminal-native",88 "@t3tools/mobile-terminal-native",
89 ],89 ],
90 },90 },
91 "apps/mobile/modules/t3-markdown-text": {91 "apps/mobile/modules/t3-source-text": {
92 // React Native codegen discovers component specifications by filename.92 // React Native codegen discovers component specifications by filename.
93 "entry": ["src/*NativeComponent.ts!"],93 "entry": ["src/*NativeComponent.ts!"],
94 },94 },
patches/react-native-nitro-markdown@0.5.8.patch deleted-70
...@@ -1,70 +0,0 @@
1diff --git a/cpp/md4c/md4c.c b/cpp/md4c/md4c.c
2--- a/cpp/md4c/md4c.c
3+++ b/cpp/md4c/md4c.c
4@@ -3908,10 +3908,10 @@
5 int min_components;
6 const MD_CHAR optional_end_char;
7 } URL_MAP[] = {
8- { _T('\0'), _T('.'), _T(".-_"), 2, _T('\0') }, /* host, mandatory */
9- { _T('/'), _T('/'), _T("/.-_"), 0, _T('/') }, /* path */
10- { _T('?'), _T('&'), _T("&.-+_=()"), 1, _T('\0') }, /* query */
11- { _T('#'), _T('\0'), _T(".-+_") , 1, _T('\0') } /* fragment */
12+ { _T('\0'), _T('.'), _T(".-_"), 2, _T('\0') }, /* host, mandatory */
13+ { _T('/'), _T('/'), _T("/.-_():~%+@&!,;"), 0, _T('/') }, /* path */
14+ { _T('?'), _T('&'), _T("&.-+_=().:!~%,;@"), 1, _T('\0') }, /* query */
15+ { _T('#'), _T('\0'), _T(".-+_:%~") , 1, _T('\0') } /* fragment */
16 };
17
18 MD_MARK* opener = &ctx->marks[mark_index];
19@@ -3981,8 +3981,11 @@
20 } else if(end < line_end &&
21 ISANYOF(end, URL_MAP[i].allowed_nonalnum_chars) &&
22 md_scan_right_for_resolved_mark(ctx, right_cursor, end, &right_cursor) == NULL &&
23- ((end > line_beg && (ISALNUM(end-1) || CH(end-1) == _T(')'))) || CH(end) == _T('(')) &&
24- ((end+1 < line_end && (ISALNUM(end+1) || CH(end+1) == _T('('))) || CH(end) == _T(')')))
25+ /* Scheme URLs keep '...' and ':' in the path and query.
26+ * Host, e-mail, and www labels stay flanked by alphanumerics. */
27+ ((opener->ch == _T(':') && i > 0) ||
28+ (((end > line_beg && (ISALNUM(end-1) || CH(end-1) == _T(')'))) || CH(end) == _T('(')) &&
29+ ((end+1 < line_end && (ISALNUM(end+1) || CH(end+1) == _T('('))) || CH(end) == _T(')')))))
30 {
31 if(CH(end) == URL_MAP[i].delim_char)
32 n_components++;
33@@ -4006,16 +4009,35 @@
34 CH(end) == URL_MAP[i].optional_end_char)
35 end++;
36
37- if(n_components < URL_MAP[i].min_components || n_open_brackets != 0)
38+ /* Scheme autolinks allow one host label, so localhost and devbox link.
39+ * E-mail and www autolinks still require a dotted host. */
40+ if(n_components < (i == 0 && opener->ch == ':' ? 1 : URL_MAP[i].min_components) ||
41+ n_open_brackets != 0)
42 return;
43
44+ /* Optional port on a scheme autolink: ':' followed by digits. */
45+ if(i == 0 && opener->ch == ':' && end + 1 < line_end &&
46+ CH(end) == _T(':') && ISDIGIT(end + 1)) {
47+ end++;
48+ while(end < line_end && ISDIGIT(end))
49+ end++;
50+ }
51+
52 if(opener->ch == '@') /* E-mail autolinks wants only the host. */
53 break;
54+ }
55+
56+ /* GFM leaves a trailing run of punctuation outside the link, including
57+ * the sentence dots in "https://example.com/a...". */
58+ if(opener->ch == _T(':')) {
59+ while(end > opener->end && ISANYOF(end - 1, _T("?!.,:*")))
60+ end--;
61 }
62
63 /* Verify there's line boundary, whitespace, allowed punctuation or
64 * resolved emphasis mark just after the suspected autolink. */
65- if(end == line_end || ISUNICODEWHITESPACE(end) || ISANYOF(end, _T(")}].!?,;"))) {
66+ if(end == line_end || ISUNICODEWHITESPACE(end) ||
67+ ISANYOF(end, opener->ch == _T(':') ? _T(")}].!?,;:*_~'\"`<>") : _T(")}].!?,;"))) {
68 right_boundary_ok = TRUE;
69 } else {
70 MD_MARK* right_mark;
patches/react-native-nitro-modules@0.35.9.patch deleted-32
...@@ -1,32 +0,0 @@
1diff --git a/ios/turbomodule/NativeNitroModules+NewArch.mm b/ios/turbomodule/NativeNitroModules+NewArch.mm
2--- a/ios/turbomodule/NativeNitroModules+NewArch.mm
3+++ b/ios/turbomodule/NativeNitroModules+NewArch.mm
4@@ -34,6 +34,12 @@
5 }
6 RCT_EXPORT_MODULE(NitroModules)
7
8+// RN 0.87+ passes the CallInvoker directly when installing JSI bindings.
9+- (void)installJSIBindingsWithRuntime:(jsi::Runtime&)runtime callInvoker:(const std::shared_ptr<react::CallInvoker>&)callInvoker {
10+ _callInvoker = callInvoker;
11+ [self installJSIBindingsWithRuntime:runtime];
12+}
13+
14 - (void)installJSIBindingsWithRuntime:(jsi::Runtime&)runtime {
15 // 1. Get CallInvoker we cached statically
16 std::shared_ptr<react::CallInvoker> callInvoker = _callInvoker.lock();
17diff --git a/package.json b/package.json
18index 4e4366fb9d05a6c250acfc62e79487475d8ac05b..200e4680d77cbb4f14d22eb986efde1275a0d3e8 100644
19--- a/package.json
20+++ b/package.json
21@@ -88,6 +88,11 @@
22 "jsSrcsDir": "./src",
23 "android": {
24 "javaPackageName": "com.margelo.nitro"
25+ },
26+ "ios": {
27+ "modulesProvider": {
28+ "NitroModules": "NativeNitroModules"
29+ }
30 }
31 },
32 "jest": {
pnpm-lock.yaml+44-68
...@@ -167,8 +167,6 @@ patchedDependencies:...@@ -167,8 +167,6 @@ patchedDependencies:
167 node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414167 node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414
168 react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad168 react-native-gesture-handler@3.2.1: 3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad
169 react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d169 react-native-keyboard-controller@1.22.6: 6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d
170 react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c
171 react-native-nitro-modules@0.35.9: daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2
172 react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378170 react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378
173171
174importers:172importers:
...@@ -366,12 +364,12 @@ importers:...@@ -366,12 +364,12 @@ importers:
366 '@t3tools/contracts':364 '@t3tools/contracts':
367 specifier: workspace:*365 specifier: workspace:*
368 version: link:../../packages/contracts366 version: link:../../packages/contracts
369 '@t3tools/mobile-markdown-text':
370 specifier: file:./modules/t3-markdown-text
371 version: file:apps/mobile/modules/t3-markdown-text(afae94297d61d3a65abc2214f9b05422)
372 '@t3tools/mobile-review-diff-native':367 '@t3tools/mobile-review-diff-native':
373 specifier: file:./modules/t3-review-diff368 specifier: file:./modules/t3-review-diff
374 version: file:apps/mobile/modules/t3-review-diff369 version: file:apps/mobile/modules/t3-review-diff
370 '@t3tools/mobile-source-text':
371 specifier: file:./modules/t3-source-text
372 version: file:apps/mobile/modules/t3-source-text(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
375 '@t3tools/mobile-terminal-native':373 '@t3tools/mobile-terminal-native':
376 specifier: file:./modules/t3-terminal374 specifier: file:./modules/t3-terminal
377 version: file:apps/mobile/modules/t3-terminal375 version: file:apps/mobile/modules/t3-terminal
...@@ -528,6 +526,9 @@ importers:...@@ -528,6 +526,9 @@ importers:
528 react-native:526 react-native:
529 specifier: 0.88.0-rc.3527 specifier: 0.88.0-rc.3
530 version: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)528 version: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
529 react-native-enriched-markdown:
530 specifier: file:deps/react-native-enriched-markdown-1.2.0-t3.10.tgz
531 version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz(katex@0.16.47)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
531 react-native-gesture-handler:532 react-native-gesture-handler:
532 specifier: ~3.2.1533 specifier: ~3.2.1
533 version: 3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)534 version: 3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
...@@ -537,12 +538,6 @@ importers:...@@ -537,12 +538,6 @@ importers:
537 react-native-keyboard-controller:538 react-native-keyboard-controller:
538 specifier: 1.22.6539 specifier: 1.22.6
539 version: 1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)540 version: 1.22.6(patch_hash=6e448411781347cd0b87868ee279117514a399801c18f233ddca4fa3935be75d)(react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
540 react-native-nitro-markdown:
541 specifier: ^0.5.0
542 version: 0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
543 react-native-nitro-modules:
544 specifier: 0.35.9
545 version: 0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
546 react-native-reanimated:541 react-native-reanimated:
547 specifier: 4.7.0542 specifier: 4.7.0
548 version: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)543 version: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
...@@ -564,12 +559,21 @@ importers:...@@ -564,12 +559,21 @@ importers:
564 react-native-worklets:559 react-native-worklets:
565 specifier: 0.13.0560 specifier: 0.13.0
566 version: 0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)561 version: 0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
562 remark-gfm:
563 specifier: 'catalog:'
564 version: 4.0.1
565 remark-parse:
566 specifier: 'catalog:'
567 version: 11.0.0
567 shiki:568 shiki:
568 specifier: 4.2.0569 specifier: 4.2.0
569 version: 4.2.0570 version: 4.2.0
570 tailwind-merge:571 tailwind-merge:
571 specifier: ^3.5.0572 specifier: ^3.5.0
572 version: 3.6.0573 version: 3.6.0
574 unified:
575 specifier: 'catalog:'
576 version: 11.0.5
573 uniwind:577 uniwind:
574 specifier: 1.12.1578 specifier: 1.12.1
575 version: 1.12.1(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3)579 version: 1.12.1(@expo/metro-config@58.0.6(patch_hash=90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760)(bufferutil@4.1.0)(expo@58.0.2)(typescript@7.0.2)(utf-8-validate@6.0.6))(metro-cache@0.87.1)(metro-transform-worker@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.87.1(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(tailwindcss@4.3.3)
...@@ -580,6 +584,9 @@ importers:...@@ -580,6 +584,9 @@ importers:
580 '@pierre/trees':584 '@pierre/trees':
581 specifier: 1.0.0-beta.6585 specifier: 1.0.0-beta.6
582 version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)586 version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)
587 '@types/mdast':
588 specifier: ^4.0.4
589 version: 4.0.4
583 '@types/react':590 '@types/react':
584 specifier: ~19.3.0591 specifier: ~19.3.0
585 version: 19.3.0592 version: 19.3.0
...@@ -5803,22 +5810,14 @@ packages:...@@ -5803,22 +5810,14 @@ packages:
5803 resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}5810 resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
5804 engines: {node: '>=10'}5811 engines: {node: '>=10'}
58055812
5806 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text':5813 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff':
5807 resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory}5814 resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory}
5815
5816 '@t3tools/mobile-source-text@file:apps/mobile/modules/t3-source-text':
5817 resolution: {directory: apps/mobile/modules/t3-source-text, type: directory}
5808 peerDependencies:5818 peerDependencies:
5809 '@t3tools/client-runtime': '*'
5810 '@t3tools/shared': '*'
5811 expo: '*'
5812 expo-asset: '*'
5813 expo-clipboard: '*'
5814 expo-haptics: '*'
5815 expo-symbols: '*'
5816 react: '*'5819 react: '*'
5817 react-native: '*'5820 react-native: '*'
5818 react-native-nitro-markdown: '*'
5819
5820 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff':
5821 resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory}
58225821
5823 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal':5822 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal':
5824 resolution: {directory: apps/mobile/modules/t3-terminal, type: directory}5823 resolution: {directory: apps/mobile/modules/t3-terminal, type: directory}
...@@ -10804,6 +10803,17 @@ packages:...@@ -10804,6 +10803,17 @@ packages:
10804 '@types/react': '>=18'10803 '@types/react': '>=18'
10805 react: '>=18'10804 react: '>=18'
1080610805
10806 react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz:
10807 resolution: {integrity: sha512-n9If6J8Jy1iZJAvcLflWAgVxO9LjiwMQax//seCvNWgTdob5Hc48m2mfnjxiVzxwCHFPVcRnNpbe65oROG+gVQ==, tarball: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz}
10808 version: 1.2.0-t3.10
10809 peerDependencies:
10810 katex: '>=0.16.0'
10811 react: '*'
10812 react-native: '*'
10813 peerDependenciesMeta:
10814 katex:
10815 optional: true
10816
10807 react-native-gesture-handler@3.2.1:10817 react-native-gesture-handler@3.2.1:
10808 resolution: {integrity: sha512-VEWscxUN29aeccbD2McF0LSXrwOhSdisaSRFiEWg0O/3WrVghNJp/tOtLrCgzb9gfcZ3xdA+K2Sp8F5G3iHx/Q==}10818 resolution: {integrity: sha512-VEWscxUN29aeccbD2McF0LSXrwOhSdisaSRFiEWg0O/3WrVghNJp/tOtLrCgzb9gfcZ3xdA+K2Sp8F5G3iHx/Q==}
10809 peerDependencies:10819 peerDependencies:
...@@ -10829,26 +10839,6 @@ packages:...@@ -10829,26 +10839,6 @@ packages:
10829 react-native: '*'10839 react-native: '*'
10830 react-native-reanimated: '>=3.0.0'10840 react-native-reanimated: '>=3.0.0'
1083110841
10832 react-native-nitro-markdown@0.5.8:
10833 resolution: {integrity: sha512-bBKmdWgMqZoNozuHzJgc8VxWRSjLnrHAK0bRRvz7gj5FqPxLJz+KhRV0VYZDZgvsR3S8hnmmeEqluTRBTRw+yA==}
10834 peerDependencies:
10835 react: '*'
10836 react-native: '>=0.75.0'
10837 react-native-mathjax-svg: '>=0.9.0'
10838 react-native-nitro-modules: '>=0.35.5'
10839 react-native-svg: '>=13.0.0'
10840 peerDependenciesMeta:
10841 react-native-mathjax-svg:
10842 optional: true
10843 react-native-svg:
10844 optional: true
10845
10846 react-native-nitro-modules@0.35.9:
10847 resolution: {integrity: sha512-yCO6eJ85SPPUo4a4an7H5oj6wPCSIT72fbjr5WZ/20n6zswaJ2gNNpnWtg2We0AZwkAOjSqkOJ0Vjc05p6kGiA==}
10848 peerDependencies:
10849 react: '*'
10850 react-native: '*'
10851
10852 react-native-reanimated@4.7.0:10842 react-native-reanimated@4.7.0:
10853 resolution: {integrity: sha512-8TyTrPisqz7WzmbhCz8F60Uw7NI2QeYWrY5Hk58/kurCA5L5KuHmhyQjxeq5k2YalpXUAKbdkn/mAMZWm/fU0g==}10843 resolution: {integrity: sha512-8TyTrPisqz7WzmbhCz8F60Uw7NI2QeYWrY5Hk58/kurCA5L5KuHmhyQjxeq5k2YalpXUAKbdkn/mAMZWm/fU0g==}
10854 peerDependencies:10844 peerDependencies:
...@@ -16361,20 +16351,12 @@ snapshots:...@@ -16361,20 +16351,12 @@ snapshots:
16361 dependencies:16351 dependencies:
16362 defer-to-connect: 2.0.116352 defer-to-connect: 2.0.1
1636316353
16364 '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(afae94297d61d3a65abc2214f9b05422)':16354 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {}
16355
16356 '@t3tools/mobile-source-text@file:apps/mobile/modules/t3-source-text(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)':
16365 dependencies:16357 dependencies:
16366 '@t3tools/client-runtime': link:packages/client-runtime
16367 '@t3tools/shared': link:packages/shared
16368 expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54)
16369 expo-asset: 58.0.10(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2)
16370 expo-clipboard: 58.0.3(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
16371 expo-haptics: 58.0.4(expo@58.0.2)
16372 expo-symbols: 58.0.3(expo-font@58.0.5(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
16373 react: 19.3.016358 react: 19.3.0
16374 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)16359 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
16375 react-native-nitro-markdown: 0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
16376
16377 '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {}
1637816360
16379 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {}16361 '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {}
1638016362
...@@ -22009,6 +21991,13 @@ snapshots:...@@ -22009,6 +21991,13 @@ snapshots:
22009 transitivePeerDependencies:21991 transitivePeerDependencies:
22010 - supports-color21992 - supports-color
2201121993
21994 react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz(katex@0.16.47)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
21995 dependencies:
21996 react: 19.3.0
21997 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
21998 optionalDependencies:
21999 katex: 0.16.47
22000
22012 react-native-gesture-handler@3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):22001 react-native-gesture-handler@3.2.1(patch_hash=3037216c0dd1b14e8877441bde1a7404d1c6e9c09f835bd184c3cdba4aa066ad)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
22013 dependencies:22002 dependencies:
22014 '@types/react-test-renderer': 19.1.022003 '@types/react-test-renderer': 19.1.0
...@@ -22033,19 +22022,6 @@ snapshots:...@@ -22033,19 +22022,6 @@ snapshots:
22033 react-native-is-edge-to-edge: 1.3.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)22022 react-native-is-edge-to-edge: 1.3.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
22034 react-native-reanimated: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)22023 react-native-reanimated: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
2203522024
22036 react-native-nitro-markdown@0.5.8(patch_hash=642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c)(react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native-svg@15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
22037 dependencies:
22038 react: 19.3.0
22039 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
22040 react-native-nitro-modules: 0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
22041 optionalDependencies:
22042 react-native-svg: 15.15.5(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
22043
22044 react-native-nitro-modules@0.35.9(patch_hash=daf4a639ce3f951af630a3711e4082d58e3a11e5a79f45efb0b59925327ebde2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
22045 dependencies:
22046 react: 19.3.0
22047 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
22048
22049 react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):22025 react-native-reanimated@4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
22050 dependencies:22026 dependencies:
22051 react: 19.3.022027 react: 19.3.0
pnpm-workspace.yaml+2-3
...@@ -20,6 +20,8 @@ allowBuilds:...@@ -20,6 +20,8 @@ allowBuilds:
20 node-pty: true20 node-pty: true
21 # Ships N-API prebuilds; its install script only rebuilds from source.21 # Ships N-API prebuilds; its install script only rebuilds from source.
22 objc-js: false22 objc-js: false
23 "react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.10.tgz": true
24 react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.9.tgz: set this to true or false
23 sharp: true25 sharp: true
24 utf-8-validate: false26 utf-8-validate: false
25 workerd: false27 workerd: false
...@@ -308,11 +310,8 @@ patchedDependencies:...@@ -308,11 +310,8 @@ patchedDependencies:
308 dbus-next@0.10.2: patches/dbus-next@0.10.2.patch310 dbus-next@0.10.2: patches/dbus-next@0.10.2.patch
309 effect@4.0.2: patches/effect@4.0.2.patch311 effect@4.0.2: patches/effect@4.0.2.patch
310 node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch312 node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch
311 # Link http(s) URLs with a port or a single-label host. md4c otherwise requires a dotted host and stops at ':'.
312 react-native-nitro-markdown@0.5.8: patches/react-native-nitro-markdown@0.5.8.patch
313 react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch313 react-native-gesture-handler@3.2.1: patches/react-native-gesture-handler@3.2.1.patch
314 react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch314 react-native-keyboard-controller@1.22.6: patches/react-native-keyboard-controller@1.22.6.patch
315 react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch
316 # Android: a nested WebView hands a vertical drag to its scrolling parent at the page's edge.315 # Android: a nested WebView hands a vertical drag to its scrolling parent at the page's edge.
317 react-native-webview@14.0.1: patches/react-native-webview@14.0.1.patch316 react-native-webview@14.0.1: patches/react-native-webview@14.0.1.patch
318317
scripts/release-smoke.ts+1-1
...@@ -17,7 +17,7 @@ const workspaceFiles = [...@@ -17,7 +17,7 @@ const workspaceFiles = [
17 "apps/desktop/package.json",17 "apps/desktop/package.json",
18 "apps/web/package.json",18 "apps/web/package.json",
19 "apps/mobile/package.json",19 "apps/mobile/package.json",
20 "apps/mobile/modules/t3-markdown-text/package.json",20 "apps/mobile/modules/t3-source-text/package.json",
21 "apps/mobile/modules/t3-review-diff/package.json",21 "apps/mobile/modules/t3-review-diff/package.json",
22 "apps/mobile/modules/t3-terminal/package.json",22 "apps/mobile/modules/t3-terminal/package.json",
23 "apps/marketing/package.json",23 "apps/marketing/package.json",
third-party-licenses.config.json+1-1
...@@ -137,7 +137,7 @@...@@ -137,7 +137,7 @@
137 "bundles": ["mobile"],137 "bundles": ["mobile"],
138 "license": "MIT",138 "license": "MIT",
139 "name": "react-native-uitextview",139 "name": "react-native-uitextview",
140 "noticeFile": "apps/mobile/modules/t3-markdown-text/LICENSE",140 "noticeFile": "apps/mobile/modules/t3-source-text/LICENSE",
141 "sourceUrl": "https://github.com/bluesky-social/react-native-uitextview",141 "sourceUrl": "https://github.com/bluesky-social/react-native-uitextview",
142 "version": "2.2.0"142 "version": "2.2.0"
143 },143 },
vite.config.ts+1-1
...@@ -393,7 +393,7 @@ export default defineConfig({...@@ -393,7 +393,7 @@ export default defineConfig({
393 "apps/mobile/src/lib/useMobileNavigationTheme.ts",393 "apps/mobile/src/lib/useMobileNavigationTheme.ts",
394 "apps/mobile/src/native/T3ComposerEditor.ios.tsx",394 "apps/mobile/src/native/T3ComposerEditor.ios.tsx",
395 "apps/mobile/src/native/T3ComposerEditor.native.tsx",395 "apps/mobile/src/native/T3ComposerEditor.native.tsx",
396 "apps/mobile/src/native/SelectableMarkdownText.android.tsx",396 "apps/mobile/src/native/SelectableMarkdownText.tsx",
397 ],397 ],
398 rules: {398 rules: {
399 "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }],399 "t3code/no-mobile-uniwind-theme-escape-hatches": ["error", { allowUniwindTheme: true }],