From 85fe62d4021dfabe14572b7a7ef55e3f4f1144f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 16 Jun 2026 09:12:32 -0700 Subject: [PATCH 1/2] Stage fff native binaries in desktop builds (#3109) --- apps/server/package.json | 2 +- patches/@ff-labs__fff-node@0.9.4.patch | 37 ++++++++++++++ pnpm-lock.yaml | 9 ++-- pnpm-workspace.yaml | 70 +++++++++++++------------- scripts/build-desktop-artifact.test.ts | 50 ++++++++++++++++++ scripts/build-desktop-artifact.ts | 65 +++++++++++++++++++++++- 6 files changed, 191 insertions(+), 42 deletions(-) create mode 100644 patches/@ff-labs__fff-node@0.9.4.patch diff --git a/apps/server/package.json b/apps/server/package.json index de012aada4d..01003d7c176 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -27,7 +27,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@ff-labs/fff-node": "^0.9.4", + "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/patches/@ff-labs__fff-node@0.9.4.patch b/patches/@ff-labs__fff-node@0.9.4.patch new file mode 100644 index 00000000000..2d0c16133eb --- /dev/null +++ b/patches/@ff-labs__fff-node@0.9.4.patch @@ -0,0 +1,37 @@ +diff --git a/dist/src/binary.js b/dist/src/binary.js +index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef10af75ca7 100644 +--- a/dist/src/binary.js ++++ b/dist/src/binary.js +@@ -7,7 +7,7 @@ + */ + import { existsSync, readFileSync } from "node:fs"; + import { createRequire } from "node:module"; +-import { dirname, join } from "node:path"; ++import { dirname, join, sep } from "node:path"; + import { fileURLToPath } from "node:url"; + import { getLibFilename, getNpmPackageName } from "./platform.js"; + /** +@@ -46,6 +46,14 @@ function getPackageDir() { + // Fallback: assume we're one level deep in src/ + return dirname(currentDir); + } ++function resolveUnpackedAsarPath(binaryPath) { ++ const asarSegment = `${sep}app.asar${sep}`; ++ if (!binaryPath.includes(asarSegment)) { ++ return binaryPath; ++ } ++ const unpackedPath = binaryPath.replace(asarSegment, `${sep}app.asar.unpacked${sep}`); ++ return existsSync(unpackedPath) ? unpackedPath : binaryPath; ++} + /** + * Check if the binary exists in any known location + */ +@@ -69,7 +77,7 @@ function resolveFromNpmPackage() { + const packageDir = dirname(packageJsonPath); + const binaryPath = join(packageDir, getLibFilename()); + if (existsSync(binaryPath)) { +- return binaryPath; ++ return resolveUnpackedAsarPath(binaryPath); + } + } + catch { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1bd60b2fc9..1fa241735ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ patchedDependencies: '@expo/metro-config@56.0.13': hash: 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 path: patches/@expo%2Fmetro-config@56.0.13.patch + '@ff-labs/fff-node@0.9.4': + hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 + path: patches/@ff-labs__fff-node@0.9.4.patch '@pierre/diffs@1.3.0-beta.4': hash: f5d41705ce94bbafc731d92e9cb1671db710df046dd135cb894e3e3e9164a75b path: patches/@pierre%2Fdiffs@1.3.0-beta.4.patch @@ -396,8 +399,8 @@ importers: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) '@ff-labs/fff-node': - specifier: ^0.9.4 - version: 0.9.4 + specifier: 0.9.4 + version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -11854,7 +11857,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.9.4': optional: true - '@ff-labs/fff-node@0.9.4': + '@ff-labs/fff-node@0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)': dependencies: ffi-rs: 1.3.2 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 605fe4df4aa..58f4b6e0dfe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,22 +1,11 @@ packages: - - "apps/*" - - "infra/*" - - "oxlint-plugin-t3code" - - "packages/*" - - "scripts" - -onlyBuiltDependencies: - - electron - - esbuild - - msgpackr-extract - - node-pty - - sharp - -ignoredBuiltDependencies: - - msw + - apps/* + - infra/* + - oxlint-plugin-t3code + - packages/* + - scripts catalog: - effect: 4.0.0-beta.78 "@effect/atom-react": 4.0.0-beta.78 "@effect/openapi-generator": 4.0.0-beta.78 "@effect/platform-bun": 4.0.0-beta.78 @@ -24,23 +13,33 @@ catalog: "@effect/platform-node-shared": 4.0.0-beta.78 "@effect/sql-pg": 4.0.0-beta.78 "@effect/sql-sqlite-bun": 4.0.0-beta.78 - "@effect/vitest": 4.0.0-beta.78 "@effect/tsgo": 0.13.2 + "@effect/vitest": 4.0.0-beta.78 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 "@pierre/diffs": 1.3.0-beta.4 - "@vitest/runner": 4.1.8 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260604.1 + "@vitest/runner": 4.1.8 + effect: 4.0.0-beta.78 jose: 6.2.2 typescript: ~6.0.3 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.24 vite: npm:@voidzero-dev/vite-plus-core@0.1.24 vite-plus: 0.1.24 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.24 yaml: ^2.9.0 + +ignoredBuiltDependencies: + - msw + +onlyBuiltDependencies: + - electron + - esbuild + - msgpackr-extract + - node-pty + - sharp + overrides: - # Clerk publishes wallet auth integrations as required dependencies. T3 Code does - # not support wallet auth, so keep that unused dependency tree out of installs. "@clerk/clerk-js>@base-org/account": "-" "@clerk/clerk-js>@coinbase/wallet-sdk": "-" "@clerk/clerk-js>@solana/wallet-adapter-base": "-" @@ -56,22 +55,13 @@ overrides: "@effect/vitest": "catalog:" "@effect/vitest>@vitest/runner": "-" "@effect/vitest>vitest": "-" - # Pinned to the version the patch in patchedDependencies targets — a fresh - # resolve (e.g. release-smoke regenerating the lockfile) must not drift to a - # newer release and orphan the patch (ERR_PNPM_UNUSED_PATCH). - "@expo/metro-config": "56.0.13" + "@expo/metro-config": 56.0.13 "@types/node": "catalog:" effect: "catalog:" vite: "catalog:" vitest: "catalog:" yaml: "catalog:" -peerDependencyRules: - allowAny: - - vite - - vitest - allowedVersions: - vite: "*" - vitest: "*" + packageExtensions: "@effect/vitest@*": dependencies: @@ -79,14 +69,22 @@ packageExtensions: peerDependenciesMeta: vitest: optional: true - "vite-plus@*": + vite-plus@*: dependencies: vite: "catalog:" + patchedDependencies: - # Keep non-browser consumers off the root entrypoint, which eagerly imports - # DOM-dependent renderers. These utility/type subpaths are safe for mobile. - "@pierre/diffs@1.3.0-beta.4": patches/@pierre%2Fdiffs@1.3.0-beta.4.patch "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch + "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch + "@pierre/diffs@1.3.0-beta.4": patches/@pierre%2Fdiffs@1.3.0-beta.4.patch effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch + +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: "*" + vitest: "*" diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 974f3d036f0..8135f7e259d 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -6,8 +6,11 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { + createStageWorkspaceConfig, createStagePnpmConfig, + DESKTOP_ASAR_UNPACK, resolveDesktopRuntimeDependencies, + resolveFffNativeDependencies, resolveBuildOptions, resolveDesktopBuildIconAssets, resolveDesktopProductName, @@ -15,6 +18,7 @@ import { resolveGitHubPublishConfig, resolveMockUpdateServerPort, resolveMockUpdateServerUrl, + STAGE_INSTALL_ARGS, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -114,17 +118,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { createStagePnpmConfig( { "@expo/metro-config@56.0.13": "patches/@expo%2Fmetro-config@56.0.13.patch", + "@ff-labs/fff-node@0.9.4": "patches/@ff-labs__fff-node@0.9.4.patch", "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch", "alchemy@2.0.0-beta.49": "patches/alchemy@2.0.0-beta.49.patch", "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch", }, { + "@ff-labs/fff-node": "0.9.4", "@pierre/diffs": "1.1.20", effect: "4.0.0-beta.73", }, ), { patchedDependencies: { + "@ff-labs/fff-node@0.9.4": "patches/@ff-labs__fff-node@0.9.4.patch", "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch", "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch", }, @@ -142,6 +149,49 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); }); + it("installs optional native dependencies for the target desktop architecture", () => { + assert.deepStrictEqual(STAGE_INSTALL_ARGS, ["install", "--prod"]); + assert.deepStrictEqual(createStageWorkspaceConfig("mac", "x64"), { + supportedArchitectures: { + os: ["darwin"], + cpu: ["x64"], + }, + }); + assert.deepStrictEqual(createStageWorkspaceConfig("win", "arm64"), { + supportedArchitectures: { + os: ["win32"], + cpu: ["arm64"], + }, + }); + assert.deepStrictEqual(createStageWorkspaceConfig("mac", "universal"), { + supportedArchitectures: { + os: ["darwin"], + cpu: ["arm64", "x64"], + }, + }); + }); + + it("unpacks the fff shared library for filesystem and FFI access", () => { + assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); + }); + + it("promotes target fff binaries to direct staged dependencies", () => { + assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { + "@ff-labs/fff-bin-darwin-arm64": "0.9.4", + }); + assert.deepStrictEqual(resolveFffNativeDependencies("mac", "universal", "0.9.4"), { + "@ff-labs/fff-bin-darwin-arm64": "0.9.4", + "@ff-labs/fff-bin-darwin-x64": "0.9.4", + }); + assert.deepStrictEqual(resolveFffNativeDependencies("win", "x64", "0.9.4"), { + "@ff-labs/fff-bin-win32-x64": "0.9.4", + }); + assert.deepStrictEqual(resolveFffNativeDependencies("linux", "arm64", "0.9.4"), { + "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", + "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", + }); + }); + it("falls back to the default mock update port when the configured port is blank", () => { assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000"); assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f5785f904aa..6b519b1d4e3 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -38,11 +38,19 @@ const WorkspaceConfig = Schema.Struct({ }); type WorkspaceConfig = typeof WorkspaceConfig.Type; +const StageWorkspaceConfig = Schema.Struct({ + supportedArchitectures: Schema.Struct({ + os: Schema.Array(Schema.String), + cpu: Schema.Array(Schema.String), + }), +}); + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); +const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { const fs = yield* FileSystem.FileSystem; @@ -282,6 +290,47 @@ interface StagePackageJson { }; } +export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; +export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; + +export function resolveFffNativeDependencies( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, + version: string, +): Record { + const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch]; + + if (platform === "mac") { + return Object.fromEntries( + architectures.map((architecture) => [`@ff-labs/fff-bin-darwin-${architecture}`, version]), + ); + } + + if (platform === "win") { + return Object.fromEntries( + architectures.map((architecture) => [`@ff-labs/fff-bin-win32-${architecture}`, version]), + ); + } + + return Object.fromEntries( + architectures.flatMap((architecture) => + ["gnu", "musl"].map((libc) => [`@ff-labs/fff-bin-linux-${architecture}-${libc}`, version]), + ), + ); +} + +export function createStageWorkspaceConfig( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +): typeof StageWorkspaceConfig.Type { + return { + supportedArchitectures: { + os: [platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"], + cpu: arch === "universal" ? ["arm64", "x64"] : [arch], + }, + }; +} + export function createStagePnpmConfig( patchedDependencies: Record, dependencies: Record, @@ -702,6 +751,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( appId: "com.t3tools.t3code", productName: resolveDesktopProductName(version), artifactName: "T3-Code-${version}-${arch}.${ext}", + asarUnpack: [...DESKTOP_ASAR_UNPACK], directories: { buildResources: "apps/desktop/resources", }, @@ -909,6 +959,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stageDependencies = { ...resolvedServerDependencies, ...resolvedDesktopRuntimeDependencies, + ...resolveFffNativeDependencies( + options.platform, + options.arch, + serverPackageJson.dependencies["@ff-labs/fff-node"], + ), }; const stagePnpmConfig = createStagePnpmConfig(workspacePatchedDependencies, stageDependencies); const stagePackageJson: StagePackageJson = { @@ -939,19 +994,25 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stagePackageJsonString = yield* encodeJsonString(stagePackageJson); yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`); + const stageWorkspaceConfig = createStageWorkspaceConfig(options.platform, options.arch); + const stageWorkspaceConfigString = yield* encodeStageWorkspaceConfig(stageWorkspaceConfig); + yield* fs.writeFileString( + path.join(stageAppDir, "pnpm-workspace.yaml"), + stageWorkspaceConfigString, + ); if (Object.keys(workspacePatchedDependencies).length > 0) { yield* fs.copy(path.join(repoRoot, "patches"), path.join(stageAppDir, "patches")); } yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); - const installCommand = yield* resolveSpawnCommand("vp", ["install", "--prod", "--no-optional"]); + const installCommand = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]); yield* runCommand( ChildProcess.make(installCommand.command, installCommand.args, { cwd: stageAppDir, shell: installCommand.shell, }), - { label: "vp install --prod --no-optional", verbose: options.verbose }, + { label: "vp install --prod", verbose: options.verbose }, ); // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") From 689a882045384f9f20e0c016b27fab20b7d76969 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 16 Jun 2026 09:31:57 -0700 Subject: [PATCH 2/2] [codex] Add native mobile composer and markdown (#3101) Co-authored-by: codex --- apps/mobile/clerk-theme.json | 4 +- apps/mobile/global.css | 20 +- .../mobile/modules/t3-composer-editor/LICENSE | 21 + .../expo-module.config.json | 6 + .../ios/T3ComposerEditor.podspec | 21 + .../ios/T3ComposerEditorModule.swift | 71 ++ .../ios/T3ComposerEditorView.swift | 819 ++++++++++++++ apps/mobile/modules/t3-markdown-text/LICENSE | 20 + .../t3-markdown-text/T3MarkdownText.podspec | 25 + .../modules/t3-markdown-text/UPSTREAM.md | 12 + .../assets/file-icons/pierre_agents.png | Bin 0 -> 2500 bytes .../assets/file-icons/pierre_astro.png | Bin 0 -> 1975 bytes .../assets/file-icons/pierre_babel.png | Bin 0 -> 2899 bytes .../assets/file-icons/pierre_bash.png | Bin 0 -> 1944 bytes .../assets/file-icons/pierre_biome.png | Bin 0 -> 1865 bytes .../assets/file-icons/pierre_bootstrap.png | Bin 0 -> 1949 bytes .../assets/file-icons/pierre_browserslist.png | Bin 0 -> 3352 bytes .../assets/file-icons/pierre_bun.png | Bin 0 -> 2620 bytes .../assets/file-icons/pierre_c.png | Bin 0 -> 2019 bytes .../assets/file-icons/pierre_claude.png | Bin 0 -> 3782 bytes .../assets/file-icons/pierre_cpp.png | Bin 0 -> 2019 bytes .../assets/file-icons/pierre_css.png | Bin 0 -> 2806 bytes .../assets/file-icons/pierre_database.png | Bin 0 -> 2175 bytes .../assets/file-icons/pierre_default.png | Bin 0 -> 1054 bytes .../assets/file-icons/pierre_docker.png | Bin 0 -> 1799 bytes .../assets/file-icons/pierre_eslint.png | Bin 0 -> 2689 bytes .../assets/file-icons/pierre_font.png | Bin 0 -> 2463 bytes .../assets/file-icons/pierre_git.png | Bin 0 -> 1584 bytes .../assets/file-icons/pierre_go.png | Bin 0 -> 2104 bytes .../assets/file-icons/pierre_graphql.png | Bin 0 -> 2691 bytes .../assets/file-icons/pierre_html.png | Bin 0 -> 2130 bytes .../assets/file-icons/pierre_image.png | Bin 0 -> 1776 bytes .../assets/file-icons/pierre_javascript.png | Bin 0 -> 2271 bytes .../assets/file-icons/pierre_json.png | Bin 0 -> 1620 bytes .../assets/file-icons/pierre_markdown.png | Bin 0 -> 870 bytes .../assets/file-icons/pierre_mcp.png | Bin 0 -> 4074 bytes .../assets/file-icons/pierre_nextjs.png | Bin 0 -> 1434 bytes .../assets/file-icons/pierre_npm.png | Bin 0 -> 534 bytes .../assets/file-icons/pierre_oxc.png | Bin 0 -> 2436 bytes .../assets/file-icons/pierre_package.png | Bin 0 -> 472 bytes .../assets/file-icons/pierre_pnpm.png | Bin 0 -> 620 bytes .../assets/file-icons/pierre_postcss.png | Bin 0 -> 3765 bytes .../assets/file-icons/pierre_prettier.png | Bin 0 -> 497 bytes .../assets/file-icons/pierre_python.png | Bin 0 -> 2236 bytes .../assets/file-icons/pierre_react.png | Bin 0 -> 3993 bytes .../assets/file-icons/pierre_readme.png | Bin 0 -> 1220 bytes .../assets/file-icons/pierre_ruby.png | Bin 0 -> 2115 bytes .../assets/file-icons/pierre_rust.png | Bin 0 -> 3548 bytes .../assets/file-icons/pierre_sass.png | Bin 0 -> 2792 bytes .../assets/file-icons/pierre_stylelint.png | Bin 0 -> 2103 bytes .../assets/file-icons/pierre_svelte.png | Bin 0 -> 3335 bytes .../assets/file-icons/pierre_svg.png | Bin 0 -> 1453 bytes .../assets/file-icons/pierre_svgo.png | Bin 0 -> 3222 bytes .../assets/file-icons/pierre_swift.png | Bin 0 -> 2663 bytes .../assets/file-icons/pierre_table.png | Bin 0 -> 1195 bytes .../assets/file-icons/pierre_tailwind.png | Bin 0 -> 1258 bytes .../assets/file-icons/pierre_terraform.png | Bin 0 -> 1939 bytes .../assets/file-icons/pierre_text.png | Bin 0 -> 1142 bytes .../assets/file-icons/pierre_tsconfig.png | Bin 0 -> 2023 bytes .../assets/file-icons/pierre_typescript.png | Bin 0 -> 2103 bytes .../assets/file-icons/pierre_vite.png | Bin 0 -> 1556 bytes .../assets/file-icons/pierre_vscode.png | Bin 0 -> 2387 bytes .../assets/file-icons/pierre_vue.png | Bin 0 -> 1541 bytes .../assets/file-icons/pierre_wasm.png | Bin 0 -> 1939 bytes .../assets/file-icons/pierre_webpack.png | Bin 0 -> 3337 bytes .../assets/file-icons/pierre_yml.png | Bin 0 -> 1039 bytes .../assets/file-icons/pierre_zig.png | Bin 0 -> 2199 bytes .../assets/file-icons/pierre_zip.png | Bin 0 -> 1081 bytes apps/mobile/modules/t3-markdown-text/index.ts | 27 + .../t3-markdown-text/ios/T3MarkdownText.h | 13 + .../t3-markdown-text/ios/T3MarkdownText.mm | 688 ++++++++++++ .../ios/T3MarkdownTextComponentDescriptor.h | 13 + .../ios/T3MarkdownTextManager.mm | 36 + .../t3-markdown-text/ios/T3MarkdownTextRun.h | 24 + .../t3-markdown-text/ios/T3MarkdownTextRun.mm | 72 ++ .../T3MarkdownTextRunComponentDescriptor.h | 13 + .../ios/T3MarkdownTextRunShadowNode.cpp | 6 + .../ios/T3MarkdownTextRunShadowNode.h | 16 + .../ios/T3MarkdownTextShadowNode.h | 77 ++ .../ios/T3MarkdownTextShadowNode.mm | 269 +++++ .../modules/t3-markdown-text/package.json | 51 + .../t3-markdown-text/react-native.config.js | 10 + .../scripts/sync-pierre-file-icons.mjs | 133 +++ .../t3-markdown-text/src/CopyTextButton.tsx | 73 ++ .../src/MarkdownTextPrimitive.tsx | 109 ++ .../src/NativeMarkdownBlock.ios.tsx | 647 ++++++++++++ .../src/NativeMarkdownSelectableText.ios.tsx | 257 +++++ .../src/SelectableMarkdownText.ios.tsx | 80 ++ .../src/SelectableMarkdownText.tsx | 13 + .../src/SelectableMarkdownText.types.ts | 46 + .../src/T3MarkdownTextNativeComponent.ts | 55 + .../src/T3MarkdownTextRunNativeComponent.ts | 51 + .../src/markdownFileIcons.generated.ts | 62 ++ .../t3-markdown-text/src/markdownFileIcons.ts | 8 + .../t3-markdown-text/src/markdownLinks.ts | 349 ++++++ .../src/nativeMarkdownText.ts | 751 +++++++++++++ .../modules/t3-markdown-text/src/util.ts | 62 ++ apps/mobile/package.json | 6 +- apps/mobile/src/app/_layout.tsx | 40 +- apps/mobile/src/app/connections/_layout.tsx | 7 +- apps/mobile/src/app/index.tsx | 12 +- apps/mobile/src/app/new/_layout.tsx | 7 +- apps/mobile/src/app/settings/_layout.tsx | 24 +- apps/mobile/src/app/settings/auth.tsx | 33 + apps/mobile/src/app/settings/index.tsx | 12 +- apps/mobile/src/app/settings/waitlist.tsx | 25 +- apps/mobile/src/components/ComposerEditor.tsx | 6 + apps/mobile/src/components/CopyTextButton.tsx | 68 ++ .../src/components/GlassSafeAreaView.tsx | 13 +- apps/mobile/src/components/GlassSurface.tsx | 33 +- .../mobile/src/components/PierreEntryIcon.tsx | 25 + apps/mobile/src/components/ProviderIcon.tsx | 2 +- .../cloud/ClerkSettingsSheetDetent.tsx | 44 + .../features/cloud/useNativeClerkAuthModal.ts | 134 --- apps/mobile/src/features/home/HomeScreen.tsx | 11 +- .../review/shikiReviewHighlighter.test.ts | 21 +- .../features/review/shikiReviewHighlighter.ts | 10 + .../threads/ComposerCommandPopover.tsx | 11 +- .../features/threads/NewTaskDraftScreen.tsx | 45 +- .../src/features/threads/ThreadComposer.tsx | 167 ++- .../features/threads/ThreadDetailScreen.tsx | 91 +- .../src/features/threads/ThreadFeed.tsx | 995 ++++++++++++++---- .../features/threads/ThreadRouteScreen.tsx | 5 +- .../threads/new-task-flow-provider.tsx | 10 + apps/mobile/src/lib/composerImages.test.ts | 94 ++ apps/mobile/src/lib/composerImages.ts | 37 +- .../mobile/src/lib/copyTextWithHaptic.test.ts | 34 + apps/mobile/src/lib/copyTextWithHaptic.ts | 7 + apps/mobile/src/lib/markdownLinks.test.ts | 67 ++ .../mobile/src/lib/nativeMarkdownText.test.ts | 734 +++++++++++++ apps/mobile/src/lib/threadActivity.test.ts | 222 +++- apps/mobile/src/lib/threadActivity.ts | 379 ++++++- apps/mobile/src/lib/threadFeedLayout.test.ts | 32 + apps/mobile/src/lib/threadFeedLayout.ts | 22 + .../src/native/SelectableMarkdownText.ios.tsx | 21 + .../src/native/SelectableMarkdownText.tsx | 16 + .../src/native/T3ComposerEditor.ios.tsx | 180 ++++ apps/mobile/src/native/T3ComposerEditor.tsx | 65 ++ .../src/native/T3ComposerEditor.types.ts | 38 + apps/mobile/src/widgets/AgentActivity.tsx | 6 +- apps/web/package.json | 4 +- apps/web/src/composer-editor-mentions.ts | 102 +- infra/relay/package.json | 2 +- packages/shared/package.json | 4 + .../shared/src/composerInlineTokens.test.ts | 84 ++ packages/shared/src/composerInlineTokens.ts | 118 +++ pnpm-lock.yaml | 109 +- scripts/release-smoke.ts | 1 + 148 files changed, 8550 insertions(+), 715 deletions(-) create mode 100644 apps/mobile/modules/t3-composer-editor/LICENSE create mode 100644 apps/mobile/modules/t3-composer-editor/expo-module.config.json create mode 100644 apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec create mode 100644 apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift create mode 100644 apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift create mode 100644 apps/mobile/modules/t3-markdown-text/LICENSE create mode 100644 apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec create mode 100644 apps/mobile/modules/t3-markdown-text/UPSTREAM.md create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_astro.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_babel.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bun.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_c.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_database.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_eslint.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_font.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_git.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_graphql.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_html.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_javascript.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_json.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_markdown.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_oxc.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_package.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_react.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_readme.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_ruby.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_sass.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_stylelint.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svg.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svgo.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_swift.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_table.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vscode.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vue.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_yml.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png create mode 100644 apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png create mode 100644 apps/mobile/modules/t3-markdown-text/index.ts create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h create mode 100644 apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm create mode 100644 apps/mobile/modules/t3-markdown-text/package.json create mode 100644 apps/mobile/modules/t3-markdown-text/react-native.config.js create mode 100644 apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs create mode 100644 apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx create mode 100644 apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx create mode 100644 apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx create mode 100644 apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx create mode 100644 apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx create mode 100644 apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx create mode 100644 apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts create mode 100644 apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts create mode 100644 apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts create mode 100644 apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts create mode 100644 apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts create mode 100644 apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts create mode 100644 apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts create mode 100644 apps/mobile/modules/t3-markdown-text/src/util.ts create mode 100644 apps/mobile/src/app/settings/auth.tsx create mode 100644 apps/mobile/src/components/ComposerEditor.tsx create mode 100644 apps/mobile/src/components/CopyTextButton.tsx create mode 100644 apps/mobile/src/components/PierreEntryIcon.tsx create mode 100644 apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx delete mode 100644 apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts create mode 100644 apps/mobile/src/lib/composerImages.test.ts create mode 100644 apps/mobile/src/lib/copyTextWithHaptic.test.ts create mode 100644 apps/mobile/src/lib/copyTextWithHaptic.ts create mode 100644 apps/mobile/src/lib/markdownLinks.test.ts create mode 100644 apps/mobile/src/lib/nativeMarkdownText.test.ts create mode 100644 apps/mobile/src/lib/threadFeedLayout.test.ts create mode 100644 apps/mobile/src/lib/threadFeedLayout.ts create mode 100644 apps/mobile/src/native/SelectableMarkdownText.ios.tsx create mode 100644 apps/mobile/src/native/SelectableMarkdownText.tsx create mode 100644 apps/mobile/src/native/T3ComposerEditor.ios.tsx create mode 100644 apps/mobile/src/native/T3ComposerEditor.tsx create mode 100644 apps/mobile/src/native/T3ComposerEditor.types.ts create mode 100644 packages/shared/src/composerInlineTokens.test.ts create mode 100644 packages/shared/src/composerInlineTokens.ts diff --git a/apps/mobile/clerk-theme.json b/apps/mobile/clerk-theme.json index 52941785f3e..119927a04d6 100644 --- a/apps/mobile/clerk-theme.json +++ b/apps/mobile/clerk-theme.json @@ -13,7 +13,7 @@ "neutral": "#F5F5F5", "border": "#E5E5EA", "ring": "#A3A3A3", - "muted": "#F5F5F5", + "muted": "#F2F2F7", "shadow": "#000000" }, "darkColors": { @@ -30,7 +30,7 @@ "neutral": "#1C1C1C", "border": "#2A2A2A", "ring": "#525252", - "muted": "#1C1C1C", + "muted": "#0E0E0E", "shadow": "#000000" }, "design": { diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 4642879451a..0fbf4fb3c9d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -18,7 +18,7 @@ --color-foreground: #262626; --color-foreground-secondary: #525252; --color-foreground-muted: #737373; - --color-foreground-tertiary: #a3a3a3; + --color-foreground-tertiary: #8e8e93; /* Borders & separators */ --color-border: rgba(0, 0, 0, 0.08); @@ -28,6 +28,9 @@ /* Subtle backgrounds (badges, pills, overlays) */ --color-subtle: rgba(0, 0, 0, 0.04); --color-subtle-strong: rgba(0, 0, 0, 0.08); + --color-inline-skill-background: rgba(217, 70, 239, 0.12); + --color-inline-skill-border: rgba(217, 70, 239, 0.25); + --color-inline-skill-foreground: #a21caf; /* Primary action */ --color-primary: #262626; @@ -58,6 +61,8 @@ /* Header / glass chrome */ --color-header: rgba(255, 255, 255, 0.97); --color-header-border: rgba(0, 0, 0, 0.06); + --color-glass-surface: rgba(255, 255, 255, 0.72); + --color-glass-tint: rgba(255, 255, 255, 0.18); /* StatusBar */ --color-status-bar: #f2f2f7; @@ -105,8 +110,8 @@ /* Text */ --color-foreground: #f5f5f5; --color-foreground-secondary: #a3a3a3; - --color-foreground-muted: #737373; - --color-foreground-tertiary: #525252; + --color-foreground-muted: #8e8e93; + --color-foreground-tertiary: #636366; /* Borders & separators */ --color-border: rgba(255, 255, 255, 0.06); @@ -116,6 +121,9 @@ /* Subtle backgrounds (badges, pills, overlays) */ --color-subtle: rgba(255, 255, 255, 0.04); --color-subtle-strong: rgba(255, 255, 255, 0.08); + --color-inline-skill-background: rgba(217, 70, 239, 0.12); + --color-inline-skill-border: rgba(217, 70, 239, 0.25); + --color-inline-skill-foreground: #f0abfc; /* Primary action */ --color-primary: #f5f5f5; @@ -136,16 +144,18 @@ /* Inputs */ --color-input: #141414; --color-input-border: rgba(255, 255, 255, 0.08); - --color-placeholder: #737373; + --color-placeholder: #8e8e93; /* Icons */ --color-icon: #f5f5f5; --color-icon-muted: #a3a3a3; - --color-icon-subtle: #737373; + --color-icon-subtle: #8e8e93; /* Header / glass chrome */ --color-header: rgba(10, 10, 10, 0.97); --color-header-border: rgba(255, 255, 255, 0.06); + --color-glass-surface: rgba(23, 23, 23, 0.78); + --color-glass-tint: rgba(23, 23, 23, 0.24); /* StatusBar */ --color-status-bar: #0a0a0a; diff --git a/apps/mobile/modules/t3-composer-editor/LICENSE b/apps/mobile/modules/t3-composer-editor/LICENSE new file mode 100644 index 00000000000..30b20e3b5f0 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-composer-editor/expo-module.config.json b/apps/mobile/modules/t3-composer-editor/expo-module.config.json new file mode 100644 index 00000000000..0d6384cd91a --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["T3ComposerEditorModule"] + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec new file mode 100644 index 00000000000..57c09fa9535 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'T3ComposerEditor' + s.version = '1.0.0' + s.summary = 'Native attributed composer editor for T3 Code mobile.' + s.description = 'UIKit-backed rich text composer with atomic skill and file tokens.' + s.author = 'T3 Tools' + s.homepage = 'https://t3tools.com' + s.platforms = { + :ios => '16.4', + } + s.source = { :path => '.' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift new file mode 100644 index 00000000000..5d3b33094cb --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -0,0 +1,71 @@ +import ExpoModulesCore + +public class T3ComposerEditorModule: Module { + public func definition() -> ModuleDefinition { + Name("T3ComposerEditor") + + View(T3ComposerEditorView.self) { + Prop("value") { (view: T3ComposerEditorView, value: String) in + view.setValue(value) + } + Prop("tokensJson") { (view: T3ComposerEditorView, tokensJson: String) in + view.setTokensJson(tokensJson) + } + Prop("selectionJson") { (view: T3ComposerEditorView, selectionJson: String) in + view.setSelectionJson(selectionJson) + } + Prop("themeJson") { (view: T3ComposerEditorView, themeJson: String) in + view.setThemeJson(themeJson) + } + Prop("placeholder") { (view: T3ComposerEditorView, placeholder: String) in + view.setPlaceholder(placeholder) + } + Prop("fontFamily") { (view: T3ComposerEditorView, fontFamily: String) in + view.setFontFamily(fontFamily) + } + Prop("fontSize") { (view: T3ComposerEditorView, fontSize: Double) in + view.setFontSize(CGFloat(fontSize)) + } + Prop("lineHeight") { (view: T3ComposerEditorView, lineHeight: Double) in + view.setLineHeight(CGFloat(lineHeight)) + } + Prop("contentInsetVertical") { (view: T3ComposerEditorView, contentInsetVertical: Double) in + view.setContentInsetVertical(CGFloat(contentInsetVertical)) + } + Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in + view.setEditable(editable) + } + Prop("scrollEnabled") { (view: T3ComposerEditorView, scrollEnabled: Bool) in + view.setScrollEnabled(scrollEnabled) + } + Prop("autoFocus") { (view: T3ComposerEditorView, autoFocus: Bool) in + view.setAutoFocus(autoFocus) + } + Prop("autoCorrect") { (view: T3ComposerEditorView, autoCorrect: Bool) in + view.setAutoCorrect(autoCorrect) + } + Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in + view.setSpellCheck(spellCheck) + } + + Events( + "onComposerChange", + "onComposerSelectionChange", + "onComposerFocus", + "onComposerBlur", + "onComposerPasteImages", + "onComposerContentSizeChange" + ) + + AsyncFunction("focus") { (view: T3ComposerEditorView) in + view.focusEditor() + } + AsyncFunction("blur") { (view: T3ComposerEditorView) in + view.blurEditor() + } + AsyncFunction("setSelection") { (view: T3ComposerEditorView, start: Int, end: Int) in + view.setSelection(start: start, end: end) + } + } + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift new file mode 100644 index 00000000000..a88acbc31f7 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -0,0 +1,819 @@ +import ExpoModulesCore +import UIKit + +private struct ComposerTokenPayload: Decodable { + let type: String + let source: String + let label: String + let iconUri: String? + let start: Int + let end: Int +} + +private struct ComposerSelectionPayload: Decodable { + let start: Int + let end: Int +} + +private struct ComposerThemePayload: Decodable { + let text: String + let placeholder: String + let chipBackground: String + let chipBorder: String + let chipText: String + let skillBackground: String + let skillBorder: String + let skillText: String + let fileTint: String +} + +private struct ComposerChipStyle { + let tint: UIColor + let backgroundColor: UIColor + let borderColor: UIColor + let textColor: UIColor +} + +private final class ComposerTextAttachment: NSTextAttachment { + let source: String + + init(source: String, image: UIImage, size: CGSize, baselineOffset: CGFloat) { + self.source = source + super.init(data: nil, ofType: nil) + self.image = image + bounds = CGRect(x: 0, y: baselineOffset, width: size.width, height: size.height) + } + + required init?(coder: NSCoder) { + nil + } +} + +private final class ComposerTextView: UITextView { + private static let pastedImageDirectoryName = "t3-composer-paste" + private static let stalePastedImageAge: TimeInterval = 60 * 60 + + var onPasteImages: (([String]) -> Void)? + var onAttributedMutation: (() -> Void)? + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(paste(_:)) { + let pasteboard = UIPasteboard.general + if pasteboard.hasImages || + pasteboard.itemProviders.contains(where: { + $0.canLoadObject(ofClass: UIImage.self) + }) { + return true + } + } + return super.canPerformAction(action, withSender: sender) + } + + override func paste(_ sender: Any?) { + let pasteboard = UIPasteboard.general + let imageProviders = pasteboard.itemProviders.filter { + $0.canLoadObject(ofClass: UIImage.self) + } + if !imageProviders.isEmpty { + loadImages(from: imageProviders) + return + } + + let images = pasteboard.images ?? [] + if !images.isEmpty { + let urls = images.compactMap(Self.writeTemporaryImage) + if !urls.isEmpty { + onPasteImages?(urls) + return + } + } + super.paste(sender) + } + + override func deleteBackward() { + guard selectedRange.length == 0, selectedRange.location > 0 else { + super.deleteBackward() + return + } + + let previousOffset = selectedRange.location - 1 + if textStorage.attribute(.attachment, at: previousOffset, effectiveRange: nil) + is ComposerTextAttachment { + replaceDisplayRange(NSRange(location: previousOffset, length: 1)) + return + } + + super.deleteBackward() + } + + private func replaceDisplayRange(_ range: NSRange) { + guard let start = position(from: beginningOfDocument, offset: range.location), + let end = position(from: start, offset: range.length), + let textRange = textRange(from: start, to: end) else { + return + } + replace(textRange, withText: "") + } + + private func loadImages(from providers: [NSItemProvider]) { + let group = DispatchGroup() + let lock = NSLock() + var images = [UIImage?](repeating: nil, count: providers.count) + + for (index, provider) in providers.enumerated() { + group.enter() + provider.loadObject(ofClass: UIImage.self) { object, _ in + defer { group.leave() } + guard let image = object as? UIImage else { + return + } + lock.lock() + images[index] = image + lock.unlock() + } + } + + group.notify(queue: .main) { [weak self] in + let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage) + if !urls.isEmpty { + self?.onPasteImages?(urls) + } + } + } + + override func copy(_ sender: Any?) { + guard selectedRange.length > 0 else { + return super.copy(sender) + } + UIPasteboard.general.string = serializedText(in: selectedRange) + } + + override func cut(_ sender: Any?) { + guard isEditable, selectedRange.length > 0 else { + return super.cut(sender) + } + copy(sender) + textStorage.replaceCharacters(in: selectedRange, with: "") + selectedRange = NSRange(location: selectedRange.location, length: 0) + onAttributedMutation?() + } + + func serializedText() -> String { + serializedText(in: NSRange(location: 0, length: attributedText.length)) + } + + func serializedText(in range: NSRange) -> String { + guard range.length > 0 else { + return "" + } + + let source = NSMutableString() + let nsString = attributedText.string as NSString + var cursor = range.location + let end = NSMaxRange(range) + attributedText.enumerateAttribute(.attachment, in: range) { value, attachmentRange, _ in + if attachmentRange.location > cursor { + source.append( + nsString.substring( + with: NSRange(location: cursor, length: attachmentRange.location - cursor) + ) + ) + } + if let attachment = value as? ComposerTextAttachment { + source.append(attachment.source) + } else { + source.append(nsString.substring(with: attachmentRange)) + } + cursor = NSMaxRange(attachmentRange) + } + if cursor < end { + source.append(nsString.substring(with: NSRange(location: cursor, length: end - cursor))) + } + return source as String + } + + func sourceOffset(forDisplayOffset displayOffset: Int) -> Int { + let boundedOffset = max(0, min(attributedText.length, displayOffset)) + if boundedOffset == 0 { + return 0 + } + + var sourceOffset = 0 + let range = NSRange(location: 0, length: boundedOffset) + attributedText.enumerateAttribute(.attachment, in: range) { value, attributeRange, _ in + if let attachment = value as? ComposerTextAttachment { + sourceOffset += (attachment.source as NSString).length + } else { + sourceOffset += attributeRange.length + } + } + return sourceOffset + } + + private static func writeTemporaryImage(_ image: UIImage) -> String? { + guard let data = image.pngData() else { + return nil + } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(pastedImageDirectoryName, isDirectory: true) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + removeStaleTemporaryImages(in: directory) + let url = directory.appendingPathComponent("\(UUID().uuidString).png") + try data.write(to: url, options: .atomic) + return url.absoluteString + } catch { + return nil + } + } + + private static func removeStaleTemporaryImages(in directory: URL) { + let cutoff = Date().addingTimeInterval(-stalePastedImageAge) + guard let urls = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + for url in urls { + guard + let values = try? url.resourceValues( + forKeys: [.contentModificationDateKey, .isRegularFileKey] + ), + values.isRegularFile == true, + let modifiedAt = values.contentModificationDate, + modifiedAt < cutoff + else { + continue + } + try? FileManager.default.removeItem(at: url) + } + } +} + +public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { + private let textView = ComposerTextView() + private let placeholderLabel = UILabel() + private var value = "" + private var tokensJson = "[]" + private var tokens: [ComposerTokenPayload] = [] + private var requestedSelection: ComposerSelectionPayload? + private var theme = ComposerThemePayload( + text: "#262626", + placeholder: "#8e8e93", + chipBackground: "#f2f2f7", + chipBorder: "#dedee3", + chipText: "#262626", + skillBackground: "#f9e8fb", + skillBorder: "#e5a6eb", + skillText: "#a21caf", + fileTint: "#737373" + ) + private var fontFamily = "DMSans_400Regular" + private var fontSize: CGFloat = 15 + private var lineHeight: CGFloat = 22 + private var contentInsetVertical: CGFloat = 0 + private var shouldAutoFocus = false + private var didAutoFocus = false + private var isApplyingControlledValue = false + private var lastContentSize = CGSize.zero + private var iconImages: [String: UIImage] = [:] + private var pendingIconUris = Set() + private var tokensNeedRebuild = false + + let onComposerChange = EventDispatcher() + let onComposerSelectionChange = EventDispatcher() + let onComposerFocus = EventDispatcher() + let onComposerBlur = EventDispatcher() + let onComposerPasteImages = EventDispatcher() + let onComposerContentSizeChange = EventDispatcher() + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + clipsToBounds = false + textView.delegate = self + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.keyboardDismissMode = .interactive + textView.alwaysBounceVertical = false + textView.showsVerticalScrollIndicator = true + textView.adjustsFontForContentSizeCategory = true + textView.onPasteImages = { [weak self] urls in + self?.onComposerPasteImages(["uris": urls]) + } + textView.onAttributedMutation = { [weak self] in + self?.emitTextChange() + } + addSubview(textView) + + placeholderLabel.numberOfLines = 0 + placeholderLabel.adjustsFontForContentSizeCategory = true + addSubview(placeholderLabel) + applyTypography() + applyTheme() + } + + public override func layoutSubviews() { + super.layoutSubviews() + textView.frame = bounds + let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding + let placeholderY = textView.textContainerInset.top + let placeholderWidth = max( + 0, + bounds.width - placeholderX - textView.textContainerInset.right - + textView.textContainer.lineFragmentPadding + ) + placeholderLabel.frame = CGRect( + x: placeholderX, + y: placeholderY, + width: placeholderWidth, + height: max(lineHeight, placeholderLabel.font.lineHeight) + ) + emitContentSizeIfNeeded() + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil, shouldAutoFocus, !didAutoFocus else { + return + } + didAutoFocus = true + DispatchQueue.main.async { [weak self] in + self?.textView.becomeFirstResponder() + } + } + + func setValue(_ value: String) { + self.value = value + applyControlledDocument(force: tokensNeedRebuild) + if tokensMatchCurrentValue() { + tokensNeedRebuild = false + } + } + + func setTokensJson(_ tokensJson: String) { + guard self.tokensJson != tokensJson else { + return + } + self.tokensJson = tokensJson + tokens = decode([ComposerTokenPayload].self, from: tokensJson) ?? [] + tokensNeedRebuild = true + applyControlledDocument(force: true) + if tokensMatchCurrentValue() { + tokensNeedRebuild = false + } + } + + func setSelectionJson(_ selectionJson: String) { + requestedSelection = decode(ComposerSelectionPayload.self, from: selectionJson) + applyRequestedSelection() + } + + func setThemeJson(_ themeJson: String) { + guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else { + return + } + theme = nextTheme + applyTheme() + applyControlledDocument(force: true) + } + + func setPlaceholder(_ placeholder: String) { + placeholderLabel.text = placeholder + setNeedsLayout() + } + + func setFontFamily(_ fontFamily: String) { + self.fontFamily = fontFamily + applyTypography() + applyControlledDocument(force: true) + } + + func setFontSize(_ fontSize: CGFloat) { + self.fontSize = fontSize + applyTypography() + applyControlledDocument(force: true) + } + + func setLineHeight(_ lineHeight: CGFloat) { + self.lineHeight = lineHeight + applyTypography() + applyControlledDocument(force: true) + } + + func setContentInsetVertical(_ contentInsetVertical: CGFloat) { + self.contentInsetVertical = contentInsetVertical + textView.textContainerInset = UIEdgeInsets( + top: contentInsetVertical, + left: 0, + bottom: contentInsetVertical, + right: 0 + ) + setNeedsLayout() + } + + func setEditable(_ editable: Bool) { + textView.isEditable = editable + } + + func setScrollEnabled(_ scrollEnabled: Bool) { + textView.isScrollEnabled = scrollEnabled + } + + func setAutoFocus(_ autoFocus: Bool) { + shouldAutoFocus = autoFocus + } + + func setAutoCorrect(_ autoCorrect: Bool) { + textView.autocorrectionType = autoCorrect ? .yes : .no + } + + func setSpellCheck(_ spellCheck: Bool) { + textView.spellCheckingType = spellCheck ? .yes : .no + } + + func focusEditor() { + textView.becomeFirstResponder() + } + + func blurEditor() { + textView.resignFirstResponder() + } + + func setSelection(start: Int, end: Int) { + requestedSelection = ComposerSelectionPayload(start: start, end: end) + applyRequestedSelection() + } + + public func textViewDidChange(_ textView: UITextView) { + emitTextChange() + } + + public func textViewDidChangeSelection(_ textView: UITextView) { + guard !isApplyingControlledValue else { + return + } + emitSelection() + } + + public func textViewDidBeginEditing(_ textView: UITextView) { + onComposerFocus() + } + + public func textViewDidEndEditing(_ textView: UITextView) { + onComposerBlur() + } + + private func applyControlledDocument(force: Bool = false) { + let currentSource = textView.serializedText() + guard force || currentSource != value || !documentMatchesExpectedTokens() else { + updatePlaceholderVisibility() + return + } + + let previousSelection = sourceSelection() + isApplyingControlledValue = true + textView.attributedText = makeAttributedDocument() + let targetSelection = requestedSelection ?? previousSelection + requestedSelection = nil + textView.selectedRange = displayRange(for: targetSelection) + isApplyingControlledValue = false + updatePlaceholderVisibility() + emitContentSizeIfNeeded() + } + + private func makeAttributedDocument() -> NSAttributedString { + let result = NSMutableAttributedString() + let source = value as NSString + var cursor = 0 + let validTokens = tokens.filter { + $0.start >= cursor && + $0.end > $0.start && + $0.end <= source.length && + source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source + } + + for token in validTokens { + if token.start < cursor { + continue + } + if token.start > cursor { + appendPlainText( + source.substring(with: NSRange(location: cursor, length: token.start - cursor)), + to: result + ) + } + result.append(makeAttachmentString(token)) + cursor = token.end + } + if cursor < source.length { + appendPlainText( + source.substring(with: NSRange(location: cursor, length: source.length - cursor)), + to: result + ) + } + return result + } + + private func appendPlainText(_ text: String, to result: NSMutableAttributedString) { + result.append(NSAttributedString(string: text, attributes: baseAttributes())) + } + + private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString { + let isSkill = token.type == "skill" + let tint = UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel + let iconName = isSkill ? "cube" : "doc" + let iconImage = token.iconUri.flatMap(iconImage(for:)) + let style = ComposerChipStyle( + tint: tint, + backgroundColor: UIColor( + composerHex: isSkill ? theme.skillBackground : theme.chipBackground + ) ?? .secondarySystemFill, + borderColor: UIColor( + composerHex: isSkill ? theme.skillBorder : theme.chipBorder + ) ?? .separator, + textColor: UIColor(composerHex: isSkill ? theme.skillText : theme.chipText) ?? .label + ) + let image = renderChip( + label: token.label, + iconName: iconName, + iconImage: iconImage, + style: style + ) + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + let baselineOffset = floor((font.capHeight - image.size.height) / 2) + let attachment = ComposerTextAttachment( + source: token.source, + image: image, + size: image.size, + baselineOffset: baselineOffset + ) + return NSAttributedString(attachment: attachment) + } + + private func renderChip( + label: String, + iconName: String, + iconImage: UIImage?, + style: ComposerChipStyle + ) -> UIImage { + let font = UIFont(name: "DMSans_500Medium", size: max(12, fontSize - 2)) + ?? UIFont.systemFont(ofSize: max(12, fontSize - 2), weight: .medium) + let fallbackIcon = UIImage( + systemName: iconName, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) + ) + let icon = iconImage ?? fallbackIcon + let textSize = (label as NSString).size(withAttributes: [.font: font]) + let iconWidth = icon == nil ? 0 : 14 + let iconGap = icon == nil ? 0 : 5 + let height: CGFloat = 24 + let width = ceil(9 + CGFloat(iconWidth + iconGap) + textSize.width + 9) + let format = UIGraphicsImageRendererFormat.preferred() + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) + return renderer.image { context in + let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height)) + let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 7) + style.backgroundColor.setFill() + path.fill() + style.borderColor.setStroke() + path.lineWidth = 1 + path.stroke() + + var x: CGFloat = 9 + if let icon { + let renderedIcon = iconImage == nil + ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal) + : icon + renderedIcon.draw( + in: CGRect(x: x, y: 5, width: 14, height: 14) + ) + x += 19 + } + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .left + (label as NSString).draw( + in: CGRect(x: x, y: 3, width: textSize.width + 1, height: 18), + withAttributes: [ + .font: font, + .foregroundColor: style.textColor, + .paragraphStyle: paragraph, + ] + ) + context.cgContext.setAllowsAntialiasing(true) + } + } + + private func iconImage(for uri: String) -> UIImage? { + if let image = iconImages[uri] { + return image + } + guard !pendingIconUris.contains(uri), let url = URL(string: uri) else { + return nil + } + + if url.isFileURL, let image = UIImage(contentsOfFile: url.path) { + iconImages[uri] = image + return image + } + + pendingIconUris.insert(uri) + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let self, let data, let image = UIImage(data: data) else { + DispatchQueue.main.async { + self?.pendingIconUris.remove(uri) + } + return + } + DispatchQueue.main.async { + self.pendingIconUris.remove(uri) + self.iconImages[uri] = image + self.applyControlledDocument(force: true) + } + }.resume() + return nil + } + + private func baseAttributes() -> [NSAttributedString.Key: Any] { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + return [ + .font: font, + .foregroundColor: UIColor(composerHex: theme.text) ?? .label, + .paragraphStyle: paragraph, + ] + } + + private func applyTypography() { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + textView.font = font + textView.typingAttributes = baseAttributes() + placeholderLabel.font = font + setNeedsLayout() + } + + private func applyTheme() { + textView.textColor = UIColor(composerHex: theme.text) ?? .label + placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText + tintColor = UIColor.systemBlue + } + + private func emitTextChange() { + guard !isApplyingControlledValue else { + return + } + value = textView.serializedText() + let selection = sourceSelection() + onComposerChange([ + "value": value, + "selection": ["start": selection.start, "end": selection.end], + ]) + updatePlaceholderVisibility() + emitContentSizeIfNeeded() + } + + private func emitSelection() { + let selection = sourceSelection() + onComposerSelectionChange([ + "selection": ["start": selection.start, "end": selection.end], + ]) + } + + private func sourceSelection() -> ComposerSelectionPayload { + ComposerSelectionPayload( + start: textView.sourceOffset(forDisplayOffset: textView.selectedRange.location), + end: textView.sourceOffset(forDisplayOffset: NSMaxRange(textView.selectedRange)) + ) + } + + private func displayRange(for selection: ComposerSelectionPayload) -> NSRange { + let start = displayOffset(forSourceOffset: selection.start) + let end = displayOffset(forSourceOffset: selection.end) + return NSRange(location: start, length: max(0, end - start)) + } + + private func displayOffset(forSourceOffset sourceOffset: Int) -> Int { + let boundedOffset = max(0, min((value as NSString).length, sourceOffset)) + var collapsedLength = 0 + for token in tokens where token.end <= boundedOffset { + collapsedLength += max(0, token.end - token.start - 1) + } + if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) { + return token.start - collapsedLength + 1 + } + return boundedOffset - collapsedLength + } + + private func applyRequestedSelection() { + guard let requestedSelection else { + return + } + let nextRange = displayRange(for: requestedSelection) + guard nextRange.location <= textView.attributedText.length, + NSMaxRange(nextRange) <= textView.attributedText.length else { + return + } + isApplyingControlledValue = true + textView.selectedRange = nextRange + isApplyingControlledValue = false + } + + private func updatePlaceholderVisibility() { + placeholderLabel.isHidden = !value.isEmpty + } + + private func emitContentSizeIfNeeded() { + let nextSize = textView.contentSize + guard abs(nextSize.width - lastContentSize.width) > 0.5 || + abs(nextSize.height - lastContentSize.height) > 0.5 else { + return + } + lastContentSize = nextSize + onComposerContentSizeChange(["width": nextSize.width, "height": nextSize.height]) + } + + private func decode(_ type: T.Type, from json: String) -> T? { + guard let data = json.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(type, from: data) + } + + private func tokensMatchCurrentValue() -> Bool { + let source = value as NSString + return tokens.allSatisfy { + $0.start >= 0 && + $0.end > $0.start && + $0.end <= source.length && + source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source + } + } + + private func documentMatchesExpectedTokens() -> Bool { + let source = value as NSString + let expectedSources = tokens.compactMap { token -> String? in + guard token.start >= 0, + token.end > token.start, + token.end <= source.length, + source.substring( + with: NSRange(location: token.start, length: token.end - token.start) + ) == token.source else { + return nil + } + return token.source + } + var renderedSources: [String] = [] + textView.attributedText.enumerateAttribute( + .attachment, + in: NSRange(location: 0, length: textView.attributedText.length) + ) { value, _, _ in + if let attachment = value as? ComposerTextAttachment { + renderedSources.append(attachment.source) + } + } + return renderedSources == expectedSources + } +} + +private extension UIColor { + convenience init?(composerHex hex: String?) { + guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if value.hasPrefix("#") { + value.removeFirst() + } + guard value.count == 6 || value.count == 8, + let raw = UInt64(value, radix: 16) else { + return nil + } + if value.count == 8 { + self.init( + red: CGFloat((raw >> 24) & 0xff) / 255, + green: CGFloat((raw >> 16) & 0xff) / 255, + blue: CGFloat((raw >> 8) & 0xff) / 255, + alpha: CGFloat(raw & 0xff) / 255 + ) + } else { + self.init( + red: CGFloat((raw >> 16) & 0xff) / 255, + green: CGFloat((raw >> 8) & 0xff) / 255, + blue: CGFloat(raw & 0xff) / 255, + alpha: 1 + ) + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/LICENSE b/apps/mobile/modules/t3-markdown-text/LICENSE new file mode 100644 index 00000000000..9aa27cb649d --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2024-25 Bluesky PBC +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec b/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec new file mode 100644 index 00000000000..0ac471faf24 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec @@ -0,0 +1,25 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" + +Pod::Spec.new do |s| + s.name = "T3MarkdownText" + s.version = package["version"] + s.summary = "Native selectable markdown renderer for T3 Code mobile." + s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code." + s.homepage = "https://t3tools.com" + s.license = { :type => "MIT", :file => "LICENSE" } + s.author = { "T3 Tools" => "hello@t3tools.com" } + s.platforms = { :ios => min_ios_version_supported } + s.source = { :path => "." } + s.source_files = "ios/**/*.{h,m,mm,cpp}" + + install_modules_dependencies(s) + + if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled + add_dependency(s, "React-FabricComponents", :additional_framework_paths => [ + "react/renderer/textlayoutmanager/platform/ios", + ]) + end +end diff --git a/apps/mobile/modules/t3-markdown-text/UPSTREAM.md b/apps/mobile/modules/t3-markdown-text/UPSTREAM.md new file mode 100644 index 00000000000..0ddc7775a9e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/UPSTREAM.md @@ -0,0 +1,12 @@ +# Upstream Attribution + +The Fabric attributed-text component in this module originated from +[`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview), +version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`. + +The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under +the MIT License included in this directory. + +T3 Code has substantially modified and renamed the implementation, integrated +its markdown renderer, and owns the resulting module going forward. This is not +an upstream package dependency or a compatibility fork. diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_agents.png new file mode 100644 index 0000000000000000000000000000000000000000..4696e3fd37d9483796d1fbf661ec742c67611ebb GIT binary patch literal 2500 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bash.png new file mode 100644 index 0000000000000000000000000000000000000000..da0441b96a484ca19861606b427adb1608a96c2b GIT binary patch literal 1944 zcmV;J2WR++P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuD0!c(cRCodHTTiHzRTMwxelzpl zGkvHb#xqSw!4@rA1Q8}Jj20CbM6EJ~MB22dc3LRqQiwJo1(~%<$Ok1Z`a@_$;KHCy znUtkPVZzHlz30q)_jJyTpL4(aXXd-_%{RT_y$^=D_uO;NJ-;*eJNMrifW>Fg1B)J5 z$R5Ccf?xmM!=rk-IX1I=q*j?})KaKb6H2QZRYKTEDwRahjZ}fEASufAjT#AblW-)_ z&1TBAYN|-5DJG2s>W$`TwLUs^qprp()8}`7HPbJ z5*4dsHvEX}AgEri>-0*szU;~$yFdANHhM%R-U0hS-LdYW+W~5;Q0cMRcHB=n(rOx@ ze(BHOPF;q5pks7LIe>@w@@dd(6(+TqP6oxRDXouzy1=i!G@y)k(LNzk}{f5fbV?Y1X&9<&lTi*F$8TLMqV!L4QwPm!h1321Cz{?ni^}B_E zv4rdoHpo;rU!#2pjE^oD+S9hL6~dm8dEPHFjMOj|?+B0;-h5f)^uyhPMKJkvc^x3j zTOV@_O%pyyq>jr+$}zHK@P;sX9UyD_xf+3hdw7wty8XdDbJaIj`4A?r17u?}*A4}E zro${}v_XG|FgXs8E$yIob%S(*U=nP0pN<1$KW{U|)G4J0NaO2{45__9c)vp*Ai;;N zV)rkH8A&~AN7+)LQ&^)=?L!6k**b65xAE?K8)!VELGM=zE(Z8YBMUZLxibRfB*$jz zRo9v}D24Cy#v=s2qk?%9;$cm+u|ph8$%oBxfE;7Rn3DpG@@nfK|GHh*+&*bNOmk9>li~n*){)5gb}q;|P8+ z+mY}!(y-K|nPrXz2gC{O4;?Vu$}an`IqLvOD+r3sEBcT=0jTcH*m)1Ig{Q+~?1V|) zg?+&r?jL#Xzok$ieZ#TfG2!PxIM_$9S&#E;+c!2Azf)yoA2ac6z5!Pt;!dFCLrT?` zpw*k27eU=dMqa+0nh%@ffY`2iMb}J8zsPbw`DA%SEaRVgGv9>m1*JaK1iN?<6gAcQ z^!AH3X9tKO95(;fkZ$KC?ja6}*Fwhei?Eb026rn+jCPa9W1LDyRt=v-U2HNVv9RYkrZSi!i7C8Dk&#S<9iAcbn$|)mg%?3GI znH3K<#{qIbA7ew&qc0g5Jh=8-y4PFvQmcqAHpc;yoDgFL5A9_cKbKc?Mgw1+`yb*9 zMze*dN~eFbz2D`gah+?ELwxcJWo>3TqYe675-WXudL1Acm_FthTG6Agj4wvF@Mf<- zQMnK%CkBW@Nk65LYio)s_wf}y`ZD=^dO|dQ1)`4B1d~sfcLYf8Z9DzcCo+6A!4k?R z_Bx;qv3_azpoT0CV+lGy5|1VKHl%V`=z^h@L>(|V=m6lXYe_r~$PEp-g`rCZdDsCA zQL?Tj@px#^T84`4g25u^s$Cr*oRW1diN`sqA*hJWt&H&=w#sbM`&rQG@`igP@pxhW zVZv|%2gil`ml@L!QeK#UnzFrM)FltE=ilb!`kilS?aP7@ literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_biome.png new file mode 100644 index 0000000000000000000000000000000000000000..ba07d10d8fa9511ff0566300525fb05a9ab1fbd3 GIT binary patch literal 1865 zcmV-P2e$Z$P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuCvq?ljRCodHT3u`uRTREweo9l? zg3!|GYQO|&1Z@G6f)T2sAu-^KMlt$;#0OuDLSiHmS~BU67)_LbuRiL7jZsO&2Te$z z3FRl+WvdAg#Te2qp#q9gXm{uO-KDdgo!y+x!zXpzKc2`Q$nV$dBOf_IasQnP$Hmp;K!9Z>EoE_b)^i-)N?jHol131 znQJdd$yXp0N(5-69h7!nI0K+`P5r0>zoeT64uCOmZYvROm9}VBP_zjnPY4!(*UsvS zqLe+73ZoAhat6QP`H>G40Aty_-7u!Zk3lZkj-c~|z!pLGe&6MobTQ~WA+QL*-u?j4 z%_KY0-WCuPeFT{&1QG$o_WK3=lVSy#Cj=1zq>y$9$W{$p(N|D;Le&8<2ID(0tQ(|; z)`t)f2qaIa+9(NM?EOA6+kav38SLxh7Gu9CmNmf{zm~Srw=+KLN~2;ai-^%OJnvUv zfY*WFjW#Wr`Xc{=$P=_8y2@f(%?lP??wJW%j6ajy*ZGaJ zT{(1B0cbPhOGu>`6r!g&vCWvn^JRqj0Spx4a0z{5JHKeZ*NW=6HF1 z*!FQcZj<7kafoQSlUIyJl-YU0&SEm9_`V}xkr}@)rninDS2wYVsmys;$1UrG(@0gT zml}P$4c3|+ZT<1Gcb>igSYjr+>NRB)%F9-08=kr2nVz)%IF-XA%zqyjqJR`dJ%lE* z%T7-3(f}C!+oFviSx9XOkexTcGy|(MYj(m-o?v=ybeeCyvcD^l zS0&4Mb8{VFdwZ*#8ref!XUWJr{I8XRq z=DVZ95r6}Y_NDc|i-l#@ks>#Kea1>{xZ1bwqDz3pt1%+ggo68BbvZ=S#+&Wd#Lfi+hkQmdq0h0uURFzr;xWtdxE{(w;*W_oY*=Y454UO^pr1SeG|D`C-!n zSefsR3O;f;Q+Ad#n6qKG^Gk*jZB32yu~V$A^TKwFteq!#x{?P#t9h&uq4X0Tj;JR&*cesg*%ZG5dskc5dskc5dskcl_T&UgO$-ZfA?3s00000NkvXXu0mjf D&e&x* literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_bootstrap.png new file mode 100644 index 0000000000000000000000000000000000000000..32a8b227598fb73836da61cc3e0de95a2c5eaff1 GIT binary patch literal 1949 zcmV;O2V(e%P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuD2T4RhRCodHTT6@-MHsHXs=Ie* zU6y5eD9a*pPE6ZM@A|8F!kQVVa;MkHM#G668V&$?Oa`!>!mnxc$&0!=*AU z34jf2k4-gGMu!5~SgN3j=r1LXq(;o%FHbHSi7GEL02`N|m;*xI7nO0f`f)&Hmh9Q_ z&S|MV)_*I-7U|Zo<7-(ca1j9rg`f-_Z4;3 zPyo8Rz7|e8)lm$^T5NY0X3L&3rkDTZ!t@ITAd|7% z!iXnBTWX-cHB3(}09{=uP_d>k;>plPYr#EXdTIffcJ9~EsT0PumfEDWtuTGG0JJtX zi!j2;(gp_C+UVM#ZM4nQ{{jGXRHz-=6&}(SXX1A3fkuZn0%zM1vAq}*U~c%vQUGj- zA)cnw+kaP#|W$_p977pP#Vraf|X$ED@kg-~lG%}KLt z6AD#4NL88qY8vTALW?3!@Xa`K2~r%kGl#vx3>?g-i(bB&puVY~{#pPW11zq{Gb{Kg z#3UCul+SW(sz|i}nfQ8gvE&PxmRBW>Q$!ThPRFaCJPy~fx?C4sxOm5 zk9*n&Y5H%jL^PP=shHzf89d+p%hNaAI(U2KpHmw6$ODyL^@9M&DN*zn*(d`0fuY(7 zg7p>cAEN^$I>R#u%?rFq+?ro$<74RKBcbr?I0ZgE$ip)^A5n<~#pIOh({9c8tpO$S z7`N`U;jN?$aT`bR9h0yP4W=*$oKR8zOP7w|no4X^#NLHmy1^jj^T$UWMYm#|2Mcs!QA+GSZ(MnHzW{76?F=F}Fse z3aC^+>7YUJKqp7On|8qaeS8u2x`!pcx`+kAWI^naBMm$yaT^nL(NTLvrEY!7H% zM>_+^9SMM-*&0z5gulpx4oA5f`;Q~t!@*5j{CX*0IK@C6py9b8%5+0XF@JVz*Ql)p zz{n(|W5x#n4CP)dZVQ~$8f7RM%CEqUm}(bRe}efKo-flKFSOViQ#s!m`6;X#pT2R0_5GXBg93YRhCsLv08HfKl#U z135u?a3qV$wnZaEW&wd}0u#xLS-+t+kWy_w3%krszjbMFDUP`|QZ#%kEiPtr;1lkGpg6 z2IoSQFU4K@k^;4@%YJULEvv)5Xbciw?YeRO!-Utk4NuWOM;dsmq}!Fr{+AiAUG?`m j0(AuH2-Fd{+YtB{kX&Rj3xEVd00000NkvXXu0mjfvsZ27 literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_browserslist.png new file mode 100644 index 0000000000000000000000000000000000000000..4b15e51c463eebb5790144798ca73f216b0b999c GIT binary patch literal 3352 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 Bu#f-% literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_claude.png new file mode 100644 index 0000000000000000000000000000000000000000..90117bf0e5e54c079790e586c0771645843987b5 GIT binary patch literal 3782 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_cpp.png new file mode 100644 index 0000000000000000000000000000000000000000..adc17802ef1dda47f0b715be68905dc28b75ac11 GIT binary patch literal 2019 zcmV<92ORi`P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuDO-V#SRCodHT6=61MHrviy|!Qh zu>yC!ME($iN_wpjh)Ps~7&NFMQ2|53AChQzS3tz3uPwb3D+Gli8cC2C5*4D+7)-#Z z0h21CNV(Gl5;T$I?pj_#DX-hx@i#{=tnKvnadX#;c9PA`e)G-u_|5FhHxEJv(;xza z2>hQBpabE&U;B9!@su<{_k2dEP6^w2rR!Soe9Pnu`L-v&0LaqxzX` zM4PU=l3`IPCjgW;w?9D@bu)C2x6p5^7~LVlbh;wmSn-{$3J#d00^rq--Klt#O=$7i z4s5W`%E%s~UwtL2P5#S%rOcdi09e^J)L%BDhETEw&ydV2vn$0+ahXx}Mm%wL1AdFE z*-68YoUMTJVB}dy*#tpjvz6tfK7jZW@x#h!UF8=}>hhw*D(NP+K(ejJi{6>Q>~zsdfX_LUxy_yac;hT@xr<@Z$8xJM7|RM zs4o;*KpAPlAm?+UByYTo#4uy5jnxHqK__Nl>tqW6f2e&DA*m?tL2Ho(z`^%S(Y<$9SyS|ZL>c~jl)DIe{_)JJlG*#a`#B4&~YAx zPQm9)46HM=(}~|gCr?Hj17Byd%f>%UTe33*>JiZyguT_>edV35_4l6@8Pq{YC+Ph7 z0nZk`Vl;|MmMVm-g#KGabu!b?U>>#+JXM+aEPFtCC}M!glCOUy>|^6nVujn7-mA5b zQHvDJHgsaRy50bIkK*mPSSjfe?b)G9nS6_w!={>P|_NX0N74H@Ee8I>8O4$}l zsA{h!x80f-JxqQ|Id71;**b5wEM8Z!jWYU~nIKjP`;%BP$&*LwL=;JelmQdNI8%2MD#+T zNdq9M6pa%(#lu?>y-;Y<08nr2U( zk70J$4yF@$U*bdTnQEm@NdqT-p9G*Ut{*>`f^#p!DKyu0n0IBV0h3v-G)*@N_xD242KbsUUeLP(&v@Zq7%O0 zv4;NQ1d%{~H0BG18*wUh0**UA72+~+f#NrjY%|RgQeRVKI`yzG(W0g1imvi`JRHU0 zOgAo8J#BTd$`SzX=xFViyF8xa&Dh$S<;wAVS7L0h&Pa87D$S(c7654h-sbRA*x-B{ z^SyB8m*yL=H)AJpl3N|G4eYgRgChV^LYIG4QtlnQ24QUtdc-Yu73Go-amQu+@UO?> zr#6z-S=XHCat=U`^s-?1juJ}w?Xc&ZD3s2N*x)K*tIyR1PPvio8i2GiZ*%*jilVkc z(1bKCA8&-GzZ*t!c}IQKx7p~koCd9ga|2?D(^ZTat1<6hwBpaUoa_nK@zvi zvQHS~j^N;BS!Y8fzds~3W&I0?tm&)^w8ivK`PzRm%7vGrMD+x;v*_-NxU5__&-LY` z2bdihp|zEjdWMIf>Lt5tiW@OJd%s)ns=cU9J!h6DJD+m^(gyq29i2!Nbt{6XscD+< zjZ2QiNG^*u-1n=nTrx3l0QA`1?{!yGRC<0{=|}{sEH9inU@D{t5s9002ovPDHLkV1lxE Bu#f-% literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_css.png new file mode 100644 index 0000000000000000000000000000000000000000..0e15d2f96afb7101639478dd7170c3ca8c3898e3 GIT binary patch literal 2806 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 B2C)DD literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_default.png new file mode 100644 index 0000000000000000000000000000000000000000..06bc23496a10f88821017354f53d7c3f1049e95d GIT binary patch literal 1054 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(<^uz(rC1}St4blLz^-r?!u7*fIb zcJ}^mVMmeU({jM z|2*AvzSZ^m>#s>>)sOTP%Q%yt8~uo72s~64(WlUI?)J97RmsWEVcN{wA5!X(Zh^-a_Ay<-SlBWD9&Mau%| zd6Pu?y%Y=>FI=qas-1T<+=@kw!RqS95Z|3KT0my`y!o5D-#lC5n84KVVR^TVY<-YI z0mFp-CnkCr8?wkT2z}KuoXX+!is6OH>*GwXdzI^FeYP=c*?7!SdU92y-F*9(<{al4 z?EhpoevS|bVrTHLNk2BXesDQr`o8#7%JaiFzNc1JK4iWjyH9*dZv7-%U!?<6 z+XVInt$sV@@7=^n88a)R7z<<%i$|^fCMzMK?>I|xp=Y9j^o%rd4r}K=8TQ)qX=|A! zF7jK?J(n*bJ!y@glWzu}!jf>FWKZ!i>M}D{tlUWg(AO zspRk~J7ep+mNmv(iy0K|PPjSqWI}d2Q$v0G|5=jt%ubbCj2Tmm{{C&aJJa)1Mmp1r zuT=~UThfoco)~bcY-_)f+B{(V=NE1;e)9OpEvfXNvo`kyXELnlpX!w=|1bArWODuS zC4p;0K1Zav9O^s4pZfmoUTcl1rzABRe1m@dTk*NOyWjq!R!W^LpVXq=?ss@af^VCP zOc9uFVdQ&MBb@0DmdGBme*a literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_docker.png new file mode 100644 index 0000000000000000000000000000000000000000..66e899f28ebb981d193dbe0f9a2b3bf5707f633c GIT binary patch literal 1799 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

XZSInzQRItd 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 i)GOa3&?9j168IlD4yI6XQVW&<0000e literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_go.png new file mode 100644 index 0000000000000000000000000000000000000000..98fc3adcb9bede87d8947279c0b6f6530ed78ba5 GIT binary patch literal 2104 zcmaJ?X*|>m7yg@ZBV9`<(Oi=CGEpIhq8MA*vy3&Dv8Ayyq{+VT4TCI68TxBGj3&v~A6&T~GWBr_9zZY~in006iR4d51sdi{u;#|~?S zm;cP6umxJ^YXene$dAJY?uIaQH#P?34m~Hpmf!`jA4Lu+a!3FGm#_igLuEUfm4N=Y zyTAT0|2A zBaqe*f>0V&%1I0NDkp~`{0&xvZ^(fpL7cLv z`DqF%o=>KHv>9#k-oIsY`d#2?bHfgJt7o$ahRqW%lM!l0*No4FnB zbT0Eo?PRP>STQQ%!h+|BHLoZEho7+XHD8pz;Zo#G2j3t@v&ZGd#ZI#yKhVI2x+z~c z2x?Vfc2;&p&z@SnW`o07R|+8=<=G=lFAjgutSJd}j_COqCXcyPIPBEB;|Uy;nOZ+vqozE@dU2{!d{MoZ#br?LblK znrZe!&*~q{Eq9y{qWOHWp9e7xdY|v6if);4%4L}s627;+I zx?Bw0=~41^xKmdsX@GoPcVY_8RS*^^zj;RuIxIxYItny&yH##8nM^U+ziUextD`5a`r6 zD^PM!g0V!bHFmL0vie!77Bp21k)!1Z0dnt&udBbu2qrvX$uvWD5B@5B9qY(AE5)Y? z$*_YvRzz>hdqVC>rOi%uhS&*|J8g$!BNYKEnJwoBIxjoGvo9i^W-lP&ke@hg^->|# zs-`^+nEMTZy=jkD!cpo|)We0uYcx}GRI3KEMHsMDh5?ECIpE&gfQ!~PIR7CVPVsf% z*ShJa9c4=ILv)8*XDaO6)JWr_9-UWhm3>@GzLndYbzdH(qC#zHZoAZ~Z3VcQ#!6;S z-}bJ;&8_1uH29JQGrMBE-jS|P-GfUae_!=oNIy~7L~{s?so0sJIr|zS>-S|z6U%NU zx1WD6`n0f^<&|tIT)DdB+ZFX}_(I+nJ(sNXI^p@1XOO0+0x-xo6Ukn(#)#qmMM7mA zI=H)5i?oz|#!|-c=LhH8`!NT5fAb87blr))rD|$UI96JvF({b!?o)BpFcmBx@XEe> z+FCwom}M(YmT!UI`juz@ZJH;mMG?>F)8}yg1BUOb#V%l;@=wHf*er>9G_AftIsw`b zPMARptb0&dnfd}X%marCzO43rRnC-#7@sNYCgPILfRN|PvSspZbiuDZPY=6gR-DBI zM>DtP<$CpLFknA|BZ^i(uu!cf)S-p>g{>dU1q4J<;_2> zA~C#I$8^Y)#l=XU4(V{#o_fX<`6GIHagMfme2a$>qcifBOa~%|@06b~{+%D+x!&;N zCDXI)a(rQRmvKSHl?6H#y6Uj{Dq*)Rll+D4!`q#YdBI^d*T;h#?I*9>fTPnmAt|DOdQI}w_&9lfa-()Bryso%*9N&O0si62F zADXqz48YU@wV@$lN7+dms=1Y1fJs6O%SffCrsf6dTWa0OreaKPeeQZ%S@QhcGBGZL z6@00%KitU=NeTqVnZDySaaW{3HoiDiGDlZDqr}BUqy@#CYAngJq{C*1t@&K+>GBhv$~octQ_H+eQnA zk%)T613FwU*|i(2^9b4PvTPUYBeC7+qAl$wW9eTmoFm*Cf71&uwHWE;;v>R;oA=fr zOkw^LwQtwMyJO3{=iah^?5w**W9O<=;&=f6PCsIKmf57UFFJeszrgdq=1HYy# zN$3wNGz8}{=qUkEbu8oNlq#qedmQ}>lY6uBSz@f1yO(ZD{x_Gd9bK1E653;^og;fP z%S^1DaJpa8dsbqcTl}AyoK)S6$J^2qseqAexuU}B9(%S?u}puPf-l;@px?u~ot6=9ka#6;LFqOLRIq5pjaUhB9RqMQ{CJ ypYtQab@JB<{Y^opzxx#iXAp?}ztQT&xyLK*lTH)Z=mPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuF?MXyIRCodHTWf4v)g3?QI(8q) zDke(VcvLD&sck8CY5IU}u(3&lmdCVd1xjgS@B2ttn-nqFItruf(x>)mAFz|I*+wO% zy;|5L_UPzb8b9Y zDNxDS)3JQ&yOntLqWPMD%#y_2Mt~o23Pu#=n)qP)B_|m#d4`u@IxKFmZ+Ri3!s(&u1gKbeI|KhHj?aXFxabR@KVa7<3)KZ?iZBTlATAeSN=dGUa?rb z4aL8Z4CxXEVG$v03&CNs;+hj-%!uj>eS;_t>nS21FygQ75T#jbYQVK$> zZz-ha(H~<%$r6tTcxy{ObvxetLrBSKk|iv{By6%#()Z@qvnG%Ux2-n}{sXwH-V}l! zVUq7ScR_aF`H%F)?81jX$s0p&Bbuub*vl>X^xf7Fa?u0^Km0CKgL86ZbaCu--+zzY zux`#Lni{*L!MosSIdJ)o5OQmLpy#Jn{pprnCm<6}+=n-`8P2aP8|xQR?3BVd69{l> z=Tvo7J;v`(#>BR8c(ND_ z2KSVMr|SK^NnVr*iiajZvZP~|qptyiS}BE0;$=IIDNtLsXQFLivSZkvwF!`nKW105 zKXWc~vB92$pyTCDyr}8mt$vxPg2|_-ZBLZA@Vpd z!NuIeMFOg!Tj&xMuEMn>(nuYd8uUoPJ04(%iWS5rkVuAP%f#j~#snF9fI%^dXd|Eo zXyelg;N4B=&}=r$$Kz(fyyq6JzZT$X;RAidFXDr#S4^JnPO_v!a)d!xb{+HqdcU~* zt6k6jtWAIvOjTjK;)G{joFpS>&-y;v{yenRORRW_mcOQUkq+qsLt)vqv4mzP*`Lvr zy6rUkvo-;=I)jd>de1GzGqHT{5i4*u+}4V3eu8LN@7og_>Z9@hlrGXCT~Z<}!n6X= z`k{nvE6rL4Z)>d3^JgoqQdye-Qi|u(+i0l@J=w)F$S|_|d&!DxVWiY+ThO_|_o)eu zl>a_=cH~(LYf43>3k=(e+f4b4$yFlWm5<(j+g0c(~dSI1@>97@Y4xB0j+U z;u^R3u4;f=Fq){8<^!Z5k0bbAx4PBjN*Gu+$3@s)Plh~AKf(XAcc-7Ti4zg2bd6t0wn>H;(AJIu3;1kss8|c-3g4}vbScFOPT(T}@ z%dN6T&a(u}nq^?N#^>ObefpOx zIcVZ2Okk^L*itr4W)1h66R-_^6Na4oNK9Da{(YMM@I{_G+?}qUpzWpDHQSogns2^oz6Sm0* z@@2XhBtnm^T11Jc(c`{i0-%qW1aDQs%|~MlXsbDatq$z50wl})J)%){CZ2F z=eJI}CeKw3z^RTdZZ!N+86eGa@MVxsnP4Bo{fceVC%SXvH`q;bRV%bL0KwmZBQ1Y& z3Jx0;_s0j)zjl)Gnnw$!m>m7e30JLDmlkWQifL;Ars8URUljoi3{T6Qp_7!iJd6Wq zvgLeXN6A@0b)wE9<{E&L6vpUd=ohk1GM@7|k5LDx!#)4u@LZ?CS%VG$%Tn~cbre&M zxLKxlM5zz6acs6@E!QRhgPh}t{~jmzFftng|FDyc*E}@4dx6>PgiC(K&m0(WlF=rB z7A8>x920<+)P%8MKX!*`&hEt`h?CEB;-pPt%iOtM5`^6E1VlVN288fQc@mw@_KIMo zQy;3DA>$P{M=R=0gE(n()M9%QoRmMuCM=yhaC{-I5;R6(4y0eS>TJW>z`d+Bu$;X)W~J-Oo+W?`9d2GT>-x~On*sU+-bSM`PDf<8 z?2P5phjh+Z`6naE2LoJU>5DHc(}%G^^W48i&+J-z>c}OlY%OI^6JR=WV#!0-1&!IX zeDw&8l8BDlaFnL8G?U9llV1UWj?Jzh9xi-o_--7O{$(bTT!xxND@>nLoH{fqgPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuDyh%hsRCodHTT5tNMHrrc&P{S- zn^1^tMMzO>)wUo3MYpXWhzms*KDM?l1YHy=(uHtWis(jMvjbCn5@gp{+31~EwesN*z#E2T%m~uK=wcDbbB9AX}e(X$vS000%W#tPk0^8Rr)I!1WTy-L4hoFckFB*LgOpPiq*#A zlc6j~fUNTYo65!7@Hi%=vC0lA8ur8DB|sv)K?gZx_%$pj-|3XUr_{U>gT)v`#3_HE zjXJ7R^=ZPUQs5<61e32$F98zX`jo4dNr+5}-iCcmb^%0M(uEI{cPXNmptughahEdf zbfKM3A%w|GfW-DbCsoVRRBwUp`kp;6_0r)Kwo6ZBrL3q@4-g+AOkM(HW0S3TrRkbB z+bkcf{;=|a46%AND`L%mX7gpzAxsVdvZWpLuFf#oKvWz70B8}1xgd%-A2EVSu;pbA z0kWUZE3Gt-ncoIN^m~!dB;}15|JfksYMRicBD;QaA2x@8_F+apFO_6u5nv*QfT3LX zkMA|X7W%L`1PnU(k+mN!+T3jw^DHOod^aCRVAZflBT|LECg7@9?AgZGdeY^T9Aou~ zkMio>Jl6SV(bZo?fi;0Lt2!FreUKgoDPMI9f(d%S60v$-Ge2XMDRmD1K2M4M&Xs$! z%h+s@O90OB$n6)lRX&%R%~;z%M6u&93tr5yl*vT2UD72V5RxMWe%N5HNYtA#zT|u| zujfFO1I)ZqQ65h2hmHfj$ZjBVQg%=2p^h_YaEo|PO;M{;7Wmzbp|YnV z1IQzYr5&JY!WS`LUG3-<{rch?hX6UZhhmX0zg+8?G$X;pIEC4~TY}JbhP3`QtJ4)` z64(H}kTFxRn@+VacIf%emwpzAKC#-xRgBDnkBZk+>fNH5? zA2yeO+!vctHRP3vZ30L|5r92_WoNV=3;`Z&4gq-#E6iKL46BIE44X~a5SSGIi}8PX zh3yEM-(&*pA)vXq2VmG(q+#tJ$lDJwaMK6?*auN*Z$)6f7PFbjhrkG(ZY=@wl{Umy zsd8hiaSYqIy?pKr0Coc-z=zH00rH8u6wrV&)^V#VV`h(<02Q&d0$|BJ6{wS#gQShs|lY+{}peiO}{f0CRlA+E>@3r?t{n4~y~T!{#(zuBF5{YwHjp z7QY=2qnDb0%GT_|<_rP3lY_S!=Ub}-Dpl`b;_99FEL%>%$EJ)EY*_e0CLc)BBM=OC zgxCa~#!zZ@^74yi@&?dyM!wACog~IJu*#OYM?MPnz;4!2&gi(Za7p{JRZi>-0lAHp)2WiRmNIOKBbGq3 z1>m#C)))QYt2F0-8xJ;z0J(~`f-2gkc#~EPpdy&^mdMAl&Y}aStWiPQhs_~CE+P8X%L3nBG1hDg2n11Y5+mR00WrO% zTWLf>F!?T6Is}L>xr7+u)2o6lw*{oco3!SQmeEnfc^ly=3}NyTAU7~QKB9`ymF<;< zabOztWRI+QE=kN^QD%`!AxvHZezvuJF4Cik<(WMF`Jl@h~TGJF&Z-_@xY*$KeWi+iP06l)SyN;H3r(pSeMU7r*_m1PPFf#{&wAqz$+4LGaVY5@| zlheI>UA{wt{N9cKFp*>d7NtKsa-q5TxyzTgr5l!pXlNK;dWMQ@ZiQUd`6XI9f4F|) z?8}V{r8aX>Lm&j=p8Th&5E)Xw8vkvsAD%VY8Ut$#-2Dvv2d+HmsO9b&XaE2J07*qo IM6N<$f-)oIE&u=k literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_image.png new file mode 100644 index 0000000000000000000000000000000000000000..9f6be84d09dc7747fd7a58bd00fa9e9427004f41 GIT binary patch literal 1776 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 SJpu6m0000_imSWiA<3{dIv2r-;i z(YU82bx{W&C(E4afBEM=jXqbsy;^+p>@%Lz_U<;`{JH+t?2YGLY780G4zSE%5Kdt7 zY2Y+~bFTL`Twp!^zHEuzbm`MKAHRIJeEZIwUCk9g7WgY!8O`*UpZjm}YyH+hZndYSgU>Q$3X?ug-A8f#X)!`v`db`BO*woR=3LP>n{ebu0`iv{n;;EQja;t?Yfbg z$%*gvW}d(n<{Eha!1qF7(*xzb$Noq(2%a~M*kZ?6q!z!kXyxPAyV7UQH*L_bUd$}6 z_@OaB(rI~(+{>x$4pbe1g61M&v46D VzyJRR^*&G<_jL7hS?83{1ORFaW7q%y literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_mcp.png new file mode 100644 index 0000000000000000000000000000000000000000..b31a9473235e2169d4be11ee1fc5200a9bcb3cc5 GIT binary patch literal 4074 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_nextjs.png new file mode 100644 index 0000000000000000000000000000000000000000..2fb339d4f97316c65a53e63c382d2e3339e94107 GIT binary patch literal 1434 zcmV;L1!ek)P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuB1W80eRCodHnp=oXQ5?q441;kS z_aS8-G?8n0@Zf<5PeSn^JdhVD$w*R4ncQ$r{M z@coYc*LG&GPxfW)<9zkax}1H^+26PR|Fza$b26DS$_|trC_7MgpzJ`|f&bNkieiOt z-MZ^rSMSMYKh!ohHqO;+XT&F6UGpz}n>X)VR9RVhq>IluwQ=M6d0q4e>Z%NMRq%^u z>(XOl_uy|y_R6pgm?AmT#U07Y3jXb(8PMF^JWyX-=Y91$mGgt&Msin%W6#AiK@Rk`2mcd`i0fNVB9 zMhZB`gXsfjZ?lakZEzqBHisNi-}LV?9KP+W_gyTog}> z7+`Y&;xU4Wl3gT+DX}040}$3E-t-juqX8po#N}cv_ZG{SctZUJBhQoJE73;ZjNrA= zB720$k(2=ldns~Uh5;sC)t_xPNaN{<5=k0>t{dVh(M+&8!RCjBdTp>68Im>tVN8hZ z7J(VTWQYmfL{^5#9-3S*0I`R~XCk73MFK0sFmV>0QNRTQ(DgxV5EWdNFQiC4vZ8blQVMgNo~ zm&+~T?h4V?*7l~bFNF3beZ~;j+S+ovs;a8VB0+}as;hgysjsg;_b=bWQsX-M=mzJC z9GTg~$UT!1tPriD?Icw#4s11D2clnybs|UY{`~>%e&Rcklcu7P1_KaKvr7e;_RaBTWV%>akcW@^py!LL)GTbP0^K z8Nf)hM;IZJ3IG!<^n0q#f2H4xfsi%>5XBQCN9_FCgmA|ITM;mVRCp_zDXRV+X*2*e z%=*2>ULrzmWw4YW71);$F@Ho_4L}rUiX5N!5HSJqW(1LeIYh!Ak!AxBiNJxPEsd5E zh{g=sq|0xSW&;q#sPoj=mxe&w(twgEuu~>gJ0tA|pa_BJUn#N0r7uy4SGPAC-v5XQkO4^9|>a7x=N*yvkZFa1C%8GuMenN@)a5V2+i zDZ$|neP)KLKXNBuDUm-2WaJrG!~^;;z#kFx=TS-#V44tXOCX|Y0}T0P^y%cnQUtnK o0&VmW-bs8Z3ioTmguf2(cRq%3@5nCxM*si-07*qoM6N<$g7VT{p#T5? literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_npm.png new file mode 100644 index 0000000000000000000000000000000000000000..070802b308af22fde01f7955636243031ab09193 GIT binary patch literal 534 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_pnpm.png new file mode 100644 index 0000000000000000000000000000000000000000..f5bde9929ff821c01f5876018d9c48a6561e377d GIT binary patch literal 620 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(<^uz(rC1}St4blLz^cFohpF{Fa= zZIpM=AqRoC%hpUvE?~)Hn8Wz}mEfHdZT=o&bzw4&--_5K3!mWqAol7|kL=2T$tSgp zcg(E2xukkh-m_Y^Rl5`pv9cX#NMpOf`hTzT<8_r!PClJ-E#CUnQ)V~0ugis!HQvWx zbosV*($Obh4j50UH8uEd^V=nujcL)uOTrv74?JT`QVSeRf~uGrnQc0|m+|m*xOw?2 z6f~$8UYv4}@uJ5|PM5vRdNGeqO}h3j>Um6?L};&1Se#E~kM;Kz+%xz>BmQ~F+a135 zYTpid$&8-%bG}~FZ1XxQBrZ*#dGGn>n|GgU$9mUDUs}EDt$$|D_Y7Ia--#7xoYVN` za-Q;3dS%G_MC{7ySC8Xf?Pqap$mYzolv&_gD6TK_z-Wba8+Fd`lt*DzY@rIf2eD^bHmuha=M(dQ~$@&`eXVmdjte* zgifdOM>E>noxi8C(ePpTfi&l29`=loyT^FgZ{%e3JY?uyb$iA`hS|KmhB6BROQnrv q5;iY1Y-2WA#djuwfv2HyR%85>Z5{_DBcwNhV%pQy&t;ucLK6V#|MWNj literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_postcss.png new file mode 100644 index 0000000000000000000000000000000000000000..856e70ac4415f3348db2ec816b0d60ba5c6ddfb5 GIT binary patch literal 3765 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_prettier.png new file mode 100644 index 0000000000000000000000000000000000000000..cf805c1602c029dd30ac6b5602591e82dcad73a9 GIT binary patch literal 497 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(<^uz(rC1}St4blLz^7VYWc7*fIb zcGg+mLk2u<(rpc{5iQ2$5-pk!&dqd9;8BTaN^qDM)%|Swf^Dnhr$4gld_CP`{^V_| zemF8P2{bUUC?rg6@K)VtG4I<-vxD#V?5=vAfAC$!*}m^m|M$hI7I)Yl*Lk+(0dqrZ zq?$X20|Qeo%OokS(gVxZ7Qfl|;!Cz2t Nd%F6$taD0e0s!K=wSNEr literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_python.png new file mode 100644 index 0000000000000000000000000000000000000000..ae577548b71dc95930b69c21fdf6bc4f1685d84f GIT binary patch literal 2236 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_rust.png new file mode 100644 index 0000000000000000000000000000000000000000..b3de20632f201b7fa1d687fbed34e2c75c0237ef GIT binary patch literal 3548 zcmV<24I}c2P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuJMoC0LRCodHTYGSo#TDP(@7|k0 z0*Q!^R+I$8y)oti4C4cp8m127i~7J$J9W@e)1p$uJX$-`Hh;9E<^foyT3_X1>j?7F zf^|>}MT?~%l85HrgfYRlhVm51eSEw9?Y+6levkXzo8%_i8FnW5cF#F`cF)1%aBE=gJfbz%cz=MeM{TRM%J*n%b>Sl_Owu-0d#?hFs_@~;v*BW_@-3R^ zoyQ27#SD!c-kq`HunlogdoL|G@iEIcUE>hKiKb(ETE-w@-2QJLFUk)8}YgXW;CjeBTwAHZ`D~&AGU~w6Xo~PW7B{E+=F% z949PC+BLK|lQ3;2gtwY@GA0ASV`}9T<@pYx#WC?Uf-zyTzyUA>>5vpai8v7zKi;N! zUdQ3lui&6<5?r*xLi~b~gN*ZIoU%+R#8{9_pp4|>I~{a+_}V9nbusi|@M%B_*jMSj zQe*TsbbdF)*@;nboP*MlLAWiLyDuEqPrNZ>=_iL$>FS;bCw|A{8g&y!cOFcAmUJkI z>Iuq6zxQBap5`JmC>57*L0&0q2>c@vy%|0r1!x+(4n0{E?TvJwaP?+}vJCJkeEt{= z_bi#d?7-)CG)}PII!}S-siqpAj|s6DO1K;CTKY=`UFG(~Cj^y21}u#Xj{;!6yC_oz z!)9dQsMdxlSv;*S@R^g4sp87&0s+we42oE)39<}|xYkhD)CW$)I|h7`f<>cF^pT*x z(=*Dxid^^Mr0yxH53G0MF<4x$BIY#L6jopemf~-;9WNRR?XZIKX6_nl|)W~Xrbm&yv*xN~aMrnjLRToTSM0*AOI8SAZLD1o!<_nz!H6}*G z8V{A!cWhQ^%y~xzyj|lPlS{}Ec;$ov6TCockn{5CjqO;TMlLliESSJXY5#=PO>$~A zbSiT8wB`G|6VW&-Kvrt0_DN0OXvY8^@FW#{0paXRJN!^!f<=TLA)g+8Z-1bCJHld{ zv}e(z3o<-_U- zW9xliotB!ytq|s#K7h;!aa>UPJ~HnE_t)aOz%~OO1$zsi^j?_MJ&3<9|27FPI}br_ zxvjeJw`M`~Naz75$LK3CaG<{|x;;lMVXh;~wh9Ol@hfO=M7_FQ=MuuGsM&S@d1tEl zDN8yGKj1Re+4*gy45tsJ}G;nmH;f+ZYj|Z;?o-0Rsm^FeU&h zrZsjRFhXM``>;;p!jV11!;Y&{0IatLL!o=kj50m0s}U-!GQ%xp!SB@xk)f?6F~Z@+ z*$p`T`6-Zw!&3{f1aLCR43|~_|FsS9S^YTC!y{s6f7zHbD}dsZ_7+4_cY$m+{)~9a z|1$mY4?i-pAf&aj@XOdyrx{}`bI#r?YxKWsgc?d)TWY+!DPgM(P`d&ZU)zB>rE8c$ z0W=dk#RE{x>#&Yh9~@|Z>Gqch#vzsr0ybApz6xGu9p;iX!L-6X{530r5SaOg60zgBLf3iG`^gtTvpqYacB8av(wB7rH> zwnQPN#<4?4Luy`d!5J>M_+2tq7>i9M4FP$oY)Mm=wtotIrv(83rtn68XZx!v&R_wx zm|3-T{<~VLy~pCm(+Y8uk{?u-Y2+E0qOIv-+T{{K3$gUNotR^T(-!X0E1YOxi+h33 zS81S>Keu%SDpd2Ac!L6ZD`4Ay^kI$UR={L36eb-?ez4i8;AvYo3bjY|(L?wq5)93r zw!Aap<|axk$bNN3LU{e)i9?Ib!b@7i6p#RUOQX><-Zu%4!4aQJ{5O?gzN-&@6}+tE z@y=8>USFvFls;g5*0>LFihYX;mwAH%) zF`bT?>7a!3afH7fyW8hgoIwGEim-}Q^8%-jYI)@IG*4sh?M+oa!`!y(2lg*UxeP=` zISf7kn}y$C`?~|$`V;;xG}a?&*bs>xL5v-cZD8u#9&Z`ij}^{jl>Px(%lSmhLxnSl zCKklV=wx$U_<@fJ=MR=Nc5F~_1_ek&X+!%PI~PwV8SBcDH$3vZK9(pwC!QVbm+SP( zQky`iWl{b)#OOa(<%1;`v@DSXBN*w`B#gHI-~v zR;@vJ$w`Qm=3l(gfqbQ7!@6FBjy|5W9!wLl!W=Ul78k?jNX4Uybb?E3O+HW3m=!?b zB%XvOEjuS{DW|GTha-QA1RsT_llmG=nR2!L=$ivdEFNB^ffDYywc7W>Hq(I$c=e~I zs>zpXO#62~M9E=z;3OZweV}pA4HB3o4o>LL6>8fo0*pK0OxsjsNI&(NdU?`XQ`ic8 z^oCibGvv`1gq|y4m76hy*;u3Rf`A6480eLPF2tO&`aoYuZ%_c}sC=2hG8`@vQiecN~fwU|8-Ydg%Sbd(b zt7~2bEHv_QV^MzLe}OPQh7e*snD?EmSSqFn;8g!WUT)Zt zp`@W>i&HObr+``k@p+Y6j_0Ad(bOe2{--+c+&FBczk4dZ<29Filb!I< zHpqs*SkqEnxIsQ#Q0t{5zi+YkJnU3oWQ=Y~B)n_~Q{sw&v^O9HFhVakFpqA^3=njH z+??KN@i~6l~P%Tw_o*~#YN<5w6jIFjZ4z*lPErxM+@p{ zD^`K+7|8GS)p4+jKAJ=syzv1l8JKRB1(o032>HMCJPLs+Y<^G3(jYQSuFOzqs&+vvH#Y#RcvbcAuGVr45h0P z5Ir9ZhNj3%h^foEjzECEXbf^a`|+vBIDGqGiU{%9Q6kR8IlyCaeo)AE`4|H{Ue`sq z45R-XZsAwN@#*>wc?zV`k>*P89Hy~XA@rK{^?@JPB2L8>*DU<@Ao|FG3vqjCeIUA; zVA*^E7QXP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuGQb|NXRCodHTMKYh)fqnL?&d`j zd8{>JL4!uJyP91SQgk3wkz!l5p^q78#lUW0==3qIXj@u31#vpI6_tV3vF#wuhD2b< z6txu7#%inx1x*u@jJummlEy>}770}dN%nD1zq5F6_TGKu-hCjM=FTMNod2Brpa1{P zfBtjMf9?`8^-Li!g~0za0`y8z{s38FD7I#oQzk5;gqAWwZiZ}g2_-WqqdCa?31#OP zqwf;J-X@G4qkf^Wq_<`;867@p0BDDG%@+wXtb!7il#=PXlwk}wk(U`^kJ&u6uj_jE zoJn+D5Zso2p;hcV|5>VF#Mgn&NN(zsEb3mtN^qZR}~9_@kfNv zDwuXw9Jvy#VsCyJ99mY<^{4Y%85p%TOr~9X@#=Y0FgyYtP@Ebx@nY;0qr`)@1B}qK zlrTSKBnSD~m?|ZpTO#?;h0}#uyFmX&WLThAKo$`lEtOonq%R#SoyR8&|z`ji;?;r%zbXG z&IuzT99Sa&+U0d$Xk1Aed@EM8qs z42Jy}^_!yhh@pYvd#I0YPImoP@yBxNJdORsHJlEs8n(Gzd8#&1iz#!0omR(6!e}j& z&yLy?VMj$mzi;={v_`K_iVB_S0H#3*0LYN3m0L;xzTU?H<=| z2n7R3+Y{yp**UGfBn7}rMfYUQ$t>6n(|aI#hwQI`;ON@2uKoXxUY{B&_dCG02qS-q zAtPR`oD&A1nJh7yGOs;=E|;r2!!QT%x5@5yJ*losM|rxz^kwjs_ZJwDBU<}O7yy%{ z@b}S`XXk<<`C6&h^_o^8l5Gdi`e69vV@!NrE6;=g=(IY&1#?@g?gT#mJrX3#O1oW} zO;F84Z@I(lTm|LJrS~91tIb>cPpLdnsvCgT`Bip!+#S+%8}ERf_WJ@$7WCHljMLJa z3+)Ce`E__N*jYUmUY;ng3xKVJ2pL8r-)k!lGC+LkCbsdz-T2fsAeAVKphUtGs%^1q3YkgOD6)JP(GiR6%!3T|-K9<`6@j0Tt5QnH4 z03NXsDBYyyg?0W_tntsP>FFxBSgK}cQR9md1Hs7JGSA+3^!6)geZg$W{Q^3T*y1u0 z{HazR$+qJMtW28GrdA#&$PGCBypRZg%>ZDotx$J>IoD=)*PT$)qm|pt)r+Fhwb0tk z&O+?<4`8Et7-nCfd`El`7PwPM(_YdH0D=*VBiXX1NLqA?fs&`OWIY_cHafM#>{x(N zcVX^zV{@Y%0LIRWB3oYKu2rl8(K*GYYW4w)(n6rGc;Nlp6@)ki_=;_UK=*Z6s+TiS z+&(l$o9^m&jfTI{as8^9B-glrP~me>nC~#H;q`K!u_n>)UpGoI;r@+C5Wr6c-9HwR~UQ!hR`0Gq0zqEI` z{-UZ%xTqNbsNaem*B#+KL7?g3<%#leR9jA(ohM<^k3-=i0BXL3sS;QPA08Vd8?A*KQVjOIe{-WP{|L>1qtU`c(^V#!?!#mZn=07AGHQ-oiB zAY?%Pi*yi2Xaqz9$L*fFw^2sZ^gMB(S9W-8&Wc%i`FVY?g7Hzu=nVSbpLcIhEHYjM5te_bHu9wGqaR=tT;;ao5+RB%!Nv5|ft}Xx& zwhoStkd>H{12PAS7@!~==C?jFZ4za40l@WI*zYjaStJCawVr!E8KjxU$MDspi zA}L_{-^J)Zg^f&>+?KI}=g$vUVF?|PX;)Y#WdLx!+AY;(0%ZrV#g*d*eA#3}!F%lQ z*9~7`x`)I`DFB?~@uCN=nwFXItm62}*lYNDqtax`DS?%gGl3Lhb8CXNOv7Oa+gx@A zPPBDwRQy|c-=2ZX2~^4fkc4lyI5&X#lQ=IJLNrFk6w^a$s7hfmFB2v{V)O3#hpH+q z#iX2IJc&m9{yzYK@hfY|H{hE9jQ%?q^*7->Phj}_@E4GH0kFG)*&EnhZUHTA10?q+ zWm1{8TO4aB6}CZoMfeH|clpLH++N<-pok;1kOdi8#a25r7%M4b>!r8=PKdw4HFrcH zHkGob6k#5_GYEuVmM$F^k{!i}-xyK~_U-o%7nJohoEfiI=5XrF$;;3E1uP+S$igf4 zA&&5pvQ_FzDVqX__c@J}c7!QEB#OjY;&xTQ#4iO-p_O3FD~l&SUoxTaLx}V~bg3PE zJYM@`>yiebxzL%7P}goM&>GBG>5jhH9~>#MduwXBfG{)pdK}((}~u+zImC7^#@s>8N_pOlZ6p0qk{!R7xE*#1te2G7K6n{1u_DL|=g`n+?Ikf@uK zmihNzjr%|>jj$K~;4C2cgx`kp6Z{+X1qQPM8H-B$Yxx~PS|8m2K%48sC8FZ^8ZXgX zEvs%YlFUQUy-=dTN$#-qx(20ky!4Kq=RS&8pUOJj0HAZl2hAAY3ea3!sP=-@xn!1M zJ$8dzL60YrS14@SEb9(%D`c})Dbn9q0TZQXm)wvH<$(O=RH0KcKda>ls2{LiZHlePTX;yV#oBkLiG4deK{=DFmhvm_i_}2>cIRQe`rR=3Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuDp-DtRRCodHTTP4;d9|XjpdLnQeBFb@!^vfs-VhG#=oBCs<94 z35mCbn?DERg>c&JfY?8N-}F>(bxl=wb@%LwVLF+us#ouQ@26f>SJliq&hQyVU>JcX zIRb8x^Sc|gL#F*#b>Ck&&}=q-&+3ma2f>4nVgI8nJ=^lK z&1`FW`t!Yw#^{RbJh?p28E5%8JbGK)3EEB{@@zB)U~cZ>q0!OiFRtqx(gC=xH!joX z=NDJz=NB?Gxqnb~>Yy$ePqjt#(RTVkUj$7pwE>u$Tbgj)#tKG0CwlI<4CcD-TadZ6 zS%DCaQwMd)x?yL=7Nu`5eW6cTdeX|Q0jBw`6C_Pu2QFM#ie2R$Zxf#R%CkF zdXiP1uR1+5bGB={e*T-Z^sftP2IS3m6P$kTCERs0B7ej;EXuQCgGe)Ja-uyRN;il_C({6vxSmj0A zT*fEj*trB-M$J6sdQ|5roo0Z58gM4u4a4x@%*^B!fp1$Wi@32fNkngbn{dC8AQq# zDILI+INI|uRn^^}v&Hln8*AJtJNoLXp5Qc>*Vlv73k%ctQcX(b{P{1Ac%FNiC%%ZbJ3X)dhd+Y1rtHQQ418Syz7oO2gg6N?1UP*OoDs&zT z)3f}DKxo*yq-Jx+%cyj*)e$twnCaQ6knRKU&ixzj>q?IpbqHhnBp^}b(dZv50V+bo z!m$@mcWwFxLHJge4T_ktNnb^0ngMHT@hNotDmrXSdDLYY&x><2fN76)<4=BAmu1zu z2CH&5&`0_zbY#Zd_ZMGpG@PH1mT6`P#o$I7-{ksB6d<`F5AXEW9?}$2n_l{pLF(X13*UXzpq1ZStnG2mis2K#HocrALxro zmoWeo2*b{&C{|6vMRcTY?F^`ivD)*&XliZ%FlQbH!9U0F`gmwW$nU5%dOhe$7_^zT z(}z40Gy4D&+a05W3l{Sih~yOOfs^uU2iDNMX7 zvb2%5n(R^O)&aor@qQFJA3{OjyHo|0+Zbu>LTGaqFv-gJa-Fu(M%pUV^XjGc{Ij!@ z{Q7$lfKz#(av%2a+5q&<|If+1YTYEO@=5JQ+9I~4)$V(YrA4cDJyQAYTqG^>B37Ptno0r&h1kI#$!C4@~yP+?>ly|$c!Dn9^$QmH4 z!!NWy!S{G?g7%tBR~N#(vw+Im!&xP^j5&zhU7P=>NqoGK%;=>ecIQPjaV z&b&pkSqRA!-%e!8xKfRGO9Fs}Er{bsI5j%^ip(Src_~xG`@ieQe}Nn>GTMfx2xHhk hjKDAg!wA?$;9pUShEbbR_Xq$0002ovPDHLkV1nQR=p_IE literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_svelte.png new file mode 100644 index 0000000000000000000000000000000000000000..08381b8325b8ea054b0c11e827b5a9d81227f186 GIT binary patch literal 3335 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 JPDHLkV1iJ%6f^(; literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tailwind.png new file mode 100644 index 0000000000000000000000000000000000000000..3a817538888dde8a43916807e241901bcec06e47 GIT binary patch literal 1258 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(<^uz(rC1}St4blSkcz!Ku=;uuoF z_;yBYhDf+b!}*zSjsmdGo9HS3kQ`&E{a+)u6P1LxTx}`mjmxz|E+F-184kajkt+>yWD}wC8M{IMapf zY4uDqn2uE6k#ZMK@-+FKzqNh+*<*LQ_!g`yUlOWp)5X!7C+5Nu?%S~LE)Yv!CLFX_CHkA8<{SG_-QsfBr;Yb5S6Jj^)?av+MT%Pv?8RUFOfO zYF`ocmj2#t56s_*aC>{R2i&#YwBZ#0{*UMMwcp&E%Tg~EFZH(3Kcf3`+Z^}F$%`Y{Cwi!suC5h+vmdee9*YJ>a|>4-t=iwj9)K4Ts!A!y@$18)uF`diNv(f`>9833MYzptgZUm6dz>UymtGyedXIH zbk$X@n0WZP&CHc|%OW!cH<(!M5p?`5rtx**MM1gE)|q$Gca2l~C_#mgzKCyVd-D!Gq zq6{xtBh793uDkgKhaU-hp>x!rz9-nGruzP_{d1Q7xO2+fB0YJI_^Vm>Zyem6EE<@7 z=S*{ccd}a0^_1uh{x0iH7*o#dF|*oo$~}rZevwqg{;<#Ln>y5Eb2|kdFz4NWepl}K zy{A)V?mF9yo5*4gVAIj(+n3DcGLR$KYMOy$UA?Yr(04cB+gD^Hu_^U8>==lz4< zZ&wC62MRQ-*)?x+bdS%S@9*z9teK+lgjHehI(Cl zi5!>TJuPND`cW?6%C33&HJe^nt^ZKu=3sg4YaHW~U-Ab6POV?}ol8NR>D1PB)7cip zyspY*05cD)UiiGKIP7}$-0hDIUtD~!dDp!b<~8#_WGF}bE?9RyOsHB}+Tqsf(#yud z0vp!tPr6(%O=#`wxw=o}Pg=2l>fSB0_22j0Yvl{u+%GR(!(F<)bT9Yi%P!sA3Wu*w zdiU_Eo&2^BtJ(eq@AfK-o&V6wRFu)xK`?+t1d}@9{h*%P>l^z7_a)Oo<&3ARpUXO@ GgeCyEG&N5E literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_terraform.png new file mode 100644 index 0000000000000000000000000000000000000000..52f29bc3a6201abaa35aa5cebe9d104228ff4fc0 GIT binary patch literal 1939 zcmV;E2WP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuC{YgYYRCodHTYGF%RTw|#7B)6u zQ=ZNQgK@A$jM8>l*G3Uk1W`ndAc{dCxV9#|K>;HfDh4F@`lszAfk5H|j0yoEpduoD zZ0pKkJRBGUhG-DPMHzdv_xL+2mu7?kyp*kHu8I z_vK>GXc@W3Rt33cl+Y;}P2XCp(YtY$!JQ*-?`icA0Jwsb(!PWW%LfXXb!L-yE+_Rg z#U*`06tkLqOlp)$MpzZ|lZB27olBzilrpRZ$d$vgD54sA6yR*1r>(wS-k#3&m;m4k zQlNn1#mp@Fv)Sld^;rHlndz*!Bzw#NNCQv~5GZ+Pw=z^6oQP7YYi_!bRoUw;)>Cl)4m2LwN)RFd~R9Mt6JNGPr zFcVUy(7fs+sHmW@h9LmLZZAG0Gvq9?k?RgcRBP_tgDueuAt-Pdr^2XMT(#Zl5j$4er_f*(6skOxSQs$uSIsm>Br`L$e zbI}qH0GRS<6T&UY%FeB`n!Ke-Y@1etw*;WNq0v?_xJ{0GXZ552&?XEWihJO&nx=Wp zrs{ER^w_U>aAq-9Z)1Ys1gOQaj|}!qm=A~|*jRzk!?=Sx#OAdM+nEQ7)ZGH*G?`+=|?{x?Oc9-t0CcnRs ziR`m@Csk$~9f70urvTw&KA*Y7JCzX4F*wkNl$k#`O9%j*yrr`EH@jQ+Jn@U;7$ZI< z8FqOtMn>>4fBNb3>1%X35+5EcNbzpX-l#O~TvM2Lt8kNqjpdr}z@fS{sfqRpu< zzur(knlbT10O9|i0T5Jo>#+%c+gDZcnwJrl7sc3WScAvlW#~5sxt3dl#qNiIKHvnAGedne$VcT3EXbQkTuoJ$JoK?pgfmR5etwh%U}_gTWL&m zV^suX?MbU*i5`_RR|9~XJnU}$yZ!*vf$^Hst7-vYjD#?qPgqtRa`H=WZO-sJ zT)H=Tw2342=aB@9w+)KdG9o?MjA96fpe)Bs2CQ^|gnIWU0-gJ%WU&-fEgU zpAou_KU$LwiCVp}%ak|xTi)1KVYm`?fUHA8EI`JSzijaY6-cec;F113+H7p7 zAJx@plkYBkmw|tIB_8FuTuA|dGX@A5u!vWA4&PGZor7qzUWztxR}b<&oqqUk!pIE1 zyYOA6(|$2DBy3aLNc(0QWzzxY^5yb-0xR4Rv9%vRSi7ZRl)> zfHho@=&4X91OQ1<cg+9* literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_text.png new file mode 100644 index 0000000000000000000000000000000000000000..adc79f280a48b37fdc23ed06110f5d48e3104825 GIT binary patch literal 1142 zcmV-+1d02JP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAu9;7LS5RCodHn>}b0K@`V#=kAD_ zAlQir2DMYPGu9FUmKKVQt(8~^hP!-!T`t5X2qF=?*jd;su~qB@wXz5z!Qvor3q{NL~9&DY*~UUp;zG6MfI0pG;=si`T^)6>(xyPNM9 zV(+BqiDO_#nAjQ}9ex&mZfHu(<4P$}% z#%4IJcx?=!t#ZZ6Sa0j??L8Vc2y1|E;k3rJ5rotMz>4ogyOY7|_Ow_cRuuepT@?D z@4#+jUy$2=Cwq>xfTlFx7Z=Org`sdbbP~;(=M;dZW8zG;TDu7y<2gu^Ei^6+vVtT- z;Gi>dGYI^zg@U&ZJ^DtyUjGSwL!3Fnfhrsaa6qqiM)bWy)PRE_5S3^*odK}R*L~|* z3vmB8va2v_0e1PiZ#~Z~5f&^Lz~&v8#6@;`;?@*wfQ1!U;lX|Z-(e#JEI5x2H-Lg| z-opj}UWJye*#NKtt66NW%ttc>n2mbueZZd>EpiLBwJ@~N0{DHmXfXp|5$<1Mn!OJ& zp9k0(=c!-8MhF5&*u7gD*qgA8y9IQq-*S9+3oqu9ZrqH5lJNy60wRMW_`ucP?$u z&9x`Hr&d?rtPc(j;lv3(l5+Pz=SM~!eo$4YJ^jM3N17i{B=+5qqA#+B&0h7FZyD7F zfDb}!v3)kz5@K+MNsipYp$>a{?C?}#-k_fBk&Hk_;2$RN8|yVFeP709xBvhE07*qo IM6N<$f+-^I82|tP literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_tsconfig.png new file mode 100644 index 0000000000000000000000000000000000000000..e8bf751f434b2e3cc174792a483a6258d732cea6 GIT binary patch literal 2023 zcmZ`)X*|@88vT!vD8{~o8M0+-Bn){igBklaWE;)IL^WRfQr4_lLc-XUp=8ey+pz006kmOi^~niu^-%w&Ph% z4ES&?pddRFL*Ut{_{#Bu@^m)4Ze;~19(#5Gltctr|5T1CdQ1QSlR*IZSV4boWS0M= zNHX}p{!h^uW7q%yCoyIygUg|ybu5SXWwgleuifjrG0%~*AT$=NQA{O0=OGZ)`}|u9 z^PMp~Hq(Sl3H{HNWe_l30#C9jwoG0$-cZ2sMM8q8d25R-tF2@LyMk1xq)#Tk^Kf9p zXXe%(u5%sUFrZnqUUZZn{rljO%{-jK2!%m2rbCTl(;W_^cdMsOtT^YABIR5s$H z;9D4x?=Z3Ac%|S8@kll-{6mNfO;tKDyp7;kp(-GDx*=aY_jOsAdFpt@Gc=;Ggb^);&VQPV}}0yt(^k-R0xLb9aJyok-=C*Dr9qJm*&9X^zt^Y`k5$ zJiQc7knK5wDxBXb__9iIq1#y=mL@*c^3x>I42n`FI77M;gzCfncGy}mELX>9V>OL! zW>RrDrdc}+LTWmJ<+T_iJ9!Z!k0fkD4B8Uk<$=W z*e>kn9hFD!8tqlSemanERn;&peN_31P369EnxH<)zNOX5{_a-2+s37wVSJvU#{n-> zWUbY8i0Wt;hD|m9C#F;v%qcprifQ4g@GFv-B;{D@-rdoYXcAH^3Usy~FdI?AQoSnmy)I$VhrHaWZB**T4cq+_-{DT>=&(vYL&_1oDQH03IM!Xz+ej`W1d&=|V2Ltf9zJ zgWx!+549EJD&J&5-fB>eQX}*oic-9qMXdUHnPUA(*ubwRX1&7aH^Io=7MboC8Q)jl zn`RLv5Yo*%P4PnM>t&|nn#{B_GFCJRm4`!BwKei^I3)B6NNM$K=-@u1px2I<-;1u- zWTH(B@e=lg+#aWw@|`aMczhSS)5O-wWckHOS&|$xDb}v@^fS={ehaWM;>AeN0~1`~ zEy%(OJ?oKW+ULMWWA?_>AS|LWHINV55?UND)n$B9GVa`32WdMpqsf3;QxH4VEtGTP zaye&!xNV23sHDfaj-l#+X8-qnQ^s#XY&kz(@KDa6hrZ)X-lUczz6VcU#Z)JL;*%GGw>5F+ z-1i!bzGhn@eZx6|QV?QF=`6!OY<}_W)}j;|6ITQ(#k1Vqneac`QTk#cGci`-%8t~f zbq2joMY%|~GlxBb5;aQWrv9jWH+UOUJo+UmMF8s4YI#?%#7z{YMkf2R+RdtD&Q1~P zDtvS$4Dx(et(m7qB7OHhsunv0`KJ#gUg=U8%MD@+$g*(V`r`4E6Q8Kwen!%j{kcwj z0+mpeRPYIA4}}|lvvDD!&4t~M_8Q*Xz`tjx?ptRW@KdSmJi0fMnn$E?e+Rv75J8J6 z=lnegshR5WzR-T=;}TXs1^NPK8u3oSwGEsQG~%^KX?MdV&jj+4aRaeK_uC{szu&Eg z>nLvxeMS}9Erb`>qsP8@blS#A4J6XaVh zGI=pH(k-1}e;~A$^;iEK@#vheza#Bw7C{z!PhHdeTCAS-@LjDc*pqqqC4AYZdn+ni zClX%20c&i$3Gw9M2R=^y94uUUsiLOJuNSWP`t@V=x2`i`VNpGZf$<=oADqjvAii$x zr!*rkotf2p-5P2+EL>?hifqA0fdWzSfjW{{!mkcTilE|{y(N|C9~^n^VHfpPvlbqi0%c})6%S{J2XgC2#*qi5-&gc;E2_`7 zv)(|e?Dc?wrRwPLMRFJaLS&?$ylO)-0eS7TsTq-pAA`f8-(m8r4z(-8&0)hR!h?#fvotY!ME)lT^t0B2+Y)dTyJGfozpt?1;b}z{pC|#jcj!0 sDT6igQ?*WSgc!oKIiLN1u`IQzl0FBs7&YfA{_|wb(AKDDhHi2H2C?d%2LJ#7 literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_typescript.png new file mode 100644 index 0000000000000000000000000000000000000000..006ac67149cfe5aa50b66b98e6e180fcfc90c40c GIT binary patch literal 2103 zcmV-72*~$|P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuDp-DtRRCodHTW^R}MHHVi_wBx2 zbInrwBMPP>Qb`&?hF18Yh(NPXMMxi{K1Wedknl(-sSiOGL_uHt5`;?5ewO-@9}Ao%?q7y?1BsyZ60)yS|ru;qA*Cqb!NR7ocIjQGM6*H{7Y@%U&FP=lTh{ZKidO-F zZdOJs{3HhA{KfH{8Sc3>I(({d$E8-}NSo#jP$=jddbcc4D!+&*>~FQ>cD7>=kUoLYkx^ILh7U?Z@$~W z%Zm`5MfbFq($E0n^W8G+eLzKb!QiX2w7_PZ6tjJvR;+}NB=b0pI*(8a$LC($c>Ni5 z;DC2NNE`w3sq56)z8NzMhRSE#-zw1n@}2iL=|*ZY7Ow$hh1V&IoOYO7um~ofEUy7% zdFx~DW?B*^uK{Fj-_2lsx+h`s8bCHSJ|W#qOTy$BK(@3qwX2&+g9;|W7Vk3`C&+$2 zPV<&SddW-ANF+r=9GKmfokJ5~4Sye&t{YIdTrn@gUKLwx)q}MNzGkWn-ZA8{(P3HV zA7NGA4_s%gjI?wDTU{){F@OX!5X4B6rHJUkRHg(q{!(#c#Dk4mTk$EObzKmdt(;Wjy z^fofUg044JP_+SPYcd1cz`sw3js>nriQ^ z3rtmFtL?RUM``sa++iQq0>cf&V07@k_*}e!^TRj8GU1e}^`~vNiRPi3Bn4>u*Jv_4;H-Nwzggx{B$%n`JdpECF~V;Q_!G2H_< z*os+dVU5&P;NOBd zYRidv4>rdDaz39ZpaUubGGiDWyJ}U5;s>>aguD-%V*t6FkkGzk$Z&zv2$&!+Vjmu` zxkjlEn`aIPu-}@&&5+US;J>S_;@CNVo+ZkIbwLS3KYHL2nPM1n>%{hBQ^x>uEhWo# z*b10x3gz#{5PrNWCpZg(d{!;HISHR3f^@a5!RD=)W#STSwvA047mzzSwsK}ts{6zd z(<5o0H3S4UL$}Gdj?wT>7F~VVoDm=wk~qj>&n#W0^T3~fuiMI#x($2QkFX~>h<*Q` zq}2t5h!BQ*FK$rH$LH`?q<7Xz2JPo88Vk1Cc9M4tAh)rcO>?|e8_Pz8Uuo83hy(Ol zv2Y1FmwY(dl?bvZBcXyy-uk{2<-rK}q>^j=SFpu792bzQXmO_LECbsGAbSlsv%Ew{ z6*St5p!q?k!b9+3b4GyN!&CXzm)BTEodcjj+dtOrBWcK3n~!k~;W*Zy+pz4r9Y#qD ziu^0PLk#_W0H1k76k)T9O{DSB6p_c$*8qDr7IniYXG@>Te*Jf6V-h*!IE^J*lU0a z#Ntr*K~2&)8B3x8?aNIgo5?v21plZFNm2Dx=D7mp?1O^@hYxiQ{Y>xM2+ZF)stm#>=P|M(9R zsV-23Uzrn5kL~*bhm3!8(mFwO5uHT0G@q}4D_SFCY002ovPDHLkV1iiI!v6pO literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_vite.png new file mode 100644 index 0000000000000000000000000000000000000000..7f3ac3015451d5201550f3d07a198b81701b9e12 GIT binary patch literal 1556 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_wasm.png new file mode 100644 index 0000000000000000000000000000000000000000..167002a94db9232f048bd875ef27dd63942ef728 GIT binary patch literal 1939 zcmV;E2WP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuC{YgYYRCodHTg{IZMG&v*dF+QM zqOM@nB`7Q!zz@C>K6Zgb4&H(2Rb$Y^c<`n-j^4a@F#ZP`5>3>b2NI1$bYbN~*2L&W z;s;?xTrn>Arpv0C*F8Nwv+uo`dD%E|CfV(-?&_|pUsqN4%)9|;t_Fbyf&ViC*vt4= z_k2Ejb46@W+|L!)vi!p@9Ccj#@7z}SF9jwt9j1)Cq1katE8|tP&x4xBEcdh8wpVuck zimsxw=q@%eEK4r*5&#gsJb8K|0K7L(NRhUYTMMg-@VmTK#+rG=2C-!+5OM)HHaR`6 zp>>Cuq)JIaI97Iim9ef&u|;fh+e|$JfW-VDR^AFA8hvHzOkq!wF^b2Ud8(FTli23Z zlL0^yp3hX3)}Y6q-#6R0M+vWtsVdhWpJ!VJ0Lk|Lz^b$+#8Gu)q90EX9^sWSRpt8Q z^K5JROz~Fdw^pacAPuzcMK%z_V199E)x!FN>u<@-vQtRHlbCNuZa;9_BLl=X-;xXf zWwtth(r$DV*Qz}}+P(%?KDidLMuEw`h}3Ol^}amYG9gf1Ouc4k0I1h`4PM*E0|t+2 zy=H7YpkC`Wcx@XG7(Ax+nz8YKdac*swQW3L@R-(X#>NBcwO)hQw()?$V_L5n8xN@0 zdJSIN%rn7WA}CZJ^YhxZ9lka{=-jXPU0iBhsCEOwTc(YGPN1SU3~fr%>NS4+dBo6~ z=mizfjG-aK!+`j@rEP^+EINxXI-M&|BGxL_X5A z+fN$TbN@YVn_K7C4Ey+1c;L2`sV55bIa&kG^3vFqBES>?K@c!aBRSJ;b=#2C<1S4E zDF6V~PPd=y*ZJ>;X=4xIS8XcoX{NJ4Sr~vg;o-NAjE$+4(CPTMe40`K@alGrf7IFq zU>=k{#0mE~J6}}6%3)9#0FlcH%O*nJ5r&jf4;jVy<&K*Ge>d%Hunh}&O@@F8AbIic zBtVuIg&l+c1J8HW%*nItH}$e*DF6@?07S4RWy65`EC2-hM4}juheVwhuO!LucH8I0 zKZNu!_uXasiDCdc^cSX`%Lg>UMu#q3Ed_uMQ(GYmjT2#%xByohEeQ1o>@Ie%4s@_t z`Z)d{GdxoI@lP1I?hou?GlrkRIBn`u@?4d?pcDYA1wXT4zZpe1Aw?d*rCojQ`$?g? zLP(bkADbGxeXso=$ld;Kh8S|q z>=qs3YEFRbtfF50$1$CR5hMMzTo#mYQr z`q@KfcqKW$>7wL(x%mIx2=sJRi(2CNk9*?=rD3Nqnu~wnHVN84MBy6KFD0`d;NpF^ zt>1ci05p8V=eUpsMW->g@fknj}s9bw-&liqLZp4*BSm_FA_ICtco|F;|70_89$+FgL( zvX=J%oZ$9 zJpGjHM2hduY+E0d(jg#ob3#kDLMsdxwF;fRpFIWf604O`EQx7s&x`-~{(zGBmxETIFQ>-% z1mdS|6B|?bP*e$kNH{tNB57D+)p+@BB8OFd}+~hrkXP3 z*_Hu7J~OGQeLdCAvn>OFd}9*=*{3c&XRQf?*p&DEPzC_8=bg`XTvpKjvnICnR8wpb zoAMYlyFkJuA5Be<%a;~OTm5RX^0D#xzeVo?Kx}N)&*qjJvZ9~OEw_G+yas^=f&Uf) Z{{jdXIIqh6p^g9m002ovPDHLkV1f$AiOK)~ literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_webpack.png new file mode 100644 index 0000000000000000000000000000000000000000..838d008ff805627f0c1721d078eb1c93f07d7d12 GIT binary patch literal 3337 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 JPDHLkV1feH#ryyO literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zig.png new file mode 100644 index 0000000000000000000000000000000000000000..64b10efb9aaf174831924f7737502033b719341e GIT binary patch literal 2199 zcmV;I2x#|-P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuE0ZBwbRCodHT6=61MHrvi>kCQ& zDfkve?A>X4SJe6pF%mTzl@`IOV0vu!Kw1T&27sY&G}q~DoY6GO@+0- zvUu1|IRdu3ZD(gO@)##%0j0_qfZq}3haUiV6sfOMhJeO*aqSQ zJ-xxw;)Vm=YUux#40{4v+}0Z`oVo}lw+Kiy8r3-OAz?bRxM9yeE%2gzSP~!tTmF@{ zyA@7cfIJ1@Racnt`9<}jtP2oRbaXcg*h*C)O75Q*ml`U6-u7dZ3qEX50Gi3 zmf-=0Dd)1rj)S7kTWP%;>{qGfQd2?+TU=D@-z2neuej(EfL{&rF%ZP>MjeHNE-B7?C5COu0&L&@_aA5Po38S*J zGCzg`XNdPyU$DKqE7W~wH0E4#fxQMcZ!&6kAO!5|VLTG_st8fWkIG*I@J^!+Bqh)J zksu3~TgsB;y2P z$O!D*JP2F4`t3Qxu$z5ZUB?eXwz`Z`WXs3K*X+a7n@7;O?b zd?dH{jbD#N-_5vt-S&6k$UQQ;u-mJ2)U?;elWi_Pdt}bo(V9Cy(WbDvy&q;X*8%Z- z(k=5=+MkjI4`nO0!0Q^)5HLIhH1s4@+~%@RCzL;rF09)gU;eB8fzW(iL4yMS83AI! z0`PAGylyW<#NSVPd+)flac_@7e7fSg#&tC$A@JqWbH`;$E?5wdj)lYFQb)ty14)?D zZ8GRRKqNCpXXdWOm)Bpg;L~GCegBmDj_-7lBq=cHJz&C>b`QYcAmcBlyQ;#P_VqHd z$;u2W0a&oig`}I1B<7IcnRt$jGKrMzs;w%Lw6^YXc`VjD~^kV_l1`dgYf+;b5!F zK3`GDYjV!=0G4=L+)n%PW6*@~MB^H|zGCl+-EfK6CF?Vkt z_=)%KRm5A1RLG6dZkzxpV2m%r4@d6^ zUE3=rolh;w`v9bSco}0mxmQj+&f50K`D5GT)*>WmMIzRcoU$a2#xQnQ0eoba*5W!N zS5dM)0{at^b-+kFqz696(KmjIKsI>E`DgJAx)H?7%r=^44D|qj-@5b1_Iby(AqPEn zCHD43n%YgQK-#KhcM?3)PxZN{9U1=Gw)hQdWBJ>vY*9m)<7MQ zZ2uekJAC`JP^lXAW+p=OgRMNSZ3 zza+XtoKS^T;>kUPYilQ-1{7LyPAcf zR=2$zUp^882B!E#YxNLdyx$pH7(U2n&wm%Wb5bY&v1e_!A>! zud{BKIBh1)<(Op{Lqdi2^Ut`}{w+YL+$9{y9&7&`4X)E_0oYg|(X}i@UT}Kay;^cZ zsa)LyG`!0mV{1$RUIfOK_($i(!sEZGXs8)-#Zm;cxvdM)^;E}F=;z%a<&98?)lKpE ze;IP?dux4u;e}K`>L@`#4j({-H8aC59B`)%Bm0j(fii46+Z@IWry>- z76Ej8?zq(emO(gs;9!&go#?+f!x^l!@5ttsiq#$(Bpf2r3e$pV2GR_q8AvmbW?)D% Z@DGpmlo#Pj7Y_gc002ovPDHLkV1ftQ4Icmi literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_zip.png new file mode 100644 index 0000000000000000000000000000000000000000..4f668ea490f10c8f5d8118150e7a3f4f5b448dac GIT binary patch literal 1081 zcmV-91jhS`P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAu9qe(D@TBpsXG?F^*5v-9Td`@VT^=IwL5K zPT=F+^}7L$V!oKo0-l|RQ1kwV zQzr7`WMJ-j`Nr}e42@S!w_|hV>-hgYZJ+{tV_oDD4OgAYS2FSda*GL;VsD!IW6LUa ztI}l?5btR?OC#T=N~u#1L--3O8yTpIk42+kIom5)M-lUkg|Cqj3f$fW3g-)q885vQ z78vU}blO5}cQIXQGjf>BWUTkUtM@LPh>m8IDlbtPPW3cg=g4bI?B^kIo<`0VDXzY8 zzWTAH@9WP}jHA_ymO5V-42uCon!^kVWig+PL^}*wNKAH}msMCvIr0J&lX-1&;i6dC zWNe|8DtLuF;(*9$WRL}t00CSibaqzj!a`sPAlztBO7DVSH){qo16!N{oa&8u{+oRx zW7K>nQS=)p$g6-9Z<+upngT4OJs`k?D+Nt^fD}yu7SbNzmBHm4lJt9FaqEU@ne9vI za(DXmWOt+!7$2t3eI??s$>rLB}OR@ zo~Y92I|2l7`oF@~CSVa^LX`&?s$+VibA~ElvU+?O0y{Yzkp<``=Su=O!euvLvhBVj zfR@1y+Tu}0K*hJjToa+Zp|lergpv{==Ny-(&c3ZU0a=5Hh$P()(NUveD+6OGNX@K# znqR7Et*>Fo=*2TgAJD>`nE#fev zB3@(o$q~ET+D~9gvrL{V!FKw#Ag$?Xj|&7i>!Z|35$(M z!eX0%3j?qkpPfEK6XFAZhXaK&VGvjZCJFrOkH>z#d9aCvLsuEclhnG8lu%0M>9Q +#import + +#ifndef T3MarkdownTextNativeComponent_h +#define T3MarkdownTextNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN +@interface T3MarkdownText : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END + +#endif /* UitextviewViewNativeComponent_h */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm new file mode 100644 index 00000000000..3ebfdb7a11e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -0,0 +1,688 @@ +#import "T3MarkdownText.h" +#import "T3MarkdownTextShadowNode.h" +#import "T3MarkdownTextComponentDescriptor.h" +#import "T3MarkdownTextRun.h" +#import +#import + +#import +#import +#import +#import +#import "RCTFabricComponentsPlugins.h" + +using namespace facebook::react; + +static void T3MarkdownTextApplyParagraphStyles( + NSMutableAttributedString *attributedString, + const std::vector &styleRanges) +{ + for (const auto &styleRange : styleRanges) { + if (styleRange.length == 0 || styleRange.location >= attributedString.length) { + continue; + } + + const NSRange markerRange = NSMakeRange( + styleRange.location, + MIN(styleRange.length, attributedString.length - styleRange.location)); + const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; + const NSParagraphStyle *existingStyle = + [attributedString attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:nil]; + NSMutableParagraphStyle *paragraphStyle = + existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; + paragraphStyle.headIndent = styleRange.headIndent; + paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + paragraphStyle.tabStops = @[ + [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + location:styleRange.headIndent + options:@{}] + ]; + paragraphStyle.defaultTabInterval = styleRange.headIndent; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:paragraphStyle + range:paragraphRange]; + } +} + +static void T3MarkdownTextApplyAttachments( + NSMutableAttributedString *attributedString, + const std::vector &attachmentRanges, + NSDictionary *images) +{ + for (const auto &attachmentRange : attachmentRanges) { + if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { + continue; + } + + NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + UIImage *image = images[imageUri]; + if ([imageUri hasPrefix:@"sf:"]) { + NSString *symbolName = [imageUri substringFromIndex:3]; + UIColor *foregroundColor = + [attributedString attribute:NSForegroundColorAttributeName + atIndex:attachmentRange.location + effectiveRange:nil] ?: UIColor.labelColor; + image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor + renderingMode:UIImageRenderingModeAlwaysOriginal]; + } + attachment.image = image ?: [[UIImage alloc] init]; + attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const NSRange range = NSMakeRange( + attachmentRange.location, + MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); + NSAttributedString *attachmentString = + [NSAttributedString attributedStringWithAttachment:attachment]; + [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; + } +} + +static NSArray *> *T3MarkdownTextExtractChipBackgrounds( + NSMutableAttributedString *attributedString, + const std::vector &chipRanges) +{ + NSMutableArray *> *backgrounds = [NSMutableArray array]; + for (const auto &chipRange : chipRanges) { + if (chipRange.length == 0 || chipRange.location >= attributedString.length) { + continue; + } + + const NSRange range = NSMakeRange( + chipRange.location, + MIN(chipRange.length, attributedString.length - chipRange.location)); + UIColor *color = [attributedString attribute:NSBackgroundColorAttributeName + atIndex:range.location + effectiveRange:nil]; + UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName + atIndex:range.location + effectiveRange:nil]; + if (color == nil) { + continue; + } + [backgrounds addObject:@{ + @"range": [NSValue valueWithRange:range], + @"color": color, + @"strokeColor": [foregroundColor + colorWithAlphaComponent:chipRange.isSkill ? 0.25 : 0.1] ?: UIColor.clearColor, + }]; + [attributedString removeAttribute:NSBackgroundColorAttributeName range:range]; + } + return backgrounds; +} + +@interface T3MarkdownTextBackingView : UITextView +@property(nonatomic, copy) NSArray *> *chipBackgrounds; +@end + +@implementation T3MarkdownTextBackingView + +- (void)drawRect:(CGRect)rect +{ + [self.layoutManager ensureLayoutForTextContainer:self.textContainer]; + CGContextRef context = UIGraphicsGetCurrentContext(); + if (context != nil) { + CGContextSaveGState(context); + CGContextResetClip(context); + CGContextClipToRect(context, self.bounds); + } + for (NSDictionary *background in self.chipBackgrounds) { + const NSRange characterRange = [background[@"range"] rangeValue]; + UIColor *color = background[@"color"]; + UIColor *strokeColor = background[@"strokeColor"]; + if (characterRange.length == 0 || NSMaxRange(characterRange) > self.textStorage.length) { + continue; + } + + const NSRange glyphRange = + [self.layoutManager glyphRangeForCharacterRange:characterRange actualCharacterRange:nil]; + [color setFill]; + [self.layoutManager + enumerateEnclosingRectsForGlyphRange:glyphRange + withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0) + inTextContainer:self.textContainer + usingBlock:^(CGRect glyphRect, BOOL *stop) { + const CGFloat chipHeight = 22; + CGRect chipRect = CGRectMake( + glyphRect.origin.x - 4, + CGRectGetMidY(glyphRect) - chipHeight / 2, + glyphRect.size.width + 8, + chipHeight); + chipRect.origin.x += self.textContainerInset.left; + chipRect.origin.y += self.textContainerInset.top; + const CGFloat minimumX = self.textContainerInset.left + 0.5; + const CGFloat maximumX = + CGRectGetWidth(self.bounds) - self.textContainerInset.right - 0.5; + if (chipRect.origin.x < minimumX) { + chipRect.size.width -= minimumX - chipRect.origin.x; + chipRect.origin.x = minimumX; + } + if (CGRectGetMaxX(chipRect) > maximumX) { + chipRect.size.width = MAX(0, maximumX - chipRect.origin.x); + } + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect cornerRadius:6]; + [path fill]; + [strokeColor setStroke]; + path.lineWidth = 1; + [path stroke]; + }]; + } + if (context != nil) { + CGContextRestoreGState(context); + } + + [super drawRect:rect]; +} + +@end + +@protocol T3MarkdownOutsideTapTarget +- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView; +@end + +@interface T3MarkdownOutsideTapCoordinator : NSObject + +- (instancetype)initWithWindow:(UIWindow *)window; +- (void)addTarget:(id)target; +- (void)removeTarget:(id)target; + +@end + +static const void *T3MarkdownOutsideTapCoordinatorKey = + &T3MarkdownOutsideTapCoordinatorKey; + +@implementation T3MarkdownOutsideTapCoordinator { + __weak UIWindow *_window; + UITapGestureRecognizer *_recognizer; + NSHashTable> *_targets; +} + +- (instancetype)initWithWindow:(UIWindow *)window +{ + if (self = [super init]) { + _window = window; + _targets = [NSHashTable weakObjectsHashTable]; + _recognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handleTap:)]; + _recognizer.cancelsTouchesInView = NO; + _recognizer.delegate = self; + [window addGestureRecognizer:_recognizer]; + } + return self; +} + +- (void)addTarget:(id)target +{ + [_targets addObject:target]; +} + +- (void)removeTarget:(id)target +{ + [_targets removeObject:target]; + if (_targets.count > 0) { + return; + } + + UIWindow *window = _window; + [window removeGestureRecognizer:_recognizer]; + if (objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey) == self) { + objc_setAssociatedObject( + window, + T3MarkdownOutsideTapCoordinatorKey, + nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } +} + +- (void)handleTap:(UITapGestureRecognizer *)sender +{ + UIWindow *window = _window; + if (window == nil) { + return; + } + + UIView *hitView = [window hitTest:[sender locationInView:window] withEvent:nil]; + if (hitView == nil) { + return; + } + for (id target in _targets.allObjects) { + [target clearSelectionForOutsideTapWithHitView:hitView]; + } +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer + shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer +{ + return YES; +} + +@end + +static T3MarkdownOutsideTapCoordinator * +T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window) +{ + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(window, T3MarkdownOutsideTapCoordinatorKey); + if (coordinator == nil) { + coordinator = [[T3MarkdownOutsideTapCoordinator alloc] initWithWindow:window]; + objc_setAssociatedObject( + window, + T3MarkdownOutsideTapCoordinatorKey, + coordinator, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + return coordinator; +} + +@interface T3MarkdownText () + +@end + +@interface T3MarkdownText () +@end + +@implementation T3MarkdownText { + UIView * _view; + T3MarkdownTextBackingView * _textView; + T3MarkdownTextShadowNode::ConcreteState::Shared _state; + __weak UIWindow * _outsideTapWindow; + BOOL _suppressSelectionChange; + NSMutableDictionary * _attachmentImages; + NSMutableSet * _pendingAttachmentUris; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _view = [[UIView alloc] init]; + self.contentView = _view; + self.clipsToBounds = true; + + _textView = [[T3MarkdownTextBackingView alloc] init]; + _attachmentImages = [[NSMutableDictionary alloc] init]; + _pendingAttachmentUris = [[NSMutableSet alloc] init]; + _textView.scrollEnabled = false; + _textView.editable = false; + _textView.textContainerInset = UIEdgeInsetsZero; + _textView.textContainer.lineFragmentPadding = 0; + _textView.delegate = self; + // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. + _textView.layoutManager.usesFontLeading = NO; + [self addSubview:_textView]; + + const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(handleLongPressIfNecessary:)]; + longPressGestureRecognizer.delegate = self; + + const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handlePressIfNecessary:)]; + pressGestureRecognizer.delegate = self; + [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer]; + + [_textView addGestureRecognizer:pressGestureRecognizer]; + [_textView addGestureRecognizer:longPressGestureRecognizer]; + } + + return self; +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if (_outsideTapWindow == self.window) { + return; + } + if (_outsideTapWindow != nil) { + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; + } + _outsideTapWindow = self.window; + if (_outsideTapWindow != nil) { + [T3MarkdownOutsideTapCoordinatorForWindow(_outsideTapWindow) addTarget:self]; + } +} + +- (void)dealloc +{ + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; +} + +// See RCTParagraphComponentView +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + T3MarkdownOutsideTapCoordinator *coordinator = + objc_getAssociatedObject(_outsideTapWindow, T3MarkdownOutsideTapCoordinatorKey); + [coordinator removeTarget:self]; + _outsideTapWindow = nil; + _state.reset(); + + // Reset the frame to zero so that when it properly lays out on the next use + _textView.frame = CGRectZero; + _textView.attributedText = nil; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + // _textView's frame is assigned inside drawRect, which only fires when + // state changes. Trigger a redraw whenever the host frame moves out from + // under it (rotation, parent relayout) so the text view resizes and + // onTextLayout re-fires with the new line wrapping. + if (!CGRectEqualToRect(_textView.frame, _view.frame)) { + [self setNeedsDisplay]; + } +} + +- (void)drawRect:(CGRect)rect +{ + if (!_state) { + return; + } + + const auto &props = *std::static_pointer_cast(_props); + + const auto attrString = _state->getData().attributedString; + NSMutableAttributedString *convertedAttrString = + [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy]; + T3MarkdownTextApplyParagraphStyles( + convertedAttrString, + _state->getData().paragraphStyleRanges); + T3MarkdownTextApplyAttachments( + convertedAttrString, + _state->getData().attachmentRanges, + _attachmentImages); + _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds( + convertedAttrString, + _state->getData().chipRanges); + [self loadAttachmentImages:_state->getData().attachmentRanges]; + + // Setting attributedText clears any active text selection, and re-assigning + // the frame triggers a layout flush that has the same effect. Bail out + // entirely when nothing actually changed so a JS-side state update made in + // response to onSelectionChange doesn't deselect what the user is selecting. + const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; + const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); + if (!textChanged && !frameChanged) { + return; + } + if (textChanged) { + // Reassigning attributedText clears any active selection. Save it and + // restore after, while suppressing the synthetic textViewDidChangeSelection + // events the clear-then-restore would otherwise produce — those would + // round-trip to JS and re-trigger this same path, causing a loop. + const NSRange savedRange = _textView.selectedRange; + _suppressSelectionChange = YES; + _textView.attributedText = convertedAttrString; + if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) { + _textView.selectedRange = savedRange; + } + _suppressSelectionChange = NO; + } + if (frameChanged) { + _textView.frame = _view.frame; + } + + __block std::vector lines; + const int maxLines = props.numberOfLines; + [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect, + CGRect usedRect, + NSTextContainer * _Nonnull textContainer, + NSRange glyphRange, + BOOL * _Nonnull stop) { + const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil]; + const auto line = [self->_textView.text substringWithRange:charRange]; + lines.push_back(line.UTF8String); + // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS + // 18, so cap explicitly. + if (maxLines > 0 && lines.size() >= (size_t)maxLines) { + *stop = YES; + } + }]; + + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast(self.tag), lines}); + }; +} + +- (void)loadAttachmentImages:(const std::vector &)attachmentRanges +{ + for (const auto &attachmentRange : attachmentRanges) { + NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; + if ([imageUri hasPrefix:@"sf:"]) { + continue; + } + if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) { + continue; + } + + NSURL *url = [NSURL URLWithString:imageUri]; + if (url == nil) { + continue; + } + if (url.isFileURL) { + UIImage *image = [UIImage imageWithContentsOfFile:url.path]; + if (image != nil) { + _attachmentImages[imageUri] = image; + dispatch_async(dispatch_get_main_queue(), ^{ + [self refreshDisplayedAttachments]; + }); + } + continue; + } + + [_pendingAttachmentUris addObject:imageUri]; + [[[NSURLSession sharedSession] dataTaskWithURL:url + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + UIImage *image = data == nil ? nil : [UIImage imageWithData:data]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_pendingAttachmentUris removeObject:imageUri]; + if (image != nil) { + self->_attachmentImages[imageUri] = image; + [self refreshDisplayedAttachments]; + } + }); + }] resume]; + } +} + +- (void)refreshDisplayedAttachments +{ + if (!_state || _textView.attributedText == nil) { + return; + } + + NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy]; + T3MarkdownTextApplyAttachments( + attributedText, + _state->getData().attachmentRanges, + _attachmentImages); + + const NSRange savedRange = _textView.selectedRange; + _suppressSelectionChange = YES; + _textView.attributedText = attributedText; + if (savedRange.location != NSNotFound && + NSMaxRange(savedRange) <= _textView.attributedText.length) { + _textView.selectedRange = savedRange; + } + _suppressSelectionChange = NO; + [_textView setNeedsDisplay]; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &oldViewProps = *std::static_pointer_cast(_props); + const auto &newViewProps = *std::static_pointer_cast(props); + + if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { + _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; + } + + if (oldViewProps.selectable != newViewProps.selectable) { + _textView.selectable = newViewProps.selectable; + } + + if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { + if (@available(iOS 11.0, *)) { + _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; + } + } + + if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { + if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping; + } + } + + + // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text + // is in will have this background color applied. To apply it just to a particular part of a string, you'd need + // to do Hello. + // This is how the base component works though, so we'll go with it for now. Can change later if we want. + if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { + _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor); + } + + [super updateProps:props oldProps:oldProps]; +} + +// See RCTParagraphComponentView +- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState +{ + _state = std::static_pointer_cast(state); + [self setNeedsDisplay]; +} + +// MARK: - UIGestureRecognizerDelegate + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer +{ + return YES; +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch +{ + return YES; +} + +- (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView +{ + if ([hitView isDescendantOfView:self]) { + return; + } + // Defer past the current event loop turn so any in-flight edit-menu action + // (Copy / Define / Look Up / …) reads the live selection before we clear it. + UITextView *textView = _textView; + dispatch_async(dispatch_get_main_queue(), ^{ + UITextRange *range = textView.selectedTextRange; + if (range != nil && !range.isEmpty) { + textView.selectedTextRange = nil; + } + }); +} + +// MARK: - Touch handling + +- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender +{ + return [sender locationInView:_textView]; +} + +- (T3MarkdownTextRun*)getTouchChild:(CGPoint)location +{ + const auto charIndex = [_textView.layoutManager characterIndexForPoint:location + inTextContainer:_textView.textContainer + fractionOfDistanceBetweenInsertionPoints:nil + ]; + + int currIndex = -1; + for (UIView* child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child; + + // This is UTF16 code units!! + currIndex += textChild.text.length; + + if (charIndex <= currIndex) { + return textChild; + } + } + + return nil; +} + +- (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender +{ + const auto location = [self getLocationOfPress:sender]; + const auto child = [self getTouchChild:location]; + + if (child) { + [child onPress]; + } +} + +- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender +{ + const auto location = [self getLocationOfPress:sender]; + const auto child = [self getTouchChild:location]; + + if (child) { + [child onLongPress]; + } +} + +// MARK: - UITextViewDelegate + +- (void)textViewDidChangeSelection:(UITextView *)textView +{ + if (_suppressSelectionChange) { + return; + } + if (_eventEmitter == nullptr) { + return; + } + + const NSRange selectedRange = textView.selectedRange; + if (selectedRange.location == NSNotFound) { + return; + } + + // Fires on programmatic selection changes too (e.g. the outside-tap clear + // in handleOutsideTap:), so JS will see a synthetic empty-range event then. + std::dynamic_pointer_cast(_eventEmitter) + ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{ + static_cast(self.tag), + static_cast(selectedRange.location), + static_cast(selectedRange.location + selectedRange.length), + }); +} + +Class T3MarkdownTextCls(void) +{ + return T3MarkdownText.class; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h new file mode 100644 index 00000000000..77e21d58510 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "T3MarkdownTextShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor; + +void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm new file mode 100644 index 00000000000..3ca2b1eee5b --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm @@ -0,0 +1,36 @@ +#import +#import +#import "RCTBridge.h" +#import "Utils.h" + +@interface T3MarkdownTextManager : RCTViewManager +@end + +@implementation T3MarkdownTextManager + +RCT_EXPORT_MODULE(T3MarkdownText) + +- (UIView *)view +{ + return [[UIView alloc] init]; +} + +RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView) +{ +} + +@end + +@interface T3MarkdownTextRunManager : RCTViewManager +@end + +@implementation T3MarkdownTextRunManager + +RCT_EXPORT_MODULE(T3MarkdownTextRun) + +- (UIView *)view +{ + return nil; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h new file mode 100644 index 00000000000..b8b40657110 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h @@ -0,0 +1,24 @@ +// This guard prevent this file to be compiled in the old architecture. +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import +#import + +#ifndef T3MarkdownTextRunNativeComponent_h +#define T3MarkdownTextRunNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN + +@interface T3MarkdownTextRun : RCTViewComponentView + +@property (nonatomic, copy, nullable) NSString *text; + +- (void)onPress; +- (void)onLongPress; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* UitextviewViewNativeComponent_h */ +#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm new file mode 100644 index 00000000000..4549084f03f --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm @@ -0,0 +1,72 @@ +#import "T3MarkdownTextRun.h" +#import "T3MarkdownText.h" +#import "T3MarkdownTextRunComponentDescriptor.h" +#import +#import +#import +#import "RCTFabricComponentsPlugins.h" +#import "Utils.h" + +using namespace facebook::react; + +@interface T3MarkdownTextRun () + +@end + +@implementation T3MarkdownTextRun { + NSString * _text; + RCTBubblingEventBlock _onPress; + RCTBubblingEventBlock _onLongPress; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + } + return self; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &oldViewProps = *std::static_pointer_cast(_props); + const auto &newViewProps = *std::static_pointer_cast(props); + + if (newViewProps.text != oldViewProps.text) { + NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; + _text = text; + } + + [super updateProps:props oldProps:oldProps]; +} + +- (void)onPress { + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{}); + } +} + +- (void)onLongPress { + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{}); + } +} + ++ (BOOL)shouldBeRecycled { + return NO; +} + +Class T3MarkdownTextRunCls(void) +{ + return T3MarkdownTextRun.class; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h new file mode 100644 index 00000000000..61f9e1a129e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "T3MarkdownTextRunShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor; + +void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp new file mode 100644 index 00000000000..a1af619205d --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp @@ -0,0 +1,6 @@ +#include "T3MarkdownTextRunShadowNode.h" + +namespace facebook::react { + +extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun"; +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h new file mode 100644 index 00000000000..c00bd1f2407 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include + +namespace facebook::react { +extern const char T3MarkdownTextRunComponentName[]; + +using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode< + T3MarkdownTextRunComponentName, + T3MarkdownTextRunProps, + T3MarkdownTextRunEventEmitter, + T3MarkdownTextRunState>; +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h new file mode 100644 index 00000000000..afc276aedda --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace facebook::react { + +extern const char T3MarkdownTextComponentName[]; + +struct T3MarkdownTextParagraphStyleRange { + size_t location; + size_t length; + Float firstLineHeadIndent; + Float headIndent; + Float paragraphSpacing; +}; + +struct T3MarkdownTextAttachmentRange { + size_t location; + size_t length; + std::string imageUri; +}; + +struct T3MarkdownTextChipRange { + size_t location; + size_t length; + bool isSkill; +}; + +class T3MarkdownTextStateReal final { + public: + AttributedString attributedString; + std::vector paragraphStyleRanges; + std::vector attachmentRanges; + std::vector chipRanges; +}; + +class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< +T3MarkdownTextComponentName, +T3MarkdownTextProps, +T3MarkdownTextEventEmitter, +T3MarkdownTextStateReal> { +public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + T3MarkdownTextShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment + ); + + static ShadowNodeTraits BaseTraits() { + auto traits = ConcreteViewShadowNode::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; + } + + void layout(LayoutContext layoutContext) override; + + Size measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const override; + +private: + mutable AttributedString _attributedString; + mutable std::vector _paragraphStyleRanges; + mutable std::vector _attachmentRanges; + mutable std::vector _chipRanges; +}; +} // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm new file mode 100644 index 00000000000..00fda742284 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -0,0 +1,269 @@ +#include "T3MarkdownTextShadowNode.h" +#include "T3MarkdownTextRunShadowNode.h" +#include +#import + +#include +#include + +namespace facebook::react { + +static constexpr Float ParagraphStyleEncodingOffset = 1000; +static constexpr auto ChipNativeIdPrefix = "t3-chip-"; +static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:"; +static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:"; + +static void applyParagraphStyles( + NSMutableAttributedString *attributedString, + const std::vector &styleRanges) +{ + for (const auto &styleRange : styleRanges) { + if (styleRange.length == 0 || styleRange.location >= attributedString.length) { + continue; + } + + const NSRange markerRange = NSMakeRange( + styleRange.location, + MIN(styleRange.length, attributedString.length - styleRange.location)); + const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; + const NSParagraphStyle *existingStyle = + [attributedString attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:nil]; + NSMutableParagraphStyle *paragraphStyle = + existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; + paragraphStyle.headIndent = styleRange.headIndent; + paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + paragraphStyle.tabStops = @[ + [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + location:styleRange.headIndent + options:@{}] + ]; + paragraphStyle.defaultTabInterval = styleRange.headIndent; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:paragraphStyle + range:paragraphRange]; + } +} + +static void applyAttachments( + NSMutableAttributedString *attributedString, + const std::vector &attachmentRanges) +{ + for (const auto &attachmentRange : attachmentRanges) { + if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { + continue; + } + + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + attachment.image = [[UIImage alloc] init]; + attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const NSRange range = NSMakeRange( + attachmentRange.location, + MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); + NSAttributedString *attachmentString = + [NSAttributedString attributedStringWithAttachment:attachment]; + [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; + } +} + +T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment +) : ConcreteViewShadowNode(sourceShadowNode, fragment) { +}; + +Size T3MarkdownTextShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + + auto baseTextAttributes = TextAttributes::defaultTextAttributes(); + baseTextAttributes.backgroundColor = baseProps.backgroundColor; + baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; + + Float fontSizeMultiplier = 1.0; + if (baseTextAttributes.allowFontScaling) { + fontSizeMultiplier = layoutContext.fontSizeMultiplier; + } + + auto baseAttributedString = AttributedString{}; + auto paragraphStyleRanges = std::vector{}; + auto attachmentRanges = std::vector{}; + auto chipRanges = std::vector{}; + size_t utf16Offset = 0; + const auto &children = getChildren(); + for (size_t i = 0; i < children.size(); i++) { + const auto child = children[i].get(); + if (auto textViewChild = dynamic_cast(child)) { + auto &props = textViewChild->getConcreteProps(); + auto fragment = AttributedString::Fragment{}; + auto textAttributes = TextAttributes::defaultTextAttributes(); + + textAttributes.allowFontScaling = baseProps.allowFontScaling; + textAttributes.backgroundColor = props.backgroundColor; + textAttributes.fontSize = props.fontSize * fontSizeMultiplier; + textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; + textAttributes.foregroundColor = props.color; + const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset; + if (!hasParagraphStyle) { + textAttributes.textShadowColor = props.shadowColor; + textAttributes.textShadowOffset = props.shadowOffset; + textAttributes.textShadowRadius = props.shadowRadius; + } + textAttributes.letterSpacing = props.letterSpacing; + textAttributes.textDecorationColor = props.textDecorationColor; + textAttributes.fontFamily = props.fontFamily; + + if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) { + textAttributes.fontStyle = FontStyle::Italic; + } else { + textAttributes.fontStyle = FontStyle::Normal; + } + + if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) { + textAttributes.fontWeight = FontWeight::Bold; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) { + textAttributes.fontWeight = FontWeight::UltraLight; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) { + textAttributes.fontWeight = FontWeight::Light; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) { + textAttributes.fontWeight = FontWeight::Medium; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) { + textAttributes.fontWeight = FontWeight::Semibold; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) { + textAttributes.fontWeight = FontWeight::Heavy; + } else { + textAttributes.fontWeight = FontWeight::Regular; + } + + if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) { + textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough; + } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) { + textAttributes.textDecorationLineType = TextDecorationLineType::Underline; + } else { + textAttributes.textDecorationLineType = TextDecorationLineType::None; + } + + if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) { + textAttributes.textDecorationStyle = TextDecorationStyle::Solid; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) { + textAttributes.textDecorationStyle = TextDecorationStyle::Double; + } + + if (props.textAlign == T3MarkdownTextRunTextAlign::Left) { + textAttributes.alignment = TextAlignment::Left; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) { + textAttributes.alignment = TextAlignment::Right; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) { + textAttributes.alignment = TextAlignment::Center; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) { + textAttributes.alignment = TextAlignment::Justified; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) { + textAttributes.alignment = TextAlignment::Natural; + } + + textAttributes.backgroundColor = props.backgroundColor; + + fragment.string = props.text; + fragment.textAttributes = textAttributes; + + NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()]; + const size_t fragmentLength = fragmentText.length; + if (hasParagraphStyle) { + paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{ + utf16Offset, + fragmentLength, + props.shadowOffset.width, + props.shadowOffset.height, + props.shadowRadius - ParagraphStyleEncodingOffset, + }); + } + if (props.nativeId.rfind(ChipNativeIdPrefix, 0) == 0 && fragmentLength > 0) { + chipRanges.push_back(T3MarkdownTextChipRange{ + utf16Offset, + fragmentLength, + props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0, + }); + } + if (props.nativeId.rfind(FileChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset + 1, + 1, + props.nativeId.substr(std::char_traits::length(FileChipNativeIdPrefix)), + }); + } else if ( + props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset + 1, + 1, + props.nativeId.substr(std::char_traits::length(SkillChipNativeIdPrefix)), + }); + } + utf16Offset += fragmentLength; + baseAttributedString.appendFragment(std::move(fragment)); + } + } + + _attributedString = baseAttributedString; + _paragraphStyleRanges = paragraphStyleRanges; + _attachmentRanges = attachmentRanges; + _chipRanges = chipRanges; + + NSMutableAttributedString *convertedAttributedString = + [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; + applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); + applyAttachments(convertedAttributedString, attachmentRanges); + + const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) + ? layoutConstraints.maximumSize.width + : CGFLOAT_MAX; + NSTextStorage *textStorage = + [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString]; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + layoutManager.usesFontLeading = NO; + NSTextContainer *textContainer = + [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; + textContainer.lineFragmentPadding = 0; + textContainer.maximumNumberOfLines = baseProps.numberOfLines; + if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { + textContainer.lineBreakMode = NSLineBreakByTruncatingHead; + } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { + textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; + } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { + textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + } else { + textContainer.lineBreakMode = NSLineBreakByClipping; + } + [layoutManager addTextContainer:textContainer]; + [textStorage addLayoutManager:layoutManager]; + [layoutManager ensureLayoutForTextContainer:textContainer]; + const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; + + return { + std::clamp( + static_cast(std::ceil(usedRect.size.width)), + layoutConstraints.minimumSize.width, + layoutConstraints.maximumSize.width), + std::clamp( + static_cast(std::ceil(usedRect.size.height)), + layoutConstraints.minimumSize.height, + layoutConstraints.maximumSize.height), + }; +} + +void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { + ensureUnsealed(); + setStateData(T3MarkdownTextStateReal{ + _attributedString, + _paragraphStyleRanges, + _attachmentRanges, + _chipRanges, + }); +} +} diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json new file mode 100644 index 00000000000..d51b6c5d9ff --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -0,0 +1,51 @@ +{ + "name": "@t3tools/mobile-markdown-text", + "version": "0.0.0", + "private": true, + "source": "./index.ts", + "files": [ + "assets", + "ios", + "src", + "index.ts", + "LICENSE", + "UPSTREAM.md", + "T3MarkdownText.podspec", + "react-native.config.js" + ], + "main": "./index.ts", + "types": "./index.ts", + "react-native": "./index.ts", + "exports": { + ".": "./index.ts", + "./file-icons": "./src/markdownFileIcons.ts", + "./links": "./src/markdownLinks.ts", + "./markdown": "./src/nativeMarkdownText.ts", + "./primitive": "./src/MarkdownTextPrimitive.tsx", + "./renderer": "./src/SelectableMarkdownText.ios.tsx", + "./types": "./src/SelectableMarkdownText.types.ts" + }, + "peerDependencies": { + "expo-asset": "*", + "expo-clipboard": "*", + "expo-haptics": "*", + "expo-symbols": "*", + "react": "*", + "react-native": "*", + "react-native-nitro-markdown": "*" + }, + "codegenConfig": { + "name": "T3MarkdownTextSpec", + "type": "all", + "jsSrcsDir": "src", + "ios": { + "componentProvider": { + "T3MarkdownText": "T3MarkdownText", + "T3MarkdownTextRun": "T3MarkdownTextRun" + } + }, + "outputDir": { + "ios": "ios/generated" + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/react-native.config.js b/apps/mobile/modules/t3-markdown-text/react-native.config.js new file mode 100644 index 00000000000..6b10ea26eec --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/react-native.config.js @@ -0,0 +1,10 @@ +module.exports = { + dependency: { + platforms: { + ios: { + podspecPath: "T3MarkdownText.podspec", + }, + android: null, + }, + }, +}; diff --git a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs new file mode 100644 index 00000000000..87f17c28e0f --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs @@ -0,0 +1,133 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { getBuiltInSpriteSheet } from "@pierre/trees"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const moduleDirectory = resolve(scriptDirectory, ".."); +const repositoryRoot = resolve(moduleDirectory, "../../../.."); +const outputDirectory = join(moduleDirectory, "assets/file-icons"); +const generatedModulePath = join(moduleDirectory, "src/markdownFileIcons.generated.ts"); +const webIconSource = readFileSync(join(repositoryRoot, "apps/web/src/pierre-icons.ts"), "utf8"); +const customSprite = webIconSource.match(/const T3_FILE_ICON_SPRITE = `([\s\S]*?)`;/)?.[1]; + +if (!customSprite) { + throw new Error("Could not read the T3 Pierre icon sprite from apps/web/src/pierre-icons.ts"); +} + +const colors = { + astro: "#a631be", + babel: "#d5a910", + bash: "#199f43", + biome: "#1a85d4", + bootstrap: "#693acf", + browserslist: "#d5a910", + bun: "#594c5b", + c: "#1a85d4", + claude: "#d47628", + cpp: "#1a85d4", + css: "#693acf", + database: "#a631be", + default: "#84848a", + docker: "#1a85d4", + eslint: "#693acf", + font: "#84848a", + git: "#ff8c5b", + go: "#1ca1c7", + graphql: "#d32a61", + html: "#d47628", + image: "#d32a61", + javascript: "#d5a910", + json: "#d47628", + markdown: "#199f43", + mcp: "#17a5af", + nextjs: "#84848a", + npm: "#d52c36", + oxc: "#1ca1c7", + postcss: "#d52c36", + prettier: "#17a5af", + python: "#1a85d4", + react: "#1ca1c7", + ruby: "#d52c36", + rust: "#d47628", + sass: "#d32a61", + stylelint: "#84848a", + svelte: "#d52c36", + svg: "#d47628", + svgo: "#199f43", + swift: "#d47628", + table: "#17a5af", + tailwind: "#1ca1c7", + terraform: "#693acf", + text: "#84848a", + typescript: "#1a85d4", + vite: "#a631be", + vscode: "#1a85d4", + vue: "#199f43", + wasm: "#693acf", + webpack: "#1a85d4", + yml: "#d52c36", + zig: "#d47628", + zip: "#d47628", +}; + +const customIcons = { + agents: "t3-file-icon-agents", + claude: "t3-file-icon-claude", + package: "t3-file-icon-package-json", + pnpm: "t3-file-icon-pnpm", + readme: "t3-file-icon-readme", + tsconfig: "t3-file-icon-tsconfig", +}; + +function symbolFromSprite(sprite, id) { + const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = sprite.match( + new RegExp(`]*)>([\\s\\S]*?)<\\/symbol>`), + ); + if (!match) throw new Error(`Missing Pierre icon symbol: ${id}`); + return { + body: match[2], + viewBox: match[1].match(/viewBox="([^"]+)"/)?.[1] ?? "0 0 16 16", + }; +} + +function renderIcon(token, symbol, color) { + const svgPath = join(outputDirectory, `.pierre-${token}.svg`); + const pngPath = join(outputDirectory, `pierre_${token}.png`); + writeFileSync( + svgPath, + `${symbol.body}`, + ); + execFileSync("sips", ["-s", "format", "png", svgPath, "--out", pngPath], { + stdio: "ignore", + }); + rmSync(svgPath); +} + +rmSync(outputDirectory, { recursive: true, force: true }); +mkdirSync(outputDirectory, { recursive: true }); + +const builtInSprite = getBuiltInSpriteSheet("complete"); +const builtInTokens = [...builtInSprite.matchAll(/ match[1]) + .sort(); + +for (const token of builtInTokens) { + renderIcon( + token, + symbolFromSprite(builtInSprite, `file-tree-builtin-${token}`), + colors[token] ?? colors.default, + ); +} +for (const [token, symbolId] of Object.entries(customIcons)) { + renderIcon(token, symbolFromSprite(customSprite, symbolId), colors[token] ?? colors.default); +} + +const tokens = [...new Set([...builtInTokens, ...Object.keys(customIcons)])].sort(); +const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const MARKDOWN_FILE_ICON_SOURCES = {\n${tokens + .map((token) => ` ${token}: require("../assets/file-icons/pierre_${token}.png"),`) + .join("\n")}\n} as const satisfies Readonly>;\n`; +writeFileSync(generatedModulePath, generatedSource); diff --git a/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx new file mode 100644 index 00000000000..ffbc0e2fcb6 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx @@ -0,0 +1,73 @@ +import { SymbolView } from "expo-symbols"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import { memo, useEffect, useRef, useState } from "react"; +import { Pressable, type ColorValue } from "react-native"; + +const COPY_FEEDBACK_DURATION_MS = 1200; + +function copyTextWithHaptic(value: string): void { + void Clipboard.setStringAsync(value); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +} + +export const CopyTextButton = memo(function CopyTextButton(props: { + readonly accessibilityLabel: string; + readonly text: string; + readonly tintColor: ColorValue; + readonly copiedTintColor?: ColorValue; + readonly backgroundColor?: ColorValue; + readonly borderColor?: ColorValue; + readonly iconSize?: number; + readonly buttonSize?: number; +}) { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }, + [], + ); + + return ( + { + copyTextWithHaptic(props.text); + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_FEEDBACK_DURATION_MS); + }} + style={({ pressed }) => ({ + width: props.buttonSize ?? 30, + height: props.buttonSize ?? 30, + alignItems: "center", + justifyContent: "center", + borderRadius: 9, + borderWidth: props.borderColor ? 1 : 0, + borderColor: props.borderColor, + backgroundColor: props.backgroundColor, + opacity: pressed ? 0.52 : 1, + })} + > + + + ); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx new file mode 100644 index 00000000000..6ed7fecd2d3 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; +import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; +import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; +import { flattenStyles } from "./util"; + +const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ + false, + StyleSheet.create({}), +]); + +const textDefaults: TextProps = { + allowFontScaling: true, + selectable: true, +}; + +const useTextAncestorContext = () => React.useContext(TextAncestorContext); + +/** + * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices + * into the rendered string. `start === end` means the selection was cleared. + */ +export type SelectionChangeEvent = { + nativeEvent: { target: number; start: number; end: number }; +}; + +export type MarkdownTextPrimitiveProps = TextProps & { + uiTextView?: boolean; + /** + * Fired when the native text selection changes. Only fires on iOS when + * `uiTextView` is true. Note: fires on every selection-edge adjustment + * (e.g. dragging a selection handle), so consumers driving expensive work + * off this event should debounce. + */ + onSelectionChange?: (event: SelectionChangeEvent) => void; +}; + +function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) { + const [isAncestor, rootStyle] = useTextAncestorContext(); + + // Flatten the styles, and apply the root styles when needed + const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); + const contextValue = React.useMemo<[boolean, ViewStyle]>( + () => [true, flattenedStyle], + [flattenedStyle], + ); + let childPosition = 0; + const nativeChildren = React.Children.toArray(children).map((child) => { + const position = childPosition; + childPosition += 1; + + if (React.isValidElement(child)) { + return child; + } + if (typeof child !== "string" && typeof child !== "number") { + return null; + } + + const text = child.toString(); + return ( + // @ts-expect-error The generated run props do not include inherited Text props. + + ); + }); + + if (!isAncestor) { + return ( + + + {nativeChildren} + + + ); + } + + return <>{nativeChildren}; +} + +function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) { + const [isAncestor] = useTextAncestorContext(); + + // Even if the uiTextView prop is set, we can still default to using + // normal selection (i.e. base RN text) if the text doesn't need to be + // selectable + if ((!props.selectable || !props.uiTextView) && !isAncestor) { + return ; + } + return ; +} + +export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { + if (Platform.OS !== "ios") { + return ; + } + return ; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx new file mode 100644 index 00000000000..757b6c66011 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -0,0 +1,647 @@ +import { useEffect, useState } from "react"; +import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { CopyTextButton } from "./CopyTextButton"; +import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; +import { + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, +} from "./nativeMarkdownText"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, +} from "./SelectableMarkdownText.types"; + +type HighlightedCode = ReadonlyArray>; + +const highlightedCodeCache = new Map(); +const highlightedCodePromiseCache = new Map>(); +const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; + +function nodeKey(node: MarkdownNode, index: number): string { + return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; +} + +function nodeText(node: MarkdownNode): string { + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(nodeText).join(""); +} + +function documentFor(node: MarkdownNode): MarkdownNode { + return node.type === "document" ? node : { type: "document", children: [node] }; +} + +function SelectableNode(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + return ( + + ); +} + +function codeHighlightCacheKey( + code: string, + language: string | undefined, + theme: "light" | "dark", +): string { + return `${theme}:${language ?? "text"}:${code}`; +} + +function cacheHighlightedCode(key: string, tokens: HighlightedCode): void { + highlightedCodeCache.delete(key); + highlightedCodeCache.set(key, tokens); + + while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) { + const oldestKey = highlightedCodeCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + highlightedCodeCache.delete(oldestKey); + } +} + +function loadHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): Promise { + const key = codeHighlightCacheKey(code, language, theme); + const cached = highlightedCodeCache.get(key); + if (cached) { + return Promise.resolve(cached); + } + + const pending = highlightedCodePromiseCache.get(key); + if (pending) { + return pending; + } + + const promise = highlightCode({ code, language, theme }) + .then((tokens) => { + cacheHighlightedCode(key, tokens); + highlightedCodePromiseCache.delete(key); + return tokens; + }) + .catch((error) => { + highlightedCodePromiseCache.delete(key); + throw error; + }); + highlightedCodePromiseCache.set(key, promise); + return promise; +} + +function useHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): HighlightedCode | null { + const key = codeHighlightCacheKey(code, language, theme); + const [highlighted, setHighlighted] = useState<{ + readonly key: string; + readonly tokens: HighlightedCode | null; + }>(() => ({ + key, + tokens: highlightedCodeCache.get(key) ?? null, + })); + + useEffect(() => { + let active = true; + const cached = highlightedCodeCache.get(key); + if (cached) { + cacheHighlightedCode(key, cached); + setHighlighted({ key, tokens: cached }); + return () => { + active = false; + }; + } + + void loadHighlightedCode(code, language, theme, highlightCode) + .then((tokens) => { + if (active) { + setHighlighted({ key, tokens }); + } + }) + .catch(() => { + if (active) { + setHighlighted({ key, tokens: null }); + } + }); + return () => { + active = false; + }; + }, [code, highlightCode, key, language, theme]); + + return highlighted.key === key ? highlighted.tokens : null; +} + +function HighlightedCodeText(props: { + readonly content: string; + readonly highlighted: HighlightedCode | null; + readonly textStyle: NativeMarkdownTextStyle; +}) { + if (!props.highlighted) { + return ( + + {props.content} + + ); + } + const highlighted = props.highlighted; + let sourceOffset = 0; + const keyOccurrences = new Map(); + const keyedLines = highlighted.map((line) => { + const lineStart = sourceOffset; + const tokens = line.map((token) => { + const start = sourceOffset; + sourceOffset += token.content.length; + const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`; + const occurrence = keyOccurrences.get(signature) ?? 0; + keyOccurrences.set(signature, occurrence + 1); + return { key: `${signature}:${occurrence}`, token }; + }); + sourceOffset += 1; + return { + key: `line:${lineStart}:${line.map((token) => token.content).join("")}`, + tokens, + }; + }); + + return ( + + {keyedLines.map((line, lineIndex) => ( + + {line.tokens.map(({ key, token }) => ( + + {token.content} + + ))} + {lineIndex + 1 < keyedLines.length ? "\n" : ""} + + ))} + + ); +} + +function NativeCodeBlock(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly compact?: boolean; +}) { + const content = nodeText(props.node).replace(/\n$/, ""); + const colorScheme = useColorScheme(); + const theme = colorScheme === "dark" ? "dark" : "light"; + const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); + const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; + return ( + + + + {languageLabel} + + + + + + + + ); +} + +function collectTableRows(node: MarkdownNode): MarkdownNode[] { + const rows: MarkdownNode[] = []; + const visit = (child: MarkdownNode) => { + if (child.type === "table_row") { + rows.push(child); + return; + } + for (const nested of child.children ?? []) { + visit(nested); + } + }; + visit(node); + return rows; +} + +function NativeTable(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const rows = collectTableRows(props.node); + return ( + + + {rows.map((row, rowIndex) => ( + + {(row.children ?? []).map((cell, cellIndex) => ( + + + rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, + )} + textStyle={props.textStyle} + /> + + ))} + + ))} + + + ); +} + +function NativeMarkdownImage(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const href = props.node.href; + if (!href) { + return ; + } + + return ( + + + {props.node.alt ? ( + + {props.node.alt} + + ) : null} + + ); +} + +function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { + const groups: MarkdownNode[] = []; + let inline: MarkdownNode[] = []; + const flush = () => { + if (inline.length === 0) { + return; + } + groups.push({ type: "paragraph", children: inline }); + inline = []; + }; + + for (const node of nodes) { + if (node.type === "image") { + flush(); + groups.push(node); + } else { + inline.push(node); + } + } + flush(); + return groups; +} + +function NativeMixedParagraph(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + return ( + + {inlineGroups(props.node.children ?? []).map((child, index) => + child.type === "image" ? ( + + ) : ( + + ), + )} + + ); +} + +function NativeList(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly depth: number; +}) { + const ordered = props.node.ordered ?? false; + const start = props.node.start ?? 1; + const nested = props.depth > 0; + return ( + + {(props.node.children ?? []).map((item, index) => { + const taskMarker = item.type === "task_list_item"; + const marker = taskMarker + ? item.checked + ? "☑︎" + : "☐︎" + : ordered + ? `${start + index}.` + : props.depth % 3 === 1 + ? "◦" + : props.depth % 3 === 2 + ? "▪︎" + : "•"; + const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; + const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; + return ( + + + + {marker} + + + + {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( + + ))} + + + ); + })} + + ); +} + +export function NativeMarkdownBlock(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly depth?: number; + readonly compact?: boolean; +}) { + const depth = props.depth ?? 0; + switch (props.node.type) { + case "document": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "code_block": + return ( + + ); + case "table": + return ; + case "image": + return ; + case "horizontal_rule": + return ( + + ); + case "blockquote": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "list": + return ( + + ); + case "paragraph": + return (props.node.children ?? []).some((child) => child.type === "image") ? ( + + ) : ( + + ); + case "html_block": + case "math_block": + return ( + + + + ); + case "table_head": + case "table_body": + case "table_row": + case "table_cell": + case "list_item": + case "task_list_item": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + default: + return ; + } +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx new file mode 100644 index 00000000000..c6495eed860 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -0,0 +1,257 @@ +import { useEffect, useMemo, useState } from "react"; +import { Asset } from "expo-asset"; +import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; + +import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; +import { markdownFileIconSource } from "./markdownFileIcons"; +import type { MarkdownFileIcon } from "./markdownLinks"; +import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +const EXTERNAL_LINK_PREFIX = "◉ "; +const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0"; +const CHIP_SUFFIX = "\u00A0"; +const SKILL_ICON_PLACEHOLDER = "\uFFFC"; +const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; + +function useFileIconUris(runs: ReadonlyArray) { + const iconSignature = JSON.stringify( + [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(), + ); + const icons = useMemo( + () => JSON.parse(iconSignature) as ReadonlyArray, + [iconSignature], + ); + const [uris, setUris] = useState>(() => new Map()); + + useEffect(() => { + let cancelled = false; + + void Promise.all( + icons.map(async (icon) => { + const source = markdownFileIconSource(icon); + const fallbackUri = Image.resolveAssetSource(source).uri; + if (typeof source !== "number" && typeof source !== "string") { + return [icon, fallbackUri] as const; + } + try { + const asset = Asset.fromModule(source); + await asset.downloadAsync(); + return [icon, asset.localUri ?? fallbackUri] as const; + } catch { + return [icon, fallbackUri] as const; + } + }), + ).then((entries) => { + if (!cancelled) { + setUris(new Map(entries)); + } + }); + + return () => { + cancelled = true; + }; + }, [icons]); + + return uris; +} + +function runKeySignature(run: NativeMarkdownTextRun): string { + return [ + run.text, + run.bold, + run.italic, + run.strikethrough, + run.code, + run.href, + run.externalHost, + run.fileIcon, + run.skillName, + run.skillLabel, + run.role, + run.headingLevel, + run.depth, + run.spacing, + run.firstLineHeadIndent, + run.headIndent, + run.paragraphSpacing, + ].join(":"); +} + +function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { + const isFile = run.fileIcon != null; + const isSkill = run.skillName != null; + const isChip = isFile || isSkill; + const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); + const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; + const isHeading = run.role === "heading"; + const isCodeBlock = run.role === "code-block" || run.role === "code-language"; + const hasParagraphStyle = run.headIndent !== undefined; + const textDecorationLine = run.strikethrough ? "line-through" : run.href ? "underline" : "none"; + + return { + color: isFile + ? textStyle.fileTextColor + : isSkill + ? textStyle.skillTextColor + : run.href + ? textStyle.linkColor + : isHeading + ? textStyle.strongColor + : run.role === "quote-marker" + ? textStyle.quoteMarkerColor + : run.role === "divider" + ? textStyle.dividerColor + : run.role === "code-language" + ? textStyle.mutedColor + : run.role === "list-marker" + ? textStyle.mutedColor + : run.code || isFile + ? textStyle.codeColor + : run.bold + ? textStyle.strongColor + : textStyle.color, + fontFamily: isChip + ? "DMSans_500Medium" + : run.code || isCodeBlock + ? "ui-monospace" + : isHeading + ? textStyle.headingFontFamily + : run.bold + ? textStyle.boldFontFamily + : textStyle.fontFamily, + fontSize: + run.role === "spacer" + ? (run.spacing ?? 10) + : run.role === "list-break" + ? textStyle.fontSize + : isHeading + ? headingFontSize + : run.role === "code-language" + ? 11 + : run.code || isChip || isCodeBlock + ? Math.max(12, textStyle.fontSize - 2) + : textStyle.fontSize, + lineHeight: + run.role === "spacer" + ? (run.spacing ?? 10) + : run.role === "list-break" + ? textStyle.lineHeight + (run.spacing ?? 0) + : isHeading + ? Math.max(headingFontSize + 6, 20) + : isCodeBlock + ? 18 + : textStyle.lineHeight, + fontStyle: run.italic ? "italic" : "normal", + fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400", + textDecorationLine, + backgroundColor: isCodeBlock + ? textStyle.codeBlockBackgroundColor + : isSkill + ? textStyle.skillBackgroundColor + : run.code + ? textStyle.codeBackgroundColor + : isFile + ? textStyle.fileBackgroundColor + : undefined, + ...(hasParagraphStyle + ? { + shadowColor: "transparent", + shadowOffset: { + width: run.firstLineHeadIndent ?? 0, + height: run.headIndent, + }, + shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), + } + : {}), + }; +} + +export function NativeMarkdownSelectableText(props: { + readonly runs: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const colorScheme = useColorScheme(); + const fileIconUris = useFileIconUris(props.runs); + const occurrences = new Map(); + const prefixedExternalLinks = new Set(); + const keyedRuns = props.runs.map((run) => { + const signature = runKeySignature(run); + const occurrence = occurrences.get(signature) ?? 0; + occurrences.set(signature, occurrence + 1); + + let text = run.text; + if (run.fileIcon) { + text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`; + } else if (run.skillName && run.skillLabel) { + text = `\u00A0${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}${CHIP_SUFFIX}`; + } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { + prefixedExternalLinks.add(run.href); + text = `${EXTERNAL_LINK_PREFIX}${text}`; + } + + return { key: `${signature}:${occurrence}`, run, text }; + }); + // T3MarkdownText only rebuilds its attributed string during native layout. A + // color-only child update can otherwise leave the previous appearance cached. + const appearanceKey = [ + colorScheme ?? "unspecified", + props.textStyle.color, + props.textStyle.strongColor, + props.textStyle.mutedColor, + props.textStyle.linkColor, + props.textStyle.codeColor, + props.textStyle.codeBackgroundColor, + props.textStyle.codeBlockBackgroundColor, + props.textStyle.fileBackgroundColor, + props.textStyle.fileTextColor, + props.textStyle.skillBackgroundColor, + props.textStyle.skillTextColor, + props.textStyle.quoteMarkerColor, + props.textStyle.dividerColor, + ].join(":"); + + return ( + + {keyedRuns.map(({ key, run, text }) => { + const href = run.href; + return ( + { + void Linking.openURL(href); + } + : undefined + } + > + {text} + + ); + })} + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx new file mode 100644 index 00000000000..7c8f8d1bd55 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -0,0 +1,80 @@ +import { useMemo } from "react"; +import { View } from "react-native"; +import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, +} from "./nativeMarkdownText"; +import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import type { + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +const EMPTY_SKILLS: ReadonlyArray = []; + +export type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText({ + markdown, + skills = EMPTY_SKILLS, + textStyle, + highlightCode, + marginTop = 0, + marginBottom = 0, +}: SelectableMarkdownTextProps) { + const chunks = useMemo(() => { + const document = parseMarkdownWithOptions(markdown, { + gfm: true, + html: true, + math: false, + }); + return nativeMarkdownDocumentChunks(document).map((chunk) => + chunk.kind === "selectable" + ? { + ...chunk, + runs: nativeMarkdownDocumentRuns(chunk.node, skills), + } + : chunk, + ); + }, [markdown, skills]); + + return ( + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); + + return ( + + {content} + + ); + })} + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx new file mode 100644 index 00000000000..fcb2472f648 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -0,0 +1,13 @@ +import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; + +export type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +export function SelectableMarkdownText(_props: SelectableMarkdownTextProps) { + return null; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts new file mode 100644 index 00000000000..bd67d9110e5 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -0,0 +1,46 @@ +export interface NativeMarkdownTextStyle { + readonly color: string; + readonly strongColor: string; + readonly mutedColor: string; + readonly linkColor: string; + readonly codeColor: string; + readonly codeBackgroundColor: string; + readonly codeBlockBackgroundColor: string; + readonly fileBackgroundColor: string; + readonly fileTextColor: string; + readonly skillBackgroundColor: string; + readonly skillTextColor: string; + readonly quoteMarkerColor: string; + readonly dividerColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly fontFamily: string; + readonly headingFontFamily: string; + readonly boldFontFamily: string; +} + +export interface MarkdownHighlightedToken { + readonly content: string; + readonly color: string | null; + readonly fontStyle: number | null; +} + +export type MarkdownCodeHighlighter = (input: { + readonly code: string; + readonly language?: string | null; + readonly theme: "light" | "dark"; +}) => Promise>>; + +export interface SelectableMarkdownSkill { + readonly name: string; + readonly displayName?: string | null; +} + +export interface SelectableMarkdownTextProps { + readonly markdown: string; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly skills?: ReadonlyArray; + readonly marginTop?: number; + readonly marginBottom?: number; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts new file mode 100644 index 00000000000..656ad47d252 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts @@ -0,0 +1,55 @@ +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; +import type { ViewProps } from "react-native"; +import type { + BubblingEventHandler, + Int32, + WithDefault, +} from "react-native/Libraries/Types/CodegenTypes"; + +interface TargetedEvent { + target: Int32; +} + +interface TextLayoutEvent extends TargetedEvent { + lines: string[]; +} + +/** + * Event fired when text selection changes in the MarkdownTextPrimitive. + * @property target - The view tag identifier + * @property start - The start index of the selected range (0-based) + * @property end - The end index of the selected range (0-based, exclusive) + */ +interface SelectionChangeEvent extends TargetedEvent { + start: Int32; + end: Int32; +} + +type EllipsizeMode = "head" | "middle" | "tail" | "clip"; + +interface NativeProps extends ViewProps { + numberOfLines?: Int32; + allowFontScaling?: WithDefault; + ellipsizeMode?: WithDefault; + selectable?: boolean; + onTextLayout?: BubblingEventHandler; + /** + * Callback fired when the text selection changes. + * + * @example + * ```tsx + * { + * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end); + * }} + * > + * Selectable text + * + * ``` + */ + onSelectionChange?: BubblingEventHandler; +} + +export default codegenNativeComponent("T3MarkdownText", { + excludedPlatforms: ["android"], +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts new file mode 100644 index 00000000000..7f8fab8d844 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts @@ -0,0 +1,51 @@ +import type { ColorValue, ViewProps } from "react-native"; +import type { + BubblingEventHandler, + Float, + Int32, + WithDefault, +} from "react-native/Libraries/Types/CodegenTypes"; +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; + +interface TargetedEvent { + target: Int32; +} + +type TextDecorationLine = "none" | "underline" | "line-through"; + +type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; + +export type NativeFontWeight = + | "normal" + | "bold" + | "ultraLight" + | "light" + | "medium" + | "semibold" + | "heavy"; + +type FontStyle = "normal" | "italic"; + +type TextAlign = "auto" | "left" | "right" | "center" | "justify"; + +interface NativeProps extends ViewProps { + text: string; + color?: ColorValue; + fontSize?: Float; + fontStyle?: WithDefault; + fontWeight?: WithDefault; + fontFamily?: string; + letterSpacing?: Float; + lineHeight?: Float; + textDecorationLine?: WithDefault; + textDecorationStyle?: WithDefault; + textDecorationColor?: ColorValue; + textAlign?: WithDefault; + shadowRadius?: WithDefault; + onPress?: BubblingEventHandler; + onLongPress?: BubblingEventHandler; +} + +export default codegenNativeComponent("T3MarkdownTextRun", { + excludedPlatforms: ["android"], +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts new file mode 100644 index 00000000000..608fa08c486 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts @@ -0,0 +1,62 @@ +import type { ImageSourcePropType } from "react-native"; + +export const MARKDOWN_FILE_ICON_SOURCES = { + agents: require("../assets/file-icons/pierre_agents.png"), + astro: require("../assets/file-icons/pierre_astro.png"), + babel: require("../assets/file-icons/pierre_babel.png"), + bash: require("../assets/file-icons/pierre_bash.png"), + biome: require("../assets/file-icons/pierre_biome.png"), + bootstrap: require("../assets/file-icons/pierre_bootstrap.png"), + browserslist: require("../assets/file-icons/pierre_browserslist.png"), + bun: require("../assets/file-icons/pierre_bun.png"), + c: require("../assets/file-icons/pierre_c.png"), + claude: require("../assets/file-icons/pierre_claude.png"), + cpp: require("../assets/file-icons/pierre_cpp.png"), + css: require("../assets/file-icons/pierre_css.png"), + database: require("../assets/file-icons/pierre_database.png"), + default: require("../assets/file-icons/pierre_default.png"), + docker: require("../assets/file-icons/pierre_docker.png"), + eslint: require("../assets/file-icons/pierre_eslint.png"), + font: require("../assets/file-icons/pierre_font.png"), + git: require("../assets/file-icons/pierre_git.png"), + go: require("../assets/file-icons/pierre_go.png"), + graphql: require("../assets/file-icons/pierre_graphql.png"), + html: require("../assets/file-icons/pierre_html.png"), + image: require("../assets/file-icons/pierre_image.png"), + javascript: require("../assets/file-icons/pierre_javascript.png"), + json: require("../assets/file-icons/pierre_json.png"), + markdown: require("../assets/file-icons/pierre_markdown.png"), + mcp: require("../assets/file-icons/pierre_mcp.png"), + nextjs: require("../assets/file-icons/pierre_nextjs.png"), + npm: require("../assets/file-icons/pierre_npm.png"), + oxc: require("../assets/file-icons/pierre_oxc.png"), + package: require("../assets/file-icons/pierre_package.png"), + pnpm: require("../assets/file-icons/pierre_pnpm.png"), + postcss: require("../assets/file-icons/pierre_postcss.png"), + prettier: require("../assets/file-icons/pierre_prettier.png"), + python: require("../assets/file-icons/pierre_python.png"), + react: require("../assets/file-icons/pierre_react.png"), + readme: require("../assets/file-icons/pierre_readme.png"), + ruby: require("../assets/file-icons/pierre_ruby.png"), + rust: require("../assets/file-icons/pierre_rust.png"), + sass: require("../assets/file-icons/pierre_sass.png"), + stylelint: require("../assets/file-icons/pierre_stylelint.png"), + svelte: require("../assets/file-icons/pierre_svelte.png"), + svg: require("../assets/file-icons/pierre_svg.png"), + svgo: require("../assets/file-icons/pierre_svgo.png"), + swift: require("../assets/file-icons/pierre_swift.png"), + table: require("../assets/file-icons/pierre_table.png"), + tailwind: require("../assets/file-icons/pierre_tailwind.png"), + terraform: require("../assets/file-icons/pierre_terraform.png"), + text: require("../assets/file-icons/pierre_text.png"), + tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), + typescript: require("../assets/file-icons/pierre_typescript.png"), + vite: require("../assets/file-icons/pierre_vite.png"), + vscode: require("../assets/file-icons/pierre_vscode.png"), + vue: require("../assets/file-icons/pierre_vue.png"), + wasm: require("../assets/file-icons/pierre_wasm.png"), + webpack: require("../assets/file-icons/pierre_webpack.png"), + yml: require("../assets/file-icons/pierre_yml.png"), + zig: require("../assets/file-icons/pierre_zig.png"), + zip: require("../assets/file-icons/pierre_zip.png"), +} as const satisfies Readonly>; diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts new file mode 100644 index 00000000000..94b08c1de7e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts @@ -0,0 +1,8 @@ +import type { ImageSourcePropType } from "react-native"; + +import type { MarkdownFileIcon } from "./markdownLinks"; +import { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; + +export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType { + return MARKDOWN_FILE_ICON_SOURCES[icon]; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts new file mode 100644 index 00000000000..affd7515b25 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -0,0 +1,349 @@ +import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; + +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; +const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; +const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const POSIX_FILE_ROOT_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/etc/", + "/opt/", + "/mnt/", + "/Volumes/", + "/private/", + "/root/", +] as const; + +export type MarkdownLinkPresentation = + | { + readonly kind: "external"; + readonly href: string; + readonly host: string; + } + | { + readonly kind: "file"; + readonly icon: MarkdownFileIcon; + readonly label: string; + } + | { + readonly kind: "link"; + readonly href: string | null; + }; + +export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; + +const FILE_ICON_BY_NAME: Readonly> = { + ".babelrc": "babel", + ".babelrc.json": "babel", + ".bash_profile": "bash", + ".bashrc": "bash", + ".browserslistrc": "browserslist", + ".dockerignore": "docker", + ".eslintignore": "eslint", + ".eslintrc": "eslint", + ".eslintrc.cjs": "eslint", + ".eslintrc.js": "eslint", + ".eslintrc.json": "eslint", + ".eslintrc.yaml": "eslint", + ".eslintrc.yml": "eslint", + ".gitattributes": "git", + ".gitignore": "git", + ".gitkeep": "git", + ".gitmodules": "git", + ".oxlintrc.json": "oxc", + ".postcssrc": "postcss", + ".postcssrc.json": "postcss", + ".postcssrc.yaml": "postcss", + ".postcssrc.yml": "postcss", + ".prettierignore": "prettier", + ".prettierrc": "prettier", + ".prettierrc.json": "prettier", + ".prettierrc.cjs": "prettier", + ".prettierrc.js": "prettier", + ".prettierrc.mjs": "prettier", + ".prettierrc.toml": "prettier", + ".prettierrc.yaml": "prettier", + ".prettierrc.yml": "prettier", + ".stylelintignore": "stylelint", + ".stylelintrc": "stylelint", + ".stylelintrc.cjs": "stylelint", + ".stylelintrc.js": "stylelint", + ".stylelintrc.json": "stylelint", + ".stylelintrc.mjs": "stylelint", + ".stylelintrc.yaml": "stylelint", + ".stylelintrc.yml": "stylelint", + ".terraform.lock.hcl": "terraform", + ".zprofile": "bash", + ".zshenv": "bash", + ".zshrc": "bash", + "agents.md": "agents", + "babel.config.js": "babel", + "babel.config.cjs": "babel", + "babel.config.json": "babel", + "babel.config.mjs": "babel", + "biome.json": "biome", + "biome.jsonc": "biome", + "bun.lock": "bun", + "bun.lockb": "bun", + "bunfig.toml": "bun", + "claude.md": "claude", + "compose.yaml": "docker", + "compose.yml": "docker", + "docker-compose.yaml": "docker", + "docker-compose.yml": "docker", + "docker-compose.override.yml": "docker", + dockerfile: "docker", + "eslint.config.js": "eslint", + "eslint.config.cjs": "eslint", + "eslint.config.mjs": "eslint", + "eslint.config.mts": "eslint", + "eslint.config.ts": "eslint", + gemfile: "ruby", + "next.config.js": "nextjs", + "next.config.mjs": "nextjs", + "next.config.mts": "nextjs", + "next.config.ts": "nextjs", + "package.json": "package", + "pnpm-lock.yaml": "pnpm", + "pnpm-workspace.yaml": "pnpm", + "postcss.config.js": "postcss", + "postcss.config.cjs": "postcss", + "postcss.config.mjs": "postcss", + "postcss.config.ts": "postcss", + "prettier.config.js": "prettier", + "prettier.config.cjs": "prettier", + "prettier.config.mjs": "prettier", + rakefile: "ruby", + "readme.md": "readme", + "stylelint.config.js": "stylelint", + "stylelint.config.cjs": "stylelint", + "stylelint.config.mjs": "stylelint", + "svgo.config.js": "svgo", + "svgo.config.cjs": "svgo", + "svgo.config.mjs": "svgo", + "svgo.config.ts": "svgo", + "tailwind.config.js": "tailwind", + "tailwind.config.cjs": "tailwind", + "tailwind.config.mjs": "tailwind", + "tailwind.config.ts": "tailwind", + "tsconfig.json": "tsconfig", + "vite.config.js": "vite", + "vite.config.mjs": "vite", + "vite.config.mts": "vite", + "vite.config.ts": "vite", + "webpack.config.js": "webpack", + "webpack.config.babel.js": "webpack", + "webpack.config.cjs": "webpack", + "webpack.config.mjs": "webpack", + "webpack.config.ts": "webpack", +}; + +const FILE_ICON_BY_EXTENSION: Readonly> = { + "7z": "zip", + astro: "astro", + avif: "image", + "code-workspace": "vscode", + bash: "bash", + bmp: "image", + bz2: "zip", + c: "c", + cc: "cpp", + cpp: "cpp", + cxx: "cpp", + css: "css", + csv: "table", + cts: "typescript", + db: "database", + env: "text", + "env.development": "text", + "env.local": "text", + "env.production": "text", + eot: "font", + erb: "ruby", + fish: "bash", + gif: "image", + go: "go", + gql: "graphql", + graphql: "graphql", + gz: "zip", + h: "c", + hh: "cpp", + hpp: "cpp", + hxx: "cpp", + htm: "html", + html: "html", + ico: "image", + icns: "image", + ini: "text", + inl: "cpp", + jar: "zip", + jpeg: "image", + jpg: "image", + js: "javascript", + jsx: "react", + json: "json", + jsonc: "json", + less: "css", + md: "markdown", + mdx: "markdown", + "mdx.tsx": "markdown", + mjs: "javascript", + mts: "typescript", + png: "image", + postcss: "css", + py: "python", + pyi: "python", + pyw: "python", + pyx: "python", + rake: "ruby", + rar: "zip", + rb: "ruby", + rs: "rust", + sass: "sass", + scss: "sass", + sh: "bash", + sql: "database", + sqlite: "database", + sqlite3: "database", + svelte: "svelte", + svg: "svg", + swift: "swift", + tar: "zip", + tf: "terraform", + tfstate: "terraform", + tfvars: "terraform", + tgz: "zip", + ts: "typescript", + tsv: "table", + tsx: "react", + txt: "text", + woff: "font", + woff2: "font", + vue: "vue", + wasm: "wasm", + webp: "image", + yml: "yml", + yaml: "yml", + zig: "zig", + zip: "zip", + zsh: "bash", +}; + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function normalizeDestination(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; +} + +function fileUrlPath(href: string): string | null { + try { + const parsed = new URL(href); + if (parsed.protocol.toLowerCase() !== "file:") { + return null; + } + const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) + ? parsed.pathname.slice(1) + : parsed.pathname; + const lineMatch = parsed.hash.match(/^#L(\d+)(?:C(\d+))?$/i); + return `${safeDecode(path)}${ + lineMatch?.[1] ? `:${lineMatch[1]}${lineMatch[2] ? `:${lineMatch[2]}` : ""}` : "" + }`; + } catch { + return null; + } +} + +function looksLikePosixFilesystemPath(path: string): boolean { + if (!path.startsWith("/")) { + return false; + } + if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) { + return true; + } + if (POSITION_SUFFIX_PATTERN.test(path)) { + return true; + } + const basename = path.slice(path.lastIndexOf("/") + 1); + return /\.[A-Za-z0-9_-]+$/.test(basename); +} + +function looksLikeFilePath(value: string): boolean { + if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) { + return true; + } + if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) { + return true; + } + if (value.startsWith("/")) { + return looksLikePosixFilesystemPath(value); + } + if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { + return true; + } + return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); +} + +function fileLabel(value: string): string { + const normalized = value.replaceAll("\\", "/"); + const basename = normalized.slice(normalized.lastIndexOf("/") + 1); + return basename || normalized; +} + +export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { + const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + const exactIcon = FILE_ICON_BY_NAME[basename]; + if (exactIcon) return exactIcon; + if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { + return "tsconfig"; + } + const segments = basename.split("."); + for (let index = 1; index < segments.length; index += 1) { + const icon = FILE_ICON_BY_EXTENSION[segments.slice(index).join(".")]; + if (icon) return icon; + } + return "default"; +} + +export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { + const normalized = normalizeDestination(href); + try { + const parsed = new URL(normalized); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return { + kind: "external", + href: parsed.toString(), + host: parsed.hostname, + }; + } + } catch { + // Relative paths and non-URL link destinations are handled below. + } + + const fileTarget = normalized.toLowerCase().startsWith("file:") + ? fileUrlPath(normalized) + : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized); + if (fileTarget && looksLikeFilePath(fileTarget)) { + return { + kind: "file", + icon: resolveMarkdownFileIcon(fileTarget), + label: fileLabel(fileTarget), + }; + } + + return { + kind: "link", + href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, + }; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts new file mode 100644 index 00000000000..6751e165f1c --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -0,0 +1,751 @@ +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; +import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; + +export interface NativeMarkdownTextRun { + readonly text: string; + readonly bold?: boolean; + readonly italic?: boolean; + readonly strikethrough?: boolean; + readonly code?: boolean; + readonly href?: string; + readonly externalHost?: string; + readonly fileIcon?: MarkdownFileIcon; + readonly skillName?: string; + readonly skillLabel?: string; + readonly role?: + | "body" + | "heading" + | "list-marker" + | "list-break" + | "quote-marker" + | "code-block" + | "code-language" + | "divider" + | "spacer"; + readonly headingLevel?: number; + readonly depth?: number; + readonly spacing?: number; + readonly firstLineHeadIndent?: number; + readonly headIndent?: number; + readonly paragraphSpacing?: number; +} + +export type NativeMarkdownDocumentChunk = + | { + readonly kind: "selectable"; + readonly key: string; + readonly node: MarkdownNode; + } + | { + readonly kind: "rich"; + readonly key: string; + readonly node: MarkdownNode; + }; + +interface RunContext { + readonly bold: boolean; + readonly italic: boolean; + readonly strikethrough: boolean; + readonly code: boolean; + readonly href?: string; + readonly externalHost?: string; + readonly fileIcon?: MarkdownFileIcon; + readonly role?: NativeMarkdownTextRun["role"]; + readonly headingLevel?: number; + readonly depth?: number; + readonly spacing?: number; + readonly firstLineHeadIndent?: number; + readonly headIndent?: number; + readonly paragraphSpacing?: number; +} + +const EMPTY_CONTEXT: RunContext = { + bold: false, + italic: false, + strikethrough: false, + code: false, +}; + +const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; + +function decodeHtmlEntitiesOnce(value: string): string { + return value.replace( + /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, + (entity, decimal: string | undefined, hexadecimal: string | undefined) => { + if (decimal) { + return String.fromCodePoint(Number.parseInt(decimal, 10)); + } + if (hexadecimal) { + return String.fromCodePoint(Number.parseInt(hexadecimal, 16)); + } + switch (entity.toLowerCase()) { + case "&": + return "&"; + case "'": + return "'"; + case ">": + return ">"; + case "<": + return "<"; + case " ": + return "\u00a0"; + case """: + return '"'; + default: + return entity; + } + }, + ); +} + +function decodeHtmlEntities(value: string): string { + let decoded = value; + for (let pass = 0; pass < 2; pass += 1) { + const next = decodeHtmlEntitiesOnce(decoded); + if (next === decoded) { + break; + } + decoded = next; + } + return decoded; +} + +function textNodeContent(value: string): string { + return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); +} + +function inlineHtmlText(value: string): string { + if (/^$/i.test(value.trim())) { + return "\n"; + } + return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); +} + +function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { + return ( + left.bold === right.bold && + left.italic === right.italic && + left.strikethrough === right.strikethrough && + left.code === right.code && + left.href === right.href && + left.externalHost === right.externalHost && + left.fileIcon === right.fileIcon && + left.skillName === right.skillName && + left.skillLabel === right.skillLabel && + left.role === right.role && + left.headingLevel === right.headingLevel && + left.depth === right.depth && + left.spacing === right.spacing && + left.firstLineHeadIndent === right.firstLineHeadIndent && + left.headIndent === right.headIndent && + left.paragraphSpacing === right.paragraphSpacing + ); +} + +function appendRun( + runs: NativeMarkdownTextRun[], + text: string, + context: RunContext, +): NativeMarkdownTextRun[] { + if (text.length === 0) { + return runs; + } + + const run: NativeMarkdownTextRun = { + text, + ...(context.bold ? { bold: true } : {}), + ...(context.italic ? { italic: true } : {}), + ...(context.strikethrough ? { strikethrough: true } : {}), + ...(context.code ? { code: true } : {}), + ...(context.href ? { href: context.href } : {}), + ...(context.externalHost ? { externalHost: context.externalHost } : {}), + ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), + ...(context.role ? { role: context.role } : {}), + ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), + ...(context.depth ? { depth: context.depth } : {}), + ...(context.spacing ? { spacing: context.spacing } : {}), + ...(context.firstLineHeadIndent !== undefined + ? { firstLineHeadIndent: context.firstLineHeadIndent } + : {}), + ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}), + ...(context.paragraphSpacing !== undefined + ? { paragraphSpacing: context.paragraphSpacing } + : {}), + }; + const previous = runs.at(-1); + if (previous && sameRunStyle(previous, run)) { + runs[runs.length - 1] = { ...previous, text: previous.text + run.text }; + return runs; + } + + runs.push(run); + return runs; +} + +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +function formatSkillLabel(skill: SelectableMarkdownSkill): string { + const displayName = skill.displayName?.trim(); + if (displayName) { + return displayName; + } + return skill.name + .split(/[\s:_-]+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function decorateSkillRuns( + runs: ReadonlyArray, + skills: ReadonlyArray, +): ReadonlyArray { + if (skills.length === 0) { + return runs; + } + const skillByName = new Map(skills.map((skill) => [skill.name, skill])); + const decorated: NativeMarkdownTextRun[] = []; + + for (const run of runs) { + if (run.code || run.href || run.fileIcon || run.role === "code-block") { + decorated.push(run); + continue; + } + + let cursor = 0; + let matched = false; + for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { + const prefix = match[1] ?? ""; + const name = match[2] ?? ""; + const skill = skillByName.get(name); + if (!skill) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + name.length + 1; + if (start > cursor) { + decorated.push({ ...run, text: run.text.slice(cursor, start) }); + } + decorated.push({ + ...run, + text: run.text.slice(start, end), + skillName: name, + skillLabel: formatSkillLabel(skill), + }); + cursor = end; + matched = true; + } + if (!matched) { + decorated.push(run); + } else if (cursor < run.text.length) { + decorated.push({ ...run, text: run.text.slice(cursor) }); + } + } + + return decorated; +} + +function appendChildren( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + for (const child of node.children ?? []) { + appendNode(runs, child, context); + } + return runs; +} + +function nodeTextContent(node: MarkdownNode): string { + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(nodeTextContent).join(""); +} + +function appendNode( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + switch (node.type) { + case "text": + case "math_inline": + return appendRun(runs, textNodeContent(nodeTextContent(node)), context); + case "html_inline": + return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); + case "code_inline": + return appendRun(runs, nodeTextContent(node), { ...context, code: true }); + case "soft_break": + return appendRun(runs, " ", context); + case "line_break": + return appendRun(runs, "\n", context); + case "bold": + return appendChildren(runs, node, { ...context, bold: true }); + case "italic": + return appendChildren(runs, node, { ...context, italic: true }); + case "strikethrough": + return appendChildren(runs, node, { ...context, strikethrough: true }); + case "link": { + const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); + if (presentation.kind === "file") { + return appendRun(runs, presentation.label, { + ...context, + fileIcon: presentation.icon, + }); + } + if (presentation.kind === "external") { + return appendChildren(runs, node, { + ...context, + href: presentation.href, + externalHost: presentation.host, + }); + } + return appendChildren(runs, node, { + ...context, + ...(presentation.href ? { href: presentation.href } : {}), + }); + } + case "image": + return appendRun(runs, node.alt ?? node.title ?? "", context); + default: + return appendChildren(runs, node, context); + } +} + +export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray { + return appendChildren([], node, EMPTY_CONTEXT); +} + +function appendBlockTerminator( + runs: NativeMarkdownTextRun[], + context: RunContext, +): NativeMarkdownTextRun[] { + return appendRun(runs, "\n", context); +} + +function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] { + return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing }); +} + +function appendInlineChildren( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + for (const child of node.children ?? []) { + appendNode(runs, child, context); + } + return runs; +} + +function isInlineNode(node: MarkdownNode): boolean { + return ( + node.type === "text" || + node.type === "bold" || + node.type === "italic" || + node.type === "strikethrough" || + node.type === "link" || + node.type === "image" || + node.type === "code_inline" || + node.type === "math_inline" || + node.type === "html_inline" || + node.type === "soft_break" || + node.type === "line_break" + ); +} + +export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray { + const blocks: MarkdownNode[] = []; + let inlineNodes: MarkdownNode[] = []; + const flushInlineNodes = () => { + if (inlineNodes.length === 0) { + return; + } + blocks.push({ type: "paragraph", children: inlineNodes }); + inlineNodes = []; + }; + + for (const child of node.children ?? []) { + if (isInlineNode(child)) { + inlineNodes.push(child); + continue; + } + + flushInlineNodes(); + blocks.push(child); + } + flushInlineNodes(); + return blocks; +} + +function appendListItem( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + marker: string, + depth: number, + markerColumnWidth: number, +): NativeMarkdownTextRun[] { + const firstLineHeadIndent = Math.max(0, depth - 1) * 20; + appendRun(runs, `${marker}\t`, { + ...EMPTY_CONTEXT, + role: "list-marker", + depth, + firstLineHeadIndent, + headIndent: firstLineHeadIndent + markerColumnWidth, + paragraphSpacing: 2, + }); + + const children = node.children ?? []; + let wroteInlineContent = false; + for (const child of children) { + if (child.type === "paragraph") { + appendInlineChildren(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + wroteInlineContent = true; + continue; + } + if (child.type === "list") { + if (wroteInlineContent) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "list-break", + depth, + spacing: 1, + }); + } + appendList(runs, child, depth + 1); + wroteInlineContent = false; + continue; + } + if (isInlineNode(child)) { + appendNode(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + wroteInlineContent = true; + continue; + } + appendDocumentBlock(runs, child, depth); + wroteInlineContent = true; + } + + if (wroteInlineContent) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "list-break", + depth, + spacing: depth === 1 ? 4 : 2, + }); + } + return runs; +} + +function appendList( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const ordered = node.ordered ?? false; + const start = node.start ?? 1; + const children = node.children ?? []; + const markers = children.map((child, index) => + child.type === "task_list_item" + ? child.checked + ? "☑︎" + : "☐︎" + : ordered + ? `${start + index}.` + : depth % 3 === 2 + ? "◦" + : depth % 3 === 0 + ? "▪︎" + : "•", + ); + const markerWidth = ordered + ? Math.max(0, ...markers.map((marker) => Array.from(marker).length)) + : 0; + + for (const [index, child] of children.entries()) { + const marker = markers[index] ?? "•"; + const alignedMarker = + child.type === "task_list_item" + ? marker + : ordered + ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}` + : marker; + const markerColumnWidth = + child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; + appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); + } + return runs; +} + +function appendQuoteBlock( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + for (const [index, child] of (node.children ?? []).entries()) { + if (index > 0) { + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + } + appendRun(runs, "│\u00a0", { + ...EMPTY_CONTEXT, + role: "quote-marker", + depth, + }); + if (child.type === "paragraph") { + appendInlineChildren(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + } else { + appendDocumentBlock(runs, child, depth); + } + } + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + return runs; +} + +function appendTableRow( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const cells = node.children ?? []; + for (const [index, cell] of cells.entries()) { + if (index > 0) { + appendRun(runs, "\u00a0│\u00a0", { + ...EMPTY_CONTEXT, + role: "divider", + depth, + }); + } + appendInlineChildren(runs, cell, { + ...EMPTY_CONTEXT, + role: "body", + bold: cell.isHeader ?? false, + depth, + }); + } + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + return runs; +} + +function appendTable( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const visit = (child: MarkdownNode) => { + if (child.type === "table_row") { + appendTableRow(runs, child, depth); + return; + } + for (const nested of child.children ?? []) { + visit(nested); + } + }; + visit(node); + return runs; +} + +function appendDocumentBlock( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth = 0, +): NativeMarkdownTextRun[] { + switch (node.type) { + case "document": { + const children = node.children ?? []; + for (const [index, child] of children.entries()) { + if (index > 0) { + const previous = children[index - 1]; + appendSpacer( + runs, + child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, + ); + } + appendDocumentBlock(runs, child, depth); + } + return runs; + } + case "heading": { + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "heading", + headingLevel: node.level ?? 1, + depth, + }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } + case "paragraph": { + const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } + case "list": + return appendList(runs, node, depth + 1); + case "blockquote": + return appendQuoteBlock(runs, node, depth); + case "code_block": { + if (node.language) { + appendRun(runs, `${node.language.toUpperCase()}\n`, { + ...EMPTY_CONTEXT, + role: "code-language", + code: true, + depth, + }); + } + const content = nodeTextContent(node); + appendRun(runs, content, { + ...EMPTY_CONTEXT, + role: "code-block", + code: true, + depth, + }); + if (!content.endsWith("\n")) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "code-block", + code: true, + depth, + }); + } + return runs; + } + case "horizontal_rule": + appendRun(runs, "────────────────────────\n", { + ...EMPTY_CONTEXT, + role: "divider", + depth, + }); + return runs; + case "table": + return appendTable(runs, node, depth); + case "html_block": + appendRun(runs, inlineHtmlText(nodeTextContent(node)), { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + case "math_block": + appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + default: + appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + } +} + +function containsRichBlock(node: MarkdownNode): boolean { + if ( + node.type === "code_block" || + node.type === "table" || + node.type === "image" || + node.type === "horizontal_rule" || + node.type === "html_block" || + node.type === "math_block" + ) { + return true; + } + return (node.children ?? []).some(containsRichBlock); +} + +export function nativeMarkdownDocumentChunks( + document: MarkdownNode, +): ReadonlyArray { + const chunks: NativeMarkdownDocumentChunk[] = []; + let selectableNodes: MarkdownNode[] = []; + + const flushSelectable = () => { + if (selectableNodes.length === 0) { + return; + } + const first = selectableNodes[0]; + const last = selectableNodes.at(-1); + chunks.push({ + kind: "selectable", + key: `selectable:${first?.beg ?? "start"}:${last?.end ?? "end"}`, + node: { + type: "document", + children: selectableNodes, + }, + }); + selectableNodes = []; + }; + + for (const [index, child] of (document.children ?? []).entries()) { + if (!containsRichBlock(child)) { + selectableNodes.push(child); + continue; + } + + flushSelectable(); + chunks.push({ + kind: "rich", + key: `rich:${child.type}:${child.beg ?? index}:${child.end ?? index}`, + node: child, + }); + } + flushSelectable(); + return chunks; +} + +function topLevelNodes(node: MarkdownNode): ReadonlyArray { + return node.type === "document" ? (node.children ?? []) : [node]; +} + +export function nativeMarkdownChunkSpacing( + previous: NativeMarkdownDocumentChunk | undefined, + current: NativeMarkdownDocumentChunk, +): number { + if (!previous) { + return 0; + } + + const previousLast = topLevelNodes(previous.node).at(-1); + const currentFirst = topLevelNodes(current.node)[0]; + + if (currentFirst?.type === "heading") { + return 20; + } + if (previousLast?.type === "heading") { + return 10; + } + if (previousLast?.type === "list" && currentFirst?.type === "list") { + return 12; + } + return 14; +} + +export function nativeMarkdownDocumentRuns( + node: MarkdownNode, + skills: ReadonlyArray = [], +): ReadonlyArray { + const runs = appendDocumentBlock([], node); + while (runs.length > 0) { + const lastIndex = runs.length - 1; + const last = runs[lastIndex]; + if (!last?.text.endsWith("\n")) { + break; + } + const text = last.text.slice(0, -1); + if (text.length === 0) { + runs.pop(); + } else { + runs[lastIndex] = { ...last, text }; + } + } + return decorateSkillRuns(runs, skills); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/util.ts b/apps/mobile/modules/t3-markdown-text/src/util.ts new file mode 100644 index 00000000000..d9f33d3a2ef --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/util.ts @@ -0,0 +1,62 @@ +import { type StyleProp, StyleSheet, type TextStyle } from "react-native"; +import type { NativeFontWeight } from "./T3MarkdownTextRunNativeComponent"; + +export function flattenStyles(rootStyle: TextStyle, style: StyleProp) { + const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle; + return { + ...flattenedStyle, + fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"), + backgroundColor: flattenedStyle.backgroundColor + ? flattenedStyle.backgroundColor + : "transparent", + shadowOffset: flattenedStyle.shadowOffset + ? flattenedStyle.shadowOffset + : { width: 0, height: 0 }, + }; +} + +// Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop +// value before returning flattened styles. +function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight { + switch (fontWeight) { + case "normal": + return "normal"; + case "bold": + return "bold"; + case 100: + case "100": + case "ultralight": + return "ultraLight"; + case 200: + case "200": + return "ultraLight"; + case 300: + case "300": + case "light": + return "light"; + case 400: + case "400": + case "regular": + return "normal"; + case 500: + case "500": + case "medium": + return "medium"; + case 600: + case "600": + case "semibold": + return "semibold"; + case 700: + case "700": + return "semibold"; + case 800: + case "800": + return "bold"; + case 900: + case "900": + case "heavy": + return "heavy"; + default: + return "normal"; + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4b08a338e12..47efc95adb1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -34,12 +34,13 @@ "config:preview": "APP_VARIANT=preview expo config", "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", + "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs", "test": "vp test run", "typecheck": "tsc --noEmit" }, "dependencies": { "@callstack/liquid-glass": "^0.7.1", - "@clerk/expo": "^3.3.0", + "@clerk/expo": "^3.4.1", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", @@ -54,6 +55,7 @@ "@shikijs/themes": "3.23.0", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/shared": "workspace:*", @@ -61,6 +63,7 @@ "diff": "8.0.3", "effect": "catalog:", "expo": "^56.0.0", + "expo-asset": "~56.0.15", "expo-auth-session": "~56.0.12", "expo-build-properties": "~56.0.15", "expo-camera": "~56.0.7", @@ -104,6 +107,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@pierre/trees": "1.0.0-beta.4", "@types/react": "~19.2.0", "babel-preset-expo": "~56.0.0", "tailwindcss": "^4.0.0", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 136e141fdcf..1583fdbb2d7 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -5,7 +5,9 @@ import { DMSans_700Bold, useFonts, } from "@expo-google-fonts/dm-sans"; +import { usePathname } from "expo-router"; import Stack from "expo-router/stack"; +import { useCallback } from "react"; import { StatusBar, useColorScheme } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; @@ -21,15 +23,41 @@ import { import { RegistryContext } from "@effect/atom-react"; import { appAtomRegistry } from "../state/atom-registry"; import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; +import { + ClerkSettingsSheetDetentProvider, + useClerkSettingsSheetDetent, +} from "../features/cloud/ClerkSettingsSheetDetent"; import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; +import { useThemeColor } from "../lib/useThemeColor"; function AppNavigator() { + const pathname = usePathname(); + const clerkRouteIsActive = pathname === "/settings/auth"; + + return ( + + + + ); +} + +function AppNavigatorContent() { const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const { collapse, isExpanded } = useClerkSettingsSheetDetent(); const colorScheme = useColorScheme(); - const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7"; + const statusBarBg = useThemeColor("--color-status-bar"); const sheetStyle = useResolveClassNames("bg-sheet"); useAgentNotificationNavigation(); + const handleSettingsTransitionEnd = useCallback( + (event: { data: { closing: boolean } }) => { + if (event.data.closing) { + collapse(); + } + }, + [collapse], + ); + const newTaskScreenOptions = { contentStyle: sheetStyle, gestureEnabled: true, @@ -50,7 +78,7 @@ function AppNavigator() { const settingsSheetScreenOptions = { ...connectionSheetScreenOptions, - sheetAllowedDetents: [0.7], + sheetAllowedDetents: isExpanded ? [0.92] : [0.7], }; if (isLoadingSavedConnection) { @@ -61,7 +89,7 @@ function AppNavigator() { <> @@ -74,7 +102,11 @@ function AppNavigator() { headerShadowVisible: false, }} /> - + @@ -54,7 +56,7 @@ export default function HomeRouteScreen() { diff --git a/apps/mobile/src/app/settings/_layout.tsx b/apps/mobile/src/app/settings/_layout.tsx index 087e07ba5fb..86831d885f1 100644 --- a/apps/mobile/src/app/settings/_layout.tsx +++ b/apps/mobile/src/app/settings/_layout.tsx @@ -1,16 +1,27 @@ import Stack from "expo-router/stack"; -import { useColorScheme } from "react-native"; +import { useCallback } from "react"; import { useResolveClassNames } from "uniwind"; +import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; +import { useThemeColor } from "../../lib/useThemeColor"; + export const unstable_settings = { anchor: "index", }; export default function SettingsLayout() { + const { collapse } = useClerkSettingsSheetDetent(); const contentStyle = useResolveClassNames("bg-sheet"); - const isDark = useColorScheme() === "dark"; - const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; - const headerTint = isDark ? "#f5f5f5" : "#262626"; + const sheetBg = useThemeColor("--color-sheet"); + const headerTint = useThemeColor("--color-foreground"); + const handleClerkRouteTransitionEnd = useCallback( + (event: { data: { closing: boolean } }) => { + if (event.data.closing) { + collapse(); + } + }, + [collapse], + ); return ( + ); } diff --git a/apps/mobile/src/app/settings/auth.tsx b/apps/mobile/src/app/settings/auth.tsx new file mode 100644 index 00000000000..de33207ccda --- /dev/null +++ b/apps/mobile/src/app/settings/auth.tsx @@ -0,0 +1,33 @@ +import { useAuth } from "@clerk/expo"; +import { AuthView, UserProfileView } from "@clerk/expo/native"; +import { Redirect, Stack } from "expo-router"; +import { View } from "react-native"; + +import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; + +export default function SettingsAuthRouteScreen() { + return hasCloudPublicConfig() ? ( + + ) : ( + + ); +} + +function ConfiguredSettingsAuthRouteScreen() { + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + + return ( + <> + + + {isLoaded ? ( + isSignedIn ? ( + + ) : ( + + ) + ) : null} + + + ); +} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 85d2699c76f..c8b4cd40995 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -13,6 +13,7 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; +import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions, @@ -66,6 +67,7 @@ function LocalSettingsRouteScreen() { function ConfiguredSettingsRouteScreen() { const insets = useSafeAreaInsets(); const { push } = useRouter(); + const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); const { savedConnectionsById } = useRemoteEnvironmentState(); @@ -265,11 +267,9 @@ function ConfiguredSettingsRouteScreen() { push("/settings/waitlist"); return; } - Alert.alert( - "T3 Cloud unavailable", - "Native T3 Cloud account management is not available in this build.", - ); - }, [isLoaded, isSignedIn, push]); + expandClerkSheet(); + push("/settings/auth"); + }, [expandClerkSheet, isLoaded, isSignedIn, push]); return ( @@ -335,7 +335,7 @@ type SymbolName = ComponentProps["name"]; function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { return ( - {props.title} + {props.title} { + if (isLoaded && isSignedIn) { + router.replace("/settings"); + } + }, [isLoaded, isSignedIn, router]), + ); return ( <> @@ -31,7 +43,12 @@ function ConfiguredSettingsWaitlistRouteScreen() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - void presentAuth()} /> + { + expand(); + router.push("/settings/auth"); + }} + /> ); diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx new file mode 100644 index 00000000000..0c596e29232 --- /dev/null +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -0,0 +1,6 @@ +export { ComposerEditor } from "../native/T3ComposerEditor"; +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "../native/T3ComposerEditor"; diff --git a/apps/mobile/src/components/CopyTextButton.tsx b/apps/mobile/src/components/CopyTextButton.tsx new file mode 100644 index 00000000000..712b272a909 --- /dev/null +++ b/apps/mobile/src/components/CopyTextButton.tsx @@ -0,0 +1,68 @@ +import { SymbolView } from "expo-symbols"; +import { memo, useEffect, useRef, useState } from "react"; +import { Pressable, type ColorValue } from "react-native"; + +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; + +const COPY_FEEDBACK_DURATION_MS = 1200; + +export const CopyTextButton = memo(function CopyTextButton(props: { + readonly accessibilityLabel: string; + readonly text: string; + readonly tintColor: ColorValue; + readonly copiedTintColor?: ColorValue; + readonly backgroundColor?: ColorValue; + readonly borderColor?: ColorValue; + readonly iconSize?: number; + readonly buttonSize?: number; +}) { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }, + [], + ); + + return ( + { + copyTextWithHaptic(props.text); + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_FEEDBACK_DURATION_MS); + }} + style={({ pressed }) => ({ + width: props.buttonSize ?? 30, + height: props.buttonSize ?? 30, + alignItems: "center", + justifyContent: "center", + borderRadius: 9, + borderWidth: props.borderColor ? 1 : 0, + borderColor: props.borderColor, + backgroundColor: props.backgroundColor, + opacity: pressed ? 0.52 : 1, + })} + > + + + ); +}); diff --git a/apps/mobile/src/components/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx index f7cc49c368e..836a7cffbd7 100644 --- a/apps/mobile/src/components/GlassSafeAreaView.tsx +++ b/apps/mobile/src/components/GlassSafeAreaView.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; -import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native"; +import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColor } from "../lib/useThemeColor"; import { GlassSurface } from "./GlassSurface"; @@ -17,14 +18,16 @@ export function GlassSafeAreaView({ rightSlot, style, }: GlassSafeAreaViewProps) { - const isDarkMode = useColorScheme() === "dark"; const insets = useSafeAreaInsets(); + const headerColor = useThemeColor("--color-header"); + const headerBorderColor = useThemeColor("--color-header-border"); + const glassTint = useThemeColor("--color-glass-tint"); const headerPaddingTop = insets.top + 16; const surfaceStyle = { borderRadius: 0, - backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)", + backgroundColor: headerColor, borderBottomWidth: 1, - borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)", + borderBottomColor: headerBorderColor, } as const; return ( @@ -32,7 +35,7 @@ export function GlassSafeAreaView({ diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx new file mode 100644 index 00000000000..15ea24331b0 --- /dev/null +++ b/apps/mobile/src/components/PierreEntryIcon.tsx @@ -0,0 +1,25 @@ +import { SymbolView } from "expo-symbols"; +import { Image, type ImageStyle, type StyleProp } from "react-native"; + +import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; + +export function PierreEntryIcon(props: { + readonly path: string; + readonly kind: "file" | "directory"; + readonly size?: number; + readonly style?: StyleProp; +}) { + const size = props.size ?? 16; + if (props.kind === "directory") { + return ; + } + + return ( + + ); +} diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index d62f8d9a4bf..6c1b1038698 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -24,7 +24,7 @@ export function ProviderIcon(props: ProviderIconProps) { return ( diff --git a/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx new file mode 100644 index 00000000000..8bd51b8518d --- /dev/null +++ b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx @@ -0,0 +1,44 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +interface ClerkSettingsSheetDetentValue { + collapse: () => void; + expand: () => void; + isExpanded: boolean; +} + +const ClerkSettingsSheetDetentContext = createContext(null); + +interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren { + initiallyExpanded: boolean; +} + +export function ClerkSettingsSheetDetentProvider({ + children, + initiallyExpanded, +}: ClerkSettingsSheetDetentProviderProps) { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded); + const collapse = useCallback(() => setIsExpanded(false), []); + const expand = useCallback(() => setIsExpanded(true), []); + const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]); + + return ( + {children} + ); +} + +export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue { + const value = useContext(ClerkSettingsSheetDetentContext); + if (!value) { + throw new Error( + "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider", + ); + } + return value; +} diff --git a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts deleted file mode 100644 index 3356642776a..00000000000 --- a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { getClerkInstance } from "@clerk/expo"; -import { tokenCache } from "@clerk/expo/token-cache"; -import * as Data from "effect/Data"; -import { useCallback, useRef } from "react"; -import type { TurboModule } from "react-native"; -import { TurboModuleRegistry } from "react-native"; - -const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt"; - -interface NativeClerkModule extends TurboModule { - readonly getClientToken?: () => Promise; - readonly presentAuth?: (options: { - readonly dismissable: boolean; - readonly mode: "signInOrUp"; - }) => Promise; -} - -interface NativeAuthResult { - readonly cancelled?: boolean; - readonly session?: { - readonly id?: string; - }; - readonly sessionId?: string; -} - -interface ClerkWithNativeSync { - readonly __internal_reloadInitialResources?: () => Promise; - readonly setActive?: (params: { readonly session: string }) => Promise; -} - -const NativeClerk = TurboModuleRegistry.get("ClerkExpo"); - -class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -async function syncNativeSession(sessionId: string): Promise { - const getClientToken = NativeClerk?.getClientToken; - let nativeClientToken: string | null = null; - if (getClientToken) { - try { - nativeClientToken = await getClientToken(); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not read native Clerk client token.", - cause, - }); - } - } - if (nativeClientToken) { - const saveToken = tokenCache?.saveToken; - if (saveToken) { - try { - await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not save native Clerk client token.", - cause, - }); - } - } - } - - const clerk = getClerkInstance(); - const clerkWithNativeSync = clerk as ClerkWithNativeSync; - const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources; - if (reloadInitialResources) { - try { - await reloadInitialResources(); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not reload Clerk resources after native auth.", - cause, - }); - } - } - const setActive = clerkWithNativeSync.setActive; - if (setActive) { - try { - await setActive({ session: sessionId }); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not activate native Clerk session.", - cause, - }); - } - } -} - -export function useNativeClerkAuthModal() { - const presentingRef = useRef(false); - - const presentAuth = useCallback(async (): Promise => { - if (presentingRef.current || !NativeClerk?.presentAuth) { - return; - } - - presentingRef.current = true; - const presentNativeAuth = NativeClerk.presentAuth; - try { - // Clerk's iOS AuthView is not inline. It presents this same native modal - // internally; call the presenter directly so Expo Router does not render - // an empty formSheet behind it. - let result: NativeAuthResult | null; - try { - result = await presentNativeAuth({ - dismissable: true, - mode: "signInOrUp", - }); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Native Clerk auth presentation failed.", - cause, - }); - } - const sessionId = result?.sessionId ?? result?.session?.id ?? null; - if (sessionId && !result?.cancelled) { - await syncNativeSession(sessionId); - } - } catch (error) { - if (__DEV__) { - console.error("[useNativeClerkAuthModal] presentAuth failed:", error); - } - } finally { - presentingRef.current = false; - } - }, []); - - return { - isAvailable: !!NativeClerk?.presentAuth, - presentAuth, - }; -} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 00e4582957c..cdae41668a0 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -146,8 +146,8 @@ function ProjectGroupLabel(props: { bearerToken={props.bearerToken} /> {props.project.title} @@ -156,8 +156,8 @@ function ProjectGroupLabel(props: { {hiddenCount > 0 ? ( {props.isExpanded ? "Show less" : `${hiddenCount} more`} @@ -191,6 +191,7 @@ function ThreadRow(props: { readonly isLast: boolean; }) { const separatorColor = useThemeColor("--color-separator"); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); const { bg, fg } = statusColors(props.thread); const tone = threadStatusTone(props.thread); const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); @@ -267,7 +268,7 @@ function ThreadRow(props: { & Pick, @@ -119,3 +119,22 @@ describe("highlightReviewFile", () => { ]); }); }); + +describe("highlightCodeSnippet", () => { + it("resolves language aliases and returns syntax-colored tokens", async () => { + const source = "const answer: number = 42;"; + const highlighted = await highlightCodeSnippet({ + code: source, + language: "ts", + theme: "dark", + }); + + expect( + highlighted + .flat() + .map((token) => token.content) + .join(""), + ).toBe(source); + expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 8e254fbb0b3..d6d09221dac 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -685,6 +685,16 @@ async function highlightLines( return highlightedLines; } +export async function highlightCodeSnippet(input: { + readonly code: string; + readonly language?: string | null; + readonly theme: ReviewDiffTheme; +}): Promise>> { + const languageHint = input.language?.trim() || "text"; + const language = await resolveLanguageFromPath(`snippet.${languageHint}`, languageHint); + return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); +} + async function highlightPatchLinesInChunks(input: { readonly lines: ReadonlyArray; readonly language: string; diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 08746eb74e7..1b652a139cf 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -6,6 +6,7 @@ import { memo } from "react"; import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { PierreEntryIcon } from "../../components/PierreEntryIcon"; export type ComposerCommandItem = | { @@ -88,13 +89,13 @@ function PopoverSurface(props: { function itemIcon(item: ComposerCommandItem) { switch (item.type) { - case "path": - return item.kind === "directory" ? ("folder" as const) : ("doc" as const); case "slash-command": case "provider-slash-command": return "terminal" as const; case "skill": return "cube" as const; + case "path": + return null; } } @@ -149,7 +150,11 @@ const CommandRow = memo(function CommandRow(props: { borderBottomColor: "rgba(255,255,255,0.1)", })} > - + {props.item.type === "path" ? ( + + ) : iconName ? ( + + ) : null} state.isVisible); const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; - const promptInputRef = useRef(null); + const promptInputRef = useRef(null); const borderColor = useThemeColor("--color-border"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; @@ -410,10 +403,6 @@ export function NewTaskDraftScreen(props: { [flow], ); - const handleNativePaste = useNativePaste((uris) => { - void handleNativePasteImages(uris); - }); - async function handleStart(): Promise { if ( !flow.selectedProject || @@ -478,23 +467,19 @@ export function NewTaskDraftScreen(props: { - void handleNativePaste(payload)} + void handleNativePasteImages(uris)} + placeholder={`Describe a coding task in ${selectedProject.title}`} style={{ flex: 1, minHeight: 0 }} - > - - + textStyle={{ fontSize: 18, lineHeight: 28 }} + /> ; @@ -87,6 +74,7 @@ export interface ThreadComposerProps { readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; + readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; @@ -156,10 +144,9 @@ function formatTitleCase(value: string): string { export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const isDarkMode = useColorScheme() === "dark"; - const themePlaceholderColor = useThemeColor("--color-placeholder"); - const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor; const foregroundColor = useThemeColor("--color-foreground"); - const inputRef = useRef(null); + const fallbackInputRef = useRef(null); + const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const wasExpandedBeforePreviewRef = useRef(false); const { onExpandedChange } = props; @@ -182,11 +169,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (wasExpandedBeforePreviewRef.current) { setTimeout(() => inputRef.current?.focus(), 100); } - }, []); + }, [inputRef]); - useEffect(() => { - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); + const handleFocus = useCallback(() => { + setIsFocused(true); + onExpandedChange?.(true); + }, [onExpandedChange]); + + const handleBlur = useCallback(() => { + setIsFocused(false); + onExpandedChange?.(false); + }, [onExpandedChange]); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting" || @@ -219,26 +212,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderDriver === "claudeAgent" ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") : "1M"; - - const handleNativePaste = useNativePaste((uris) => { - void props.onNativePasteImages(uris); - }); - // ── Trigger detection ──────────────────────────────────── - const [cursorPosition, setCursorPosition] = useState(0); + const [composerSelection, setComposerSelection] = useState(() => ({ + start: props.draftMessage.length, + end: props.draftMessage.length, + })); - const handleSelectionChange = useCallback( - (event: NativeSyntheticEvent) => { - const { start } = event.nativeEvent.selection; - setCursorPosition(start); - }, - [], - ); + const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { + setComposerSelection(selection); + }, []); + useEffect(() => { + const end = props.draftMessage.length; + setComposerSelection((selection) => { + const start = Math.min(selection.start, end); + const selectionEnd = Math.min(selection.end, end); + if (start === selection.start && selectionEnd === selection.end) { + return selection; + } + return { start, end: selectionEnd }; + }); + }, [props.draftMessage.length]); - const composerTrigger = useMemo( - () => detectComposerTrigger(props.draftMessage, cursorPosition), - [cursorPosition, props.draftMessage], - ); + const composerTrigger = useMemo(() => { + if (composerSelection.start !== composerSelection.end) { + return null; + } + return detectComposerTrigger(props.draftMessage, composerSelection.end); + }, [composerSelection, props.draftMessage]); const pathSearch = useComposerPathSearch({ environmentId: props.environmentId, cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, @@ -411,7 +411,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer composerTrigger.rangeEnd, "", ); - setCursorPosition(result.cursor); + setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); void onUpdateInteractionMode(item.command); return; @@ -434,7 +434,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer composerTrigger.rangeEnd, replacement, ); - setCursorPosition(result.cursor); + setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); }, [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], @@ -624,8 +624,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer - - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - textAlignVertical={isExpanded ? "top" : "center"} - style={ - isExpanded - ? { - minHeight: 80, - maxHeight: 160, - paddingHorizontal: 4, - paddingVertical: 4, - fontSize: 15, - lineHeight: 22, - color: foregroundColor, - fontFamily: "DMSans_400Regular", - } - : { - maxHeight: 36, - paddingVertical: 6, - fontSize: 15, - lineHeight: 20, - color: foregroundColor, - fontFamily: "DMSans_400Regular", - } - } - /> - + void props.onNativePasteImages(uris)} + placeholder={props.placeholder} + onFocus={handleFocus} + onBlur={handleBlur} + scrollEnabled={isExpanded} + contentInsetVertical={isExpanded ? 0 : 6} + style={ + isExpanded + ? { + minHeight: 80, + maxHeight: 160, + paddingHorizontal: 4, + paddingVertical: 4, + } + : { + height: 36, + } + } + textStyle={{ + fontSize: 15, + lineHeight: isExpanded ? 22 : 20, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + }} + /> {!isExpanded && props.draftAttachments.length > 0 ? ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 8e6050418fd..d035f6eb909 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -11,17 +11,20 @@ import type { } from "@t3tools/contracts"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; +import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, type LayoutChangeEvent } from "react-native"; +import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { runOnJS } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { MobileLayoutVariant } from "../../lib/mobileLayout"; +import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; import type { PendingApproval, PendingUserInput, @@ -33,7 +36,6 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, - COMPOSER_EXPANDED_TOOLBAR_CHROME, ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; @@ -200,7 +202,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const { onOpenDrawer } = props; const insets = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; + const composerRef = useRef(null); + const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const [composerExpanded, setComposerExpanded] = useState(false); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; @@ -211,10 +216,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; + const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0; - const feedBottomInset = - Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8; + const feedBottomInset = resolveThreadFeedBottomInset({ + estimatedOverlayHeight, + measuredOverlayHeight, + gap: 8, + }); + const selectedProviderSkills = useMemo( + () => + props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) + ?.skills ?? [], + [props.serverConfig, selectedInstanceId], + ); const completeDrawerGesture = useCallback(() => { void Haptics.selectionAsync(); @@ -245,20 +259,66 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ); }, []); + const collapseComposer = useCallback(() => { + composerRef.current?.blur(); + }, []); + + const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { + feedTouchStartRef.current = { + pageX: event.nativeEvent.pageX, + pageY: event.nativeEvent.pageY, + }; + }, []); + + const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => { + const start = feedTouchStartRef.current; + if (!start) { + return; + } + const deltaX = event.nativeEvent.pageX - start.pageX; + const deltaY = event.nativeEvent.pageY - start.pageY; + if (Math.hypot(deltaX, deltaY) > 8) { + feedTouchStartRef.current = null; + } + }, []); + + const handleFeedTouchEnd = useCallback(() => { + if (feedTouchStartRef.current) { + collapseComposer(); + } + feedTouchStartRef.current = null; + }, [collapseComposer]); + + const handleFeedTouchCancel = useCallback(() => { + feedTouchStartRef.current = null; + }, []); + return ( {showContent ? ( - + + + ) : ( )} @@ -298,6 +358,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} ; readonly httpBaseUrl: string | null; readonly bearerToken: string | null; readonly agentLabel: string; + readonly latestTurn: ThreadFeedLatestTurn | null; + readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly layoutVariant?: MobileLayoutVariant; readonly composerExpanded?: boolean; + readonly skills?: ReadonlyArray; } function stripShellWrapper(value: string): string { @@ -75,34 +109,48 @@ function compactActivityDetail(detail: string | null): string | null { } function buildActivityRows( - activities: ReadonlyArray<{ - readonly id: string; - readonly createdAt: string; - readonly summary: string; - readonly detail: string | null; - readonly status: string | null; - }>, + activities: Extract["activities"], ) { - return activities.map<{ - id: string; - createdAt: string; - summary: string; - detail: string | null; - status: string | null; - }>((activity) => ({ - id: activity.id, - createdAt: activity.createdAt, - summary: activity.summary, + return activities.map((activity) => ({ + ...activity, detail: compactActivityDetail(activity.detail), - status: activity.status, })); } -const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; +const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; -function toMarkdownThemeColor(value: ColorValue): string { - return value as string; -} +const MARKDOWN_COLORS = { + light: { + body: "#111111", + strong: "#000000", + link: "#2563eb", + blockquoteBorder: "rgba(0, 0, 0, 0.08)", + blockquoteBackground: "rgba(0, 0, 0, 0.02)", + codeBackground: "rgba(0, 0, 0, 0.04)", + codeText: "#262626", + horizontalRule: "rgba(0, 0, 0, 0.08)", + userBody: "#ffffff", + userCodeBackground: "rgba(255, 255, 255, 0.22)", + userCodeText: "#ffffff", + userFenceBackground: "rgba(0, 0, 0, 0.16)", + userFenceText: "#ffffff", + }, + dark: { + body: "#e5e5e5", + strong: "#f5f5f5", + link: "#60a5fa", + blockquoteBorder: "rgba(255, 255, 255, 0.1)", + blockquoteBackground: "rgba(255, 255, 255, 0.03)", + codeBackground: "rgba(255, 255, 255, 0.06)", + codeText: "#e5e5e5", + horizontalRule: "rgba(255, 255, 255, 0.08)", + userBody: "#ffffff", + userCodeBackground: "rgba(255, 255, 255, 0.18)", + userCodeText: "#ffffff", + userFenceBackground: "rgba(0, 0, 0, 0.28)", + userFenceText: "#ffffff", + }, +} as const; interface MarkdownStyleSets { readonly user: MarkdownStyleSet; @@ -113,6 +161,7 @@ interface MarkdownStyleSet { readonly theme: PartialMarkdownTheme; readonly styles: NodeStyleOverrides; readonly renderers: CustomRenderers; + readonly nativeTextStyle: NativeMarkdownTextStyle; } interface ReviewCommentColors { @@ -124,6 +173,70 @@ interface ReviewCommentColors { readonly codeBackground: ColorValue; } +const failedMarkdownFaviconHosts = new Set(); +const markdownLinkStyles = StyleSheet.create({ + favicon: { + width: 14, + height: 14, + borderRadius: 3, + marginHorizontal: 3, + transform: [{ translateY: 2 }], + }, + file: { + borderRadius: 5, + borderWidth: StyleSheet.hairlineWidth, + fontFamily: "DMSans_500Medium", + fontSize: 13, + lineHeight: 20, + paddingHorizontal: 6, + paddingVertical: 2, + }, + fileIcon: { + width: 15, + height: 15, + marginRight: 4, + transform: [{ translateY: 2 }], + }, +}); + +const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { + readonly children: ReactNode; + readonly color: string; + readonly host: string; + readonly href: string; +}) { + const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); + + return ( + { + void Linking.openURL(props.href); + }} + style={{ + color: props.color, + fontFamily: "DMSans_400Regular", + textDecorationLine: "none", + }} + > + {!failed ? ( + { + failedMarkdownFaviconHosts.add(props.host); + setFailed(true); + }} + /> + ) : ( + {" ◉ "} + )} + {props.children} + + ); +}); + function useReviewCommentColors(): ReviewCommentColors { const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; @@ -148,34 +261,26 @@ function useReviewCommentColors(): ReviewCommentColors { } function useMarkdownStyles(): MarkdownStyleSets { - const bodyColor = useThemeColor("--color-md-body"); - const strongColor = useThemeColor("--color-md-strong"); - const linkColor = useThemeColor("--color-md-link"); - const blockquoteBg = useThemeColor("--color-md-blockquote-bg"); - const blockquoteBorder = useThemeColor("--color-md-blockquote-border"); - const codeBg = useThemeColor("--color-md-code-bg"); - const codeText = useThemeColor("--color-md-code-text"); - const hrColor = useThemeColor("--color-md-hr"); - const userBodyColor = useThemeColor("--color-user-bubble-foreground"); - const userCodeBg = useThemeColor("--color-md-user-code-bg"); - const userCodeText = useThemeColor("--color-md-user-code-text"); - const userFenceBg = useThemeColor("--color-md-user-fence-bg"); - const userFenceText = useThemeColor("--color-md-user-fence-text"); + const colorScheme = useColorScheme(); + const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; + const inlineChipBackground = String(useThemeColor("--color-subtle")); + const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background")); + const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); return useMemo(() => { - const markdownBodyColor = toMarkdownThemeColor(bodyColor); - const markdownStrongColor = toMarkdownThemeColor(strongColor); - const markdownLinkColor = toMarkdownThemeColor(linkColor); - const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg); - const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder); - const markdownCodeBg = toMarkdownThemeColor(codeBg); - const markdownCodeText = toMarkdownThemeColor(codeText); - const markdownHrColor = toMarkdownThemeColor(hrColor); - const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor); - const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg); - const markdownUserCodeText = toMarkdownThemeColor(userCodeText); - const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg); - const markdownUserFenceText = toMarkdownThemeColor(userFenceText); + const markdownBodyColor = colors.body; + const markdownStrongColor = colors.strong; + const markdownLinkColor = colors.link; + const markdownBlockquoteBg = colors.blockquoteBackground; + const markdownBlockquoteBorder = colors.blockquoteBorder; + const markdownCodeBg = colors.codeBackground; + const markdownCodeText = colors.codeText; + const markdownHrColor = colors.horizontalRule; + const markdownUserBodyColor = colors.userBody; + const markdownUserCodeBg = colors.userCodeBackground; + const markdownUserCodeText = colors.userCodeText; + const markdownUserFenceBg = colors.userFenceBackground; + const markdownUserFenceText = colors.userFenceText; const baseTheme: PartialMarkdownTheme = { colors: { @@ -202,12 +307,12 @@ function useMarkdownStyles(): MarkdownStyleSets { fontSizes: { s: 13, m: 15, - h1: 22, - h2: 19, - h3: 17, - h4: 15, - h5: 15, - h6: 15, + h1: 20, + h2: 18, + h3: 16, + h4: 14, + h5: 14, + h6: 14, }, fontFamilies: { regular: "DMSans_400Regular", @@ -225,8 +330,8 @@ function useMarkdownStyles(): MarkdownStyleSets { const baseStyles: NodeStyleOverrides = { document: { flexShrink: 1 }, - paragraph: { marginTop: 0, marginBottom: 8 }, - list: { marginTop: 4, marginBottom: 4 }, + paragraph: { marginTop: 0, marginBottom: 10 }, + list: { marginTop: 4, marginBottom: 8 }, list_item: { marginTop: 0, marginBottom: 4 }, task_list_item: { marginTop: 0, marginBottom: 4 }, text: { lineHeight: 22 }, @@ -241,20 +346,18 @@ function useMarkdownStyles(): MarkdownStyleSets { textDecorationLine: "underline" as const, }, blockquote: { - borderLeftWidth: 3, + borderLeftWidth: 2, borderLeftColor: markdownBlockquoteBorder, - backgroundColor: markdownBlockquoteBg, - paddingLeft: 12, - paddingVertical: 6, + paddingLeft: 11, + paddingVertical: 2, marginLeft: 0, - marginVertical: 4, - borderRadius: 4, + marginVertical: 10, }, heading: { fontFamily: "DMSans_700Bold", color: markdownStrongColor, - marginTop: 12, - marginBottom: 6, + marginTop: 18, + marginBottom: 8, }, horizontal_rule: { backgroundColor: markdownHrColor, @@ -263,44 +366,173 @@ function useMarkdownStyles(): MarkdownStyleSets { }, }; - const createCodeRenderers = ( + const createMarkdownRenderers = ( inlineBackgroundColor: string, inlineTextColor: string, blockBackgroundColor: string, blockTextColor: string, ): CustomRenderers => ({ - code_inline: ({ content }) => ( - - {content} - + link: ({ children, href = "" }) => { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind === "file") { + return ( + + + {presentation.label} + + ); + } + if (presentation.kind === "external") { + return ( + + {children} + + ); + } + const linkHref = presentation.href; + return ( + { + void Linking.openURL(linkHref); + } + : undefined + } + style={{ + color: markdownLinkColor, + textDecorationLine: "underline", + }} + > + {children} + + ); + }, + list: ({ node, Renderer, ordered = false, start = 1 }) => ( + + {node.children?.map((child, index) => { + const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; + if (child.type === "task_list_item") { + return ( + + ); + } + return ( + + + {ordered ? `${start + index}.` : "•"} + + + + + + ); + })} + ), - code_block: ({ content }) => ( + code_inline: ({ content }) => { + const value = content ?? ""; + const wrapsPoorly = + value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":"); + return ( + + {value} + + ); + }, + code_block: ({ content, language }) => ( - + {language ? ( + + + {language} + + + ) : null} + {content} @@ -333,6 +565,8 @@ function useMarkdownStyles(): MarkdownStyleSets { heading: { ...baseStyles.heading, color: markdownUserBodyColor, + marginTop: 8, + marginBottom: 4, }, link: { color: markdownUserBodyColor, @@ -357,48 +591,79 @@ function useMarkdownStyles(): MarkdownStyleSets { user: { theme: userTheme, styles: userStyles, - renderers: createCodeRenderers( + renderers: createMarkdownRenderers( markdownUserCodeBg, markdownUserCodeText, markdownUserFenceBg, markdownUserFenceText, ), + nativeTextStyle: { + color: markdownUserBodyColor, + strongColor: markdownUserBodyColor, + mutedColor: markdownUserBodyColor, + linkColor: markdownUserBodyColor, + codeColor: markdownUserCodeText, + codeBackgroundColor: markdownUserCodeBg, + codeBlockBackgroundColor: markdownUserFenceBg, + fileBackgroundColor: "rgba(255, 255, 255, 0.12)", + fileTextColor: "#ffffff", + skillBackgroundColor: "rgba(217, 70, 239, 0.24)", + skillTextColor: "#ffffff", + quoteMarkerColor: markdownUserBodyColor, + dividerColor: markdownUserBodyColor, + fontSize: 15, + lineHeight: 22, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, }, assistant: { theme: assistantTheme, styles: assistantStyles, - renderers: createCodeRenderers( + renderers: createMarkdownRenderers( markdownCodeBg, markdownCodeText, markdownCodeBg, markdownCodeText, ), + nativeTextStyle: { + color: markdownBodyColor, + strongColor: markdownStrongColor, + mutedColor: markdownBodyColor, + linkColor: markdownLinkColor, + codeColor: markdownCodeText, + codeBackgroundColor: markdownCodeBg, + codeBlockBackgroundColor: markdownCodeBg, + fileBackgroundColor: inlineChipBackground, + fileTextColor: markdownCodeText, + skillBackgroundColor: inlineSkillBackground, + skillTextColor: inlineSkillForeground, + quoteMarkerColor: markdownBlockquoteBorder, + dividerColor: markdownHrColor, + fontSize: 15, + lineHeight: 22, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, }, }; - }, [ - blockquoteBg, - blockquoteBorder, - bodyColor, - codeBg, - codeText, - hrColor, - linkColor, - strongColor, - userBodyColor, - userCodeBg, - userCodeText, - userFenceBg, - userFenceText, - ]); + }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]); } function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; + readonly expandedWorkRows: Record; + readonly terminalAssistantMessageIds: ReadonlySet; + readonly unsettledTurnId: TurnId | null; readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string) => void; + readonly onToggleWorkRow: (rowId: string) => void; + readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; @@ -410,19 +675,50 @@ function renderFeedEntry( const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "turn-fold") { + return ( + props.onToggleTurnFold(entry.turnId)} + hitSlop={4} + className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" + > + + {entry.label} + + + + ); + } + if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; - const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`; + const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = message.text.includes(" ) : null} {attachments.map((attachment) => { @@ -459,9 +756,20 @@ function renderFeedEntry( ); })} - - {timestampLabel} - + + + {timestampLabel} + + {message.text.trim().length > 0 ? ( + + ) : null} + ); } @@ -473,16 +781,24 @@ function renderFeedEntry( } return ( - + {message.text.trim().length > 0 ? ( - - {message.text} - + hasNativeSelectableMarkdownText() ? ( + + ) : ( + + {message.text} + + ) ) : null} {attachments.map((attachment) => { const uri = messageImageUrl(props.httpBaseUrl, attachment.id); @@ -508,9 +824,20 @@ function renderFeedEntry( ); })} - - {timestampLabel} - + {showAssistantMeta ? ( + + + + {timestampLabel} + + + ) : null} ); } @@ -539,67 +866,121 @@ function renderFeedEntry( ); } - const rows = buildActivityRows(entry.activities); + const rows = buildActivityRows(entry.activities).filter( + (activity) => !(activity.toolLike && activity.status === "neutral"), + ); + if (rows.length === 0) { + return null; + } const isExpanded = props.expandedWorkGroups[entry.id] ?? false; const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; const hiddenCount = rows.length - visibleRows.length; - const showHeader = hasOverflow; + const onlyToolRows = rows.every((row) => row.toolLike); + const headerTitle = onlyToolRows + ? rows.length === 1 + ? "1 tool call" + : `${rows.length} tool calls` + : "Work log"; return ( - - {showHeader ? ( - - - Tool calls ({rows.length}) - - props.onToggleWorkGroup(entry.id)}> - + + + {headerTitle} + {hasOverflow ? ( + props.onToggleWorkGroup(entry.id)} + className="flex-row items-center gap-1" + > + {isExpanded ? "Show less" : `Show ${hiddenCount} more`} + - - ) : null} + ) : null} + {visibleRows.map((row, index) => ( - { + if (row.fullDetail) { + props.onToggleWorkRow(row.id); + } + }} + onLongPress={() => props.onCopyWorkRow(row.id, row.copyText)} className={cn( - "flex-row items-center gap-2 rounded-lg px-1 py-1", + "rounded-lg px-2 py-1.5", index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", )} > - - - - + + + + { - const copyValue = row.detail ?? row.summary; - props.onCopyWorkRow(row.id, copyValue); - }} - style={{ - fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", - }} + className="min-w-0 flex-1 text-[12px] leading-[18px] text-neutral-700 dark:text-neutral-300" + numberOfLines={props.expandedWorkRows[row.id] ? undefined : 1} > {row.detail ? `${row.summary} - ${row.detail}` : row.summary} - - {props.copiedRowId === row.id ? ( - - Copied - + {row.fullDetail ? ( + + ) : null} + {props.copiedRowId === row.id ? ( + + Copied + + ) : null} + + {row.fullDetail && props.expandedWorkRows[row.id] ? ( + + + {row.fullDetail} + + ) : null} - + ))} ); @@ -609,10 +990,20 @@ function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; readonly reviewCommentColors: ReviewCommentColors; + readonly skills?: ReadonlyArray; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); if (!hasReviewComment) { + if (hasNativeSelectableMarkdownText()) { + return ( + + ); + } return ( + ) : ( = 0 ? normalized.slice(lastSlashIndex + 1) : normalized; } -const IOS_NAV_BAR_HEIGHT = 44; - export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const listRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>(null); + const scrollFrameRef = useRef(null); + const foldSettleFrameRef = useRef(null); + const foldSettleSecondFrameRef = useRef(null); + const suppressAutoFollowRef = useRef(false); + const previousLatestTurnRef = useRef(props.latestTurn); + const isNearEndRef = useRef(true); + const initialScrollReadyRef = useRef(false); + const lastContentHeightRef = useRef(0); const { width: viewportWidth } = useWindowDimensions(); - const [copiedRowId, setCopiedRowId] = useState(null); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [interactionState, setInteractionState] = useState<{ + readonly copiedRowId: string | null; + readonly expandedWorkGroups: Record; + readonly expandedWorkRows: Record; + readonly expandedTurnIds: ReadonlySet; + }>({ + copiedRowId: null, + expandedWorkGroups: {}, + expandedWorkRows: {}, + expandedTurnIds: new Set(), + }); + const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedImage, setExpandedImage] = useState<{ uri: string; headers?: Record; @@ -835,47 +1249,193 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); - const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; + const topContentInset = props.contentTopInset ?? insets.top + 44; const bottomContentInset = props.contentBottomInset ?? 18; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); const markdownStyles = useMarkdownStyles(); const reviewCommentColors = useReviewCommentColors(); + const listAppearanceData = useMemo( + () => ({ + iconSubtleColor, + markdownStyles, + reviewCommentColors, + userBubbleColor, + }), + [iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor], + ); + const presentedFeed = useMemo( + () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), + [expandedTurnIds, props.feed, props.latestTurn], + ); + const terminalAssistantMessageIds = useMemo(() => { + const terminalIdsByTurn = new Map(); + for (const entry of props.feed) { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + terminalIdsByTurn.set(entry.message.turnId, entry.message.id); + } + } + return new Set(terminalIdsByTurn.values()); + }, [props.feed]); + const unsettledTurnId = + props.latestTurn && + (props.latestTurn.completedAt === null || props.latestTurn.state === "running") + ? props.latestTurn.turnId + : null; + + const scrollToEnd = useCallback(() => { + if (scrollFrameRef.current !== null) { + return; + } + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = null; + listRef.current?.scrollToEnd({ animated: false }); + }); + }, []); + + const onListScroll = useCallback( + (event: NativeSyntheticEvent | NativeScrollEvent) => { + const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; + const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; + isNearEndRef.current = isThreadFeedNearEnd( + { + contentHeight: contentSize.height, + viewportHeight: layoutMeasurement.height, + offsetY: contentOffset.y, + bottomInset: contentInset.bottom, + }, + THREAD_FEED_END_THRESHOLD, + ); + }, + [], + ); + + const onListContentSizeChange = useCallback( + (_width: number, height: number) => { + const contentGrew = height > lastContentHeightRef.current + 0.5; + lastContentHeightRef.current = height; + + if ( + initialScrollReadyRef.current && + contentGrew && + isNearEndRef.current && + !suppressAutoFollowRef.current + ) { + scrollToEnd(); + } + }, + [scrollToEnd], + ); + + const onListLoad = useCallback(() => { + initialScrollReadyRef.current = true; + }, []); useEffect(() => { - setCopiedRowId(null); - setExpandedWorkGroups({}); - }, [props.threadId]); + const previous = previousLatestTurnRef.current; + previousLatestTurnRef.current = props.latestTurn; + if (!props.latestTurn || !previous) { + return; + } + if (props.latestTurn.turnId === previous.turnId) { + if (previous.state === "running" && props.latestTurn.state === "interrupted") { + const interruptedTurnId = props.latestTurn.turnId; + setInteractionState((current) => ({ + ...current, + expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), + })); + } + return; + } + setInteractionState((current) => { + if (!current.expandedTurnIds.has(previous.turnId)) { + return current; + } + const next = new Set(current.expandedTurnIds); + next.delete(previous.turnId); + return { ...current, expandedTurnIds: next }; + }); + }, [props.latestTurn]); useEffect(() => { return () => { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } + if (scrollFrameRef.current !== null) { + cancelAnimationFrame(scrollFrameRef.current); + } + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); + } + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); + } }; }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { void Clipboard.setStringAsync(value); void Haptics.selectionAsync(); - setCopiedRowId(rowId); + setInteractionState((current) => ({ ...current, copiedRowId: rowId })); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } copyFeedbackTimeoutRef.current = setTimeout(() => { - setCopiedRowId((current) => (current === rowId ? null : current)); + setInteractionState((current) => + current.copiedRowId === rowId ? { ...current, copiedRowId: null } : current, + ); copyFeedbackTimeoutRef.current = null; }, 1200); }, []); const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((current) => ({ + setInteractionState((current) => ({ ...current, - [groupId]: !(current[groupId] ?? false), + expandedWorkGroups: { + ...current.expandedWorkGroups, + [groupId]: !(current.expandedWorkGroups[groupId] ?? false), + }, })); }, []); + const onToggleWorkRow = useCallback((rowId: string) => { + setInteractionState((current) => ({ + ...current, + expandedWorkRows: { + ...current.expandedWorkRows, + [rowId]: !(current.expandedWorkRows[rowId] ?? false), + }, + })); + }, []); + + const onToggleTurnFold = useCallback((turnId: TurnId) => { + suppressAutoFollowRef.current = true; + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); + } + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); + } + setInteractionState((current) => { + const next = new Set(current.expandedTurnIds); + if (next.has(turnId)) { + next.delete(turnId); + } else { + next.add(turnId); + } + return { ...current, expandedTurnIds: next }; + }); + foldSettleFrameRef.current = requestAnimationFrame(() => { + foldSettleSecondFrameRef.current = requestAnimationFrame(() => { + suppressAutoFollowRef.current = false; + foldSettleFrameRef.current = null; + foldSettleSecondFrameRef.current = null; + }); + }); + }, []); + const onPressImage = useCallback((uri: string, headers?: Record) => { setExpandedImage({ uri, headers }); }, []); @@ -887,18 +1447,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { copiedRowId, httpBaseUrl: props.httpBaseUrl, expandedWorkGroups, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, onCopyWorkRow, onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, onPressImage, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + skills: props.skills, }), [ copiedRowId, expandedWorkGroups, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, iconSubtleColor, userBubbleColor, markdownStyles, @@ -906,9 +1475,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentBubbleWidth, onCopyWorkRow, onPressImage, + onToggleTurnFold, onToggleWorkGroup, + onToggleWorkRow, props.bearerToken, props.httpBaseUrl, + props.skills, ], ); @@ -935,33 +1507,46 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( <> - `${entry.type}:${entry.id}`} - getItemType={(entry) => - entry.type === "message" ? `message:${entry.message.role}` : entry.type - } - keyboardShouldPersistTaps="handled" - estimatedItemSize={180} - initialScrollAtEnd - maintainScrollAtEnd={{ - on: { layout: true, itemLayout: true, dataChange: true }, - }} - maintainScrollAtEndThreshold={0.1} - safeAreaInsetBottom={insets.bottom} - contentContainerStyle={{ - paddingTop: 12, - paddingHorizontal: horizontalPadding, - }} - /> + + `${entry.type}:${entry.id}`} + getItemType={(entry) => + entry.type === "message" ? `message:${entry.message.role}` : entry.type + } + keyboardShouldPersistTaps="always" + keyboardDismissMode="none" + estimatedItemSize={180} + initialScrollAtEnd + onContentSizeChange={onListContentSizeChange} + onLoad={onListLoad} + onScroll={onListScroll} + scrollEventThrottle={16} + ListHeaderComponent={} + contentContainerStyle={{ + paddingTop: 12, + paddingBottom: bottomContentInset, + paddingHorizontal: horizontalPadding, + }} + /> + ; readonly selectedModel: ModelSelection | null; readonly selectedModelOption: ModelOption | null; + readonly selectedProviderSkills: ReadonlyArray; readonly providerGroups: ReadonlyArray; readonly filteredBranches: ReadonlyArray; readonly reset: () => void; @@ -283,6 +285,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; + const selectedProviderSkills = + (selectedProject + ? serverConfigByEnvironmentId[selectedProject.environmentId] + : null + )?.providers.find((provider) => provider.instanceId === selectedModel?.instanceId)?.skills ?? + []; const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); const setPrompt = useCallback( @@ -450,6 +458,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { modelOptions, selectedModel, selectedModelOption, + selectedProviderSkills, providerGroups, filteredBranches, reset, @@ -498,6 +507,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedModelKey, selectedModelOption, + selectedProviderSkills, selectedProject, selectedProjectKey, selectedWorktreePath, diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts new file mode 100644 index 00000000000..40e00a271f7 --- /dev/null +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; + +const files = new Map(); + +vi.mock("expo-file-system", () => ({ + File: class { + readonly uri: string; + + constructor(uri: string) { + this.uri = uri; + } + + get exists(): boolean { + return files.has(this.uri) && files.get(this.uri)?.deleted === false; + } + + async base64(): Promise { + const entry = files.get(this.uri); + if (!entry || entry.deleted) { + throw new Error("missing file"); + } + return entry.base64; + } + + delete(): void { + const entry = files.get(this.uri); + if (entry) { + entry.deleted = true; + } + } + }, +})); + +vi.mock("./uuid", () => ({ + uuidv4: () => "attachment-id", +})); + +import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; + +describe("native pasted image cleanup", () => { + beforeEach(() => { + files.clear(); + }); + + it("recognizes only files created in the native composer paste directory", () => { + expect( + isOwnedPastedImageUri( + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png", + ), + ).toBe(true); + expect(isOwnedPastedImageUri("file:///private/var/mobile/photos/id.png")).toBe(false); + expect(isOwnedPastedImageUri("https://example.com/t3-composer-paste/id.png")).toBe(false); + }); + + it("converts owned files to data-backed previews and deletes the source", async () => { + const uri = + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png"; + files.set(uri, { base64: "aGVsbG8=", deleted: false }); + + const attachments = await convertPastedImagesToAttachments({ + uris: [uri], + existingCount: 0, + }); + + expect(attachments).toEqual([ + expect.objectContaining({ + dataUrl: "data:image/png;base64,aGVsbG8=", + previewUri: "data:image/png;base64,aGVsbG8=", + }), + ]); + expect(files.get(uri)?.deleted).toBe(true); + }); + + it("deletes rejected and overflow owned files without deleting user-owned files", async () => { + const rejected = + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/bad.png"; + const overflow = + "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/overflow.png"; + const userOwned = "file:///private/var/mobile/photos/library.png"; + files.set(rejected, { base64: "", deleted: false }); + files.set(overflow, { base64: "aGVsbG8=", deleted: false }); + files.set(userOwned, { base64: "aGVsbG8=", deleted: false }); + + await convertPastedImagesToAttachments({ + uris: [rejected, overflow, userOwned], + existingCount: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1, + }); + + expect(files.get(rejected)?.deleted).toBe(true); + expect(files.get(overflow)?.deleted).toBe(true); + expect(files.get(userOwned)?.deleted).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 871982442e6..13b53af724e 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -10,6 +10,8 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } +const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; + function estimateBase64ByteSize(base64: string): number { const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; return Math.floor((base64.length * 3) / 4) - padding; @@ -213,17 +215,35 @@ function mimeTypeFromUri(uri: string): string { } } +export function isOwnedPastedImageUri(uri: string): boolean { + try { + const url = new URL(uri); + if (url.protocol !== "file:") { + return false; + } + const segments = url.pathname.split("/").filter(Boolean); + return ( + segments.at(-2) === OWNED_PASTED_IMAGE_DIRECTORY && segments.at(-1)?.endsWith(".png") === true + ); + } catch { + return false; + } +} + export async function convertPastedImagesToAttachments(input: { readonly uris: ReadonlyArray; readonly existingCount: number; }): Promise> { const { File } = await import("expo-file-system"); const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; - const uris = input.uris.slice(0, Math.max(0, remainingSlots)); const results: DraftComposerImageAttachment[] = []; - for (const uri of uris) { + for (const [index, uri] of input.uris.entries()) { + const ownedTemporaryFile = isOwnedPastedImageUri(uri); try { + if (index >= Math.max(0, remainingSlots)) { + continue; + } const file = new File(uri); const base64 = await file.base64(); const sizeBytes = estimateBase64ByteSize(base64); @@ -238,10 +258,21 @@ export async function convertPastedImagesToAttachments(input: { mimeType, sizeBytes, dataUrl: `data:${mimeType};base64,${base64}`, - previewUri: uri, + previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri, }); } catch (error) { console.warn("Failed to read pasted image", uri, error); + } finally { + if (ownedTemporaryFile) { + try { + const file = new File(uri); + if (file.exists) { + file.delete(); + } + } catch (error) { + console.warn("Failed to remove temporary pasted image", uri, error); + } + } } } diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts new file mode 100644 index 00000000000..d15a3a1a59b --- /dev/null +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + impactAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +vi.mock("expo-clipboard", () => ({ + setStringAsync: mocks.setStringAsync, +})); + +vi.mock("expo-haptics", () => ({ + ImpactFeedbackStyle: { + Light: "light", + }, + impactAsync: mocks.impactAsync, +})); + +import { copyTextWithHaptic } from "./copyTextWithHaptic"; + +describe("copyTextWithHaptic", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.setStringAsync.mockReturnValue(new Promise(() => undefined)); + mocks.impactAsync.mockResolvedValue(undefined); + }); + + it("triggers haptic feedback without waiting for the clipboard promise", () => { + copyTextWithHaptic("trace-123"); + + expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); + expect(mocks.impactAsync).toHaveBeenCalledWith("light"); + }); +}); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts new file mode 100644 index 00000000000..80f725f5b00 --- /dev/null +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -0,0 +1,7 @@ +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; + +export function copyTextWithHaptic(value: string): void { + void Clipboard.setStringAsync(value); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +} diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts new file mode 100644 index 00000000000..90153d0afa0 --- /dev/null +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; + +describe("resolveMarkdownLinkPresentation", () => { + it("extracts external link hosts", () => { + expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ + kind: "external", + href: "https://example.com/docs?q=1", + host: "example.com", + }); + }); + + it("renders file URLs as basename pills with positions", () => { + expect( + resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), + ).toEqual({ + kind: "file", + icon: "typescript", + label: "main.ts:42:7", + }); + }); + + it("recognizes relative source paths and bare filenames", () => { + expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({ + kind: "file", + icon: "typescript", + label: "index.ts:10", + }); + expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({ + kind: "file", + icon: "agents", + label: "AGENTS.md", + }); + expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ + kind: "file", + icon: "package", + label: "package.json", + }); + }); + + it("uses the Pierre complete icon mappings", () => { + expect(resolveMarkdownLinkPresentation("src/Button.tsx")).toMatchObject({ + kind: "file", + icon: "react", + }); + expect(resolveMarkdownLinkPresentation("vite.config.ts")).toMatchObject({ + kind: "file", + icon: "vite", + }); + expect(resolveMarkdownLinkPresentation("Dockerfile")).toMatchObject({ + kind: "file", + icon: "docker", + }); + expect(resolveMarkdownLinkPresentation("pnpm-lock.yaml")).toMatchObject({ + kind: "file", + icon: "pnpm", + }); + }); + + it("does not style app routes as file links", () => { + expect(resolveMarkdownLinkPresentation("/chat/settings")).toEqual({ + kind: "link", + href: null, + }); + }); +}); diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts new file mode 100644 index 00000000000..9d5c55686ec --- /dev/null +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -0,0 +1,734 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, +} from "@t3tools/mobile-markdown-text/markdown"; + +describe("nativeMarkdownTextRuns", () => { + it("preserves inline emphasis and code styles", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "plain " }, + { type: "bold", children: [{ type: "text", content: "bold" }] }, + { type: "text", content: " " }, + { type: "code_inline", content: "const value = 1" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "plain " }, + { text: "bold", bold: true }, + { text: " " }, + { text: "const value = 1", code: true }, + ]); + }); + + it("normalizes external and file links for native presentation", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "link", + href: "https://example.com/docs", + children: [{ type: "text", content: "Docs" }], + }, + { type: "text", content: " " }, + { + type: "link", + href: "file:///repo/README.md#L12", + children: [{ type: "text", content: "ignored label" }], + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { + text: "Docs", + href: "https://example.com/docs", + externalHost: "example.com", + }, + { text: " " }, + { text: "README.md:12", fileIcon: "readme" }, + ]); + }); + + it("keeps hard breaks and collapses soft breaks", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "first" }, + { type: "soft_break" }, + { type: "text", content: "second" }, + { type: "line_break" }, + { type: "text", content: "third" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]); + }); + + it("normalizes common inline HTML and entities", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "Less than: < " }, + { type: "html_inline", content: "" }, + { type: "text", content: "⌘" }, + { type: "html_inline", content: "" }, + { type: "html_inline", content: "
" }, + { type: "html_inline", content: "highlighted" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "Less than: < ⌘\nhighlighted" }]); + }); + + it("normalizes double-encoded entities and inline tags emitted as text", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "text", + content: + "Keyboard: + K; Less than: &lt;; Greater than: &gt;", + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "Keyboard: ⌘ + K; Less than: <; Greater than: >" }, + ]); + }); + + it("reads inline content from nested text nodes", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "text", + children: [{ type: "text", content: "Plain text" }], + }, + { type: "text", content: " and " }, + { + type: "code_inline", + children: [{ type: "text", content: "inline code" }], + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "Plain text and " }, + { text: "inline code", code: true }, + ]); + }); +}); + +describe("nativeMarkdownDocumentRuns", () => { + it("decorates known skill references as selectable skill chips", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $ui for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ + { text: "Use ", role: "body" }, + { + text: "$ui", + role: "body", + skillName: "ui", + skillLabel: "UI", + }, + { text: " for this.", role: "body" }, + ]); + }); + + it("leaves unknown skill-like text unchanged", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $unknown for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [])).toEqual([ + { text: "Use $unknown for this.", role: "body" }, + ]); + }); + + it("keeps headings, paragraphs, and lists in one continuous document", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + children: [{ type: "text", content: "Header One" }], + }, + { + type: "paragraph", + children: [ + { type: "text", content: "A paragraph with " }, + { type: "bold", children: [{ type: "text", content: "bold text" }] }, + { type: "text", content: "." }, + ], + }, + { + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "First item" }], + }, + ], + }, + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Second item" }], + }, + ], + }, + ], + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node); + expect(runs.map((run) => run.text).join("")).toBe( + "Header One\n\nA paragraph with bold text.\n\n•\tFirst item\n•\tSecond item", + ); + expect(runs).toContainEqual({ + text: "Header One\n", + role: "heading", + headingLevel: 1, + }); + expect(runs).toContainEqual({ + text: "bold text", + bold: true, + role: "body", + }); + expect(runs).toContainEqual({ + text: "•\t", + role: "list-marker", + depth: 1, + firstLineHeadIndent: 0, + headIndent: 24, + paragraphSpacing: 2, + }); + }); + + it("uses distinct section, heading-content, and body spacing", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Intro" }], + }, + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Section" }], + }, + { + type: "paragraph", + children: [{ type: "text", content: "First paragraph" }], + }, + { + type: "paragraph", + children: [{ type: "text", content: "Second paragraph" }], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node) + .filter((run) => run.role === "spacer") + .map((run) => run.spacing), + ).toEqual([20, 10, 12]); + }); + + it("renders tight list items whose inline nodes are direct children", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { + type: "bold", + children: [{ type: "text", content: "Finding:" }], + }, + { type: "text", content: " details with " }, + { type: "code_inline", content: "inline code" }, + { type: "text", content: "." }, + ], + }, + ], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node)).toEqual([ + { + text: "•\t", + role: "list-marker", + depth: 1, + firstLineHeadIndent: 0, + headIndent: 24, + paragraphSpacing: 2, + }, + { text: "Finding:", bold: true, role: "body", depth: 1 }, + { text: " details with ", role: "body", depth: 1 }, + { text: "inline code", code: true, role: "body", depth: 1 }, + { text: ".", role: "body", depth: 1 }, + ]); + }); + + it("includes quotes and fenced code in the same selectable string", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Read this" }], + }, + ], + }, + { + type: "code_block", + language: "ts", + content: "const answer = 42;", + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node); + expect(runs.map((run) => run.text).join("")).toBe("│\u00a0Read this\n\nTS\nconst answer = 42;"); + expect(runs).toContainEqual({ + text: "const answer = 42;", + code: true, + role: "code-block", + }); + }); + + it("reads fenced code content from child text nodes", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node) + .map((run) => run.text) + .join(""), + ).toBe("BASH\npnpm install"); + }); +}); + +describe("nativeMarkdownListItemBlocks", () => { + it("groups consecutive inline nodes into one paragraph block", () => { + const item: MarkdownNode = { + type: "list_item", + children: [ + { type: "text", content: "Finding: " }, + { type: "bold", children: [{ type: "text", content: "important" }] }, + { type: "text", content: " details." }, + { + type: "list", + children: [ + { + type: "list_item", + children: [{ type: "text", content: "Nested" }], + }, + ], + }, + { type: "text", content: "Trailing prose." }, + ], + }; + + expect(nativeMarkdownListItemBlocks(item)).toEqual([ + { + type: "paragraph", + children: item.children?.slice(0, 3), + }, + item.children?.[3], + { + type: "paragraph", + children: [item.children?.[4]], + }, + ]); + }); +}); + +describe("nativeMarkdownDocumentChunks", () => { + it("keeps headings and plain lists in one selectable document", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Tasks" }], + }, + { + type: "list", + children: [ + { + type: "task_list_item", + checked: true, + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Completed" }], + }, + ], + }, + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Parent" }], + }, + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Nested" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect( + nativeMarkdownDocumentRuns(chunks[0]?.node ?? document) + .map((run) => run.text) + .join(""), + ).toBe("Tasks\n\n☑︎\tCompleted\n•\tParent\n◦\tNested"); + }); + + it("aligns ordered markers while keeping the list in one selectable string", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + ordered: true, + start: 9, + children: [ + { + type: "list_item", + children: [{ type: "text", content: "Ninth" }], + }, + { + type: "list_item", + children: [{ type: "text", content: "Tenth" }], + }, + ], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(document) + .map((run) => run.text) + .join(""), + ).toBe("\u20079.\tNinth\n10.\tTenth"); + }); + + it("keeps prose selectable while exposing rich AST blocks", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + beg: 0, + end: 9, + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + beg: 11, + end: 35, + children: [{ type: "text", content: "pnpm install\n" }], + }, + { + type: "paragraph", + beg: 37, + end: 42, + children: [{ type: "text", content: "Done." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:code_block:11:35", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("keeps a list containing fenced code as one rich AST container", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + beg: 0, + end: 45, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }, + ], + }, + ], + }; + + expect(nativeMarkdownDocumentChunks(document)).toEqual([ + { + kind: "rich", + key: "rich:list:0:45", + node: document.children?.[0], + }, + ]); + }); + + it("keeps surrounding prose selectable when rich nodes have no source offsets", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + children: [{ type: "text", content: "Before" }], + }, + { type: "horizontal_rule" }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:horizontal_rule:1:1", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("keeps offset-free structural lists isolated without promoting the whole document", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Before." }], + }, + { + type: "list", + ordered: true, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }, + ], + }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:list:1:1", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("never collapses a rich subtree into a second markdown parsing pass", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Before." }], + }, + { + type: "blockquote", + children: [ + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { type: "text", content: "Run this" }, + { + type: "code_block", + language: "sh", + children: [{ type: "text", content: "vp check\n" }], + }, + ], + }, + ], + }, + ], + }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich", "selectable"]); + expect(chunks[1]).toMatchObject({ + kind: "rich", + node: { type: "blockquote" }, + }); + }); + + it("keeps a plain list in one selectable native text container", () => { + const list: MarkdownNode = { + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [{ type: "text", content: "First" }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks({ + type: "document", + children: [list], + }); + + expect(chunks).toHaveLength(1); + expect(chunks[0]).toMatchObject({ + kind: "selectable", + node: { type: "document", children: [list] }, + }); + }); + + it("separates sections more than related rich blocks", () => { + const headingChunk = { + kind: "selectable" as const, + key: "heading", + node: { + type: "document", + children: [ + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Section" }], + }, + ], + } satisfies MarkdownNode, + }; + const firstList = { + kind: "rich" as const, + key: "list-1", + node: { type: "list", children: [] } satisfies MarkdownNode, + }; + const secondList = { + kind: "rich" as const, + key: "list-2", + node: { type: "list", children: [] } satisfies MarkdownNode, + }; + + expect(nativeMarkdownChunkSpacing(undefined, headingChunk)).toBe(0); + expect(nativeMarkdownChunkSpacing(headingChunk, firstList)).toBe(10); + expect(nativeMarkdownChunkSpacing(firstList, secondList)).toBe(12); + expect(nativeMarkdownChunkSpacing(firstList, headingChunk)).toBe(20); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 94354df744e..b500752c5d9 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EventId, + MessageId, ProjectId, ProviderInstanceId, ThreadId, @@ -10,7 +11,7 @@ import { type OrchestrationThreadActivity, } from "@t3tools/contracts"; -import { buildThreadFeed } from "./threadActivity"; +import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity"; function makeActivity( input: Partial & @@ -48,7 +49,7 @@ function makeThread( } describe("buildThreadFeed", () => { - it("includes runtime warnings from the latest turn", () => { + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), @@ -86,22 +87,16 @@ describe("buildThreadFeed", () => { }); const feed = buildThreadFeed(thread, [], null); - const group = feed[0]; - - expect(group).toMatchObject({ - type: "activity-group", - }); - if (!group || group.type !== "activity-group") { - return; - } - - expect(group.activities).toEqual([ + expect(feed).toMatchObject([ + { + type: "activity-group", + turnId: "turn-old", + activities: [{ id: "activity-old", turnId: "turn-old" }], + }, { - id: "activity-latest", - createdAt: "2026-04-01T00:00:03.000Z", - summary: "Runtime warning", - detail: null, - status: null, + type: "activity-group", + turnId: "turn-latest", + activities: [{ id: "activity-latest", turnId: "turn-latest" }], }, ]); }); @@ -163,10 +158,201 @@ describe("buildThreadFeed", () => { { id: "tool-completed", createdAt: "2026-04-01T00:00:02.000Z", + turnId: "turn-1", summary: "Run tests", detail: "bun run test", - status: null, + fullDetail: null, + copyText: "Run tests\nbun run test", + toolLike: true, + status: "success", }, ]); }); + + it("folds settled turn work while leaving the terminal answer visible", () => { + const turnId = TurnId.make("turn-1"); + const thread = makeThread({ + id: ThreadId.make("thread-3"), + projectId: ProjectId.make("project-1"), + title: "Folded work", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:18.000Z", + assistantMessageId: MessageId.make("assistant-final"), + }, + messages: [ + { + id: MessageId.make("assistant-commentary"), + role: "assistant", + text: "I am checking.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:03.000Z", + }, + { + id: MessageId.make("assistant-final"), + role: "assistant", + text: "Done.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:17.000Z", + updatedAt: "2026-04-01T00:00:18.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("tool-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Read files", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + title: "Read files", + itemType: "file_read", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); + expect(collapsed[0]).toMatchObject({ + type: "turn-fold", + label: "Worked for 17s", + expanded: false, + }); + + const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); + expect(expanded.map((entry) => entry.id)).toEqual([ + "turn-fold:turn-1", + "assistant-commentary", + "tool-completed", + "assistant-final", + ]); + }); + + it("measures a steer-superseded turn from its user boundary through trailing work", () => { + const firstTurnId = TurnId.make("turn-1"); + const secondTurnId = TurnId.make("turn-2"); + const thread = makeThread({ + id: ThreadId.make("thread-steered"), + projectId: ProjectId.make("project-1"), + title: "Steered work", + latestTurn: { + turnId: secondTurnId, + state: "running", + requestedAt: "2026-04-01T00:00:14.000Z", + startedAt: "2026-04-01T00:00:14.000Z", + completedAt: null, + assistantMessageId: MessageId.make("assistant-next"), + }, + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "Do it once more.", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + { + id: MessageId.make("assistant-commentary"), + role: "assistant", + text: "Kicking off call 1.", + turnId: firstTurnId, + streaming: false, + createdAt: "2026-04-01T00:00:09.000Z", + updatedAt: "2026-04-01T00:00:09.000Z", + }, + { + id: MessageId.make("user-2"), + role: "user", + text: "Actually do 15.", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:14.000Z", + updatedAt: "2026-04-01T00:00:14.000Z", + }, + { + id: MessageId.make("assistant-next"), + role: "assistant", + text: "One down - adjusting.", + turnId: secondTurnId, + streaming: true, + createdAt: "2026-04-01T00:00:17.000Z", + updatedAt: "2026-04-01T00:00:17.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("work-1"), + kind: "tool.completed", + tone: "tool", + summary: "Ran command", + createdAt: "2026-04-01T00:00:12.000Z", + turnId: firstTurnId, + payload: { + title: "Ran command", + itemType: "command_execution", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ + turnId: firstTurnId, + label: "Worked for 12s", + }); + }); + + it("keeps an active turn expanded and classifies error-shaped tool output", () => { + const turnId = TurnId.make("turn-running"); + const thread = makeThread({ + id: ThreadId.make("thread-4"), + projectId: ProjectId.make("project-1"), + title: "Running work", + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("tool-failed"), + kind: "tool.completed", + tone: "tool", + summary: "Run command", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + title: "Run command", + itemType: "command_execution", + detail: "zsh: command not found: nope", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); + expect(feed[0]).toMatchObject({ + type: "activity-group", + activities: [{ status: "failure" }], + }); + }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 6ff27cadfee..e5fdb439954 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -3,13 +3,15 @@ import type { CommandId, EnvironmentId, MessageId, + OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, - TurnId, ToolLifecycleItemType, ThreadId, + TurnId, UserInputQuestion, } from "@t3tools/contracts"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import type { DraftComposerImageAttachment } from "./composerImages"; import * as Arr from "effect/Array"; @@ -46,14 +48,21 @@ export interface QueuedThreadMessage { export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly summary: string; readonly detail: string | null; - readonly status: string | null; + readonly fullDetail: string | null; + readonly copyText: string; + readonly toolLike: boolean; + readonly status: "success" | "failure" | "neutral" | null; } +type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; + interface WorkLogEntry { id: string; createdAt: string; + turnId: TurnId | null; label: string; detail?: string; command?: string; @@ -63,6 +72,7 @@ interface WorkLogEntry { toolTitle?: string; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; + toolLifecycleStatus?: WorkLogToolLifecycleStatus; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -88,6 +98,7 @@ type RawThreadFeedEntry = readonly type: "activity"; readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly activity: ThreadFeedActivity; }; @@ -97,9 +108,23 @@ export type ThreadFeedEntry = readonly type: "activity-group"; readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly activities: ReadonlyArray; + } + | { + readonly type: "turn-fold"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId; + readonly label: string; + readonly expanded: boolean; }; +export type ThreadFeedLatestTurn = Pick< + OrchestrationLatestTurn, + "turnId" | "state" | "startedAt" | "completedAt" +>; + function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { switch (requestType) { case "command_execution_approval": @@ -202,14 +227,12 @@ function resolvePendingUserInputAnswer( function deriveWorkLogEntries( activities: ReadonlyArray, - latestTurnId: TurnId | undefined, ): WorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { - if (latestTurnId && activity.turnId !== latestTurnId) continue; if (activity.kind === "tool.started") continue; - if (activity.kind === "task.started" || activity.kind === "task.completed") continue; + if (activity.kind === "task.started") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; @@ -240,16 +263,40 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); + const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; + const taskSummary = + isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 + ? payload.summary + : null; + const taskDetailAsLabel = + isTaskActivity && + !taskSummary && + typeof payload?.detail === "string" && + payload.detail.length > 0 + ? payload.detail + : null; + const taskLabel = taskSummary || taskDetailAsLabel; const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, - label: activity.summary, - tone: activity.tone === "approval" ? "info" : activity.tone, + turnId: activity.turnId, + label: taskLabel || activity.summary, + tone: + activity.kind === "task.progress" + ? "thinking" + : activity.tone === "approval" + ? "info" + : activity.tone, activityKind: activity.kind, }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); - if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { + if ( + !taskDetailAsLabel && + payload && + typeof payload.detail === "string" && + payload.detail.length > 0 + ) { const detail = stripTrailingExitCode(payload.detail).output; if (detail) { entry.detail = detail; @@ -273,6 +320,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } + let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); + if (!toolLifecycleStatus && activity.kind === "tool.completed") { + toolLifecycleStatus = "completed"; + } + if (toolLifecycleStatus) { + entry.toolLifecycleStatus = toolLifecycleStatus; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -323,6 +377,7 @@ function mergeDerivedWorkLogEntries( const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; + const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; return { ...previous, ...next, @@ -334,6 +389,7 @@ function mergeDerivedWorkLogEntries( ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), + ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), }; } @@ -365,6 +421,78 @@ function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { + if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { + return true; + } + if (entry.command !== undefined && entry.command.trim().length > 0) { + return true; + } + if (entry.requestKind !== undefined) { + return true; + } + return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); +} + +function toolDetailTextLooksLikeFailure(text: string): boolean { + const normalized = text.toLowerCase(); + return ( + normalized.includes("file not found") || + normalized.includes("no files found") || + normalized.includes("enoent") || + normalized.includes("no such file or directory") || + normalized.includes("no such file") || + normalized.includes("commandnotfoundexception") || + normalized.includes("command not found") || + (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || + (normalized.includes("is not recognized") && normalized.includes("the term '")) || + //i.test(text) || + /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || + /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) + ); +} + +function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { + if (entry.tone === "error") { + return true; + } + if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") { + return true; + } + if (!workLogEntryIsToolLike(entry)) { + return false; + } + return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n")); +} + +function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { + if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { + return false; + } + if (entry.tone === "thinking") { + return false; + } + return ( + entry.toolLifecycleStatus !== "inProgress" && + entry.toolLifecycleStatus !== "stopped" && + entry.toolLifecycleStatus !== "failed" && + entry.toolLifecycleStatus !== "declined" + ); +} + +function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { + if (!workLogEntryIsToolLike(entry)) { + return null; + } + if (workEntryIndicatesToolFailure(entry)) { + return "failure"; + } + if (workEntryIndicatesToolSuccess(entry)) { + return "success"; + } + return "neutral"; +} + function workEntryPreview( workEntry: Pick, ): string | null { @@ -592,6 +720,22 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } +function extractWorkLogToolLifecycleStatus( + payload: Record | null, +): WorkLogToolLifecycleStatus | undefined { + const status = payload?.status; + if ( + status === "inProgress" || + status === "completed" || + status === "failed" || + status === "declined" || + status === "stopped" + ) { + return status; + } + return undefined; +} + function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; @@ -743,7 +887,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th } const previous = grouped.at(-1); - if (previous?.type === "activity-group") { + if (previous?.type === "activity-group" && previous.turnId === entry.turnId) { grouped[grouped.length - 1] = { ...previous, activities: [...previous.activities, entry.activity], @@ -755,6 +899,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th type: "activity-group", id: entry.id, createdAt: entry.createdAt, + turnId: entry.turnId, activities: [entry.activity], }); } @@ -762,6 +907,179 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th return grouped; } +function computeElapsedMs(startIso: string, endIso: string): number | null { + const start = Date.parse(startIso); + const end = Date.parse(endIso); + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + return Math.max(0, end - start); +} + +function maxIsoTimestamp(a: string | null, b: string | null): string | null { + if (a === null) return b; + if (b === null) return a; + const aMs = Date.parse(a); + const bMs = Date.parse(b); + if (!Number.isFinite(aMs)) return b; + if (!Number.isFinite(bMs)) return a; + return bMs > aMs ? b : a; +} + +function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null { + if (!latestTurn) { + return null; + } + const settled = latestTurn.completedAt !== null && latestTurn.state !== "running"; + return settled ? null : latestTurn.turnId; +} + +interface ThreadFeedTurnFold { + readonly turnId: TurnId; + readonly createdAt: string; + readonly hiddenEntryIds: ReadonlySet; + readonly label: string; +} + +function deriveThreadFeedTurnFolds( + feed: ReadonlyArray, + latestTurn: ThreadFeedLatestTurn | null, +): ReadonlyMap { + const terminalAssistantMessageIdByTurn = new Map(); + for (const entry of feed) { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); + } + } + + interface TurnGroup { + readonly entries: ThreadFeedEntry[]; + readonly startBoundary: string | null; + } + const groupsByTurnId = new Map(); + let pendingUserBoundary: string | null = null; + for (const entry of feed) { + if (entry.type === "message" && entry.message.role === "user") { + pendingUserBoundary = entry.message.createdAt; + continue; + } + const turnId = + entry.type === "message" && entry.message.role === "assistant" + ? entry.message.turnId + : entry.type === "activity-group" + ? entry.turnId + : null; + if (!turnId) { + continue; + } + let group = groupsByTurnId.get(turnId); + if (!group) { + group = { + entries: [], + startBoundary: pendingUserBoundary, + }; + pendingUserBoundary = null; + groupsByTurnId.set(turnId, group); + } + group.entries.push(entry); + } + + const unsettledTurnId = deriveUnsettledTurnId(latestTurn); + const foldsByAnchorId = new Map(); + for (const [turnId, group] of groupsByTurnId) { + const { entries } = group; + if (turnId === unsettledTurnId) { + continue; + } + if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) { + continue; + } + + const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); + const hiddenEntryIds = new Set( + entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), + ); + if (hiddenEntryIds.size === 0) { + continue; + } + + const firstEntry = entries[0]; + const lastEntry = entries.at(-1); + if (!firstEntry || !lastEntry) { + continue; + } + const terminalEntry = terminalAssistantMessageId + ? entries.find((entry) => entry.id === terminalAssistantMessageId) + : null; + const latestTurnMatches = latestTurn?.turnId === turnId; + const lastEntryEnd = + lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; + const elapsedMs = + latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt + ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt) + : computeElapsedMs( + group.startBoundary ?? firstEntry.createdAt, + maxIsoTimestamp( + terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null, + lastEntryEnd, + ) ?? lastEntryEnd, + ); + const duration = elapsedMs === null ? null : formatDuration(elapsedMs); + const interrupted = latestTurnMatches && latestTurn.state === "interrupted"; + const label = interrupted + ? duration + ? `You stopped after ${duration}` + : "You stopped this response" + : duration + ? `Worked for ${duration}` + : "Worked"; + + foldsByAnchorId.set(firstEntry.id, { + turnId, + createdAt: firstEntry.createdAt, + hiddenEntryIds, + label, + }); + } + return foldsByAnchorId; +} + +export function deriveThreadFeedPresentation( + feed: ReadonlyArray, + latestTurn: ThreadFeedLatestTurn | null, + expandedTurnIds: ReadonlySet, +): ThreadFeedEntry[] { + const sourceFeed = feed.filter((entry) => entry.type !== "turn-fold"); + const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); + const collapsedEntryIds = new Set(); + for (const fold of foldsByAnchorId.values()) { + if (!expandedTurnIds.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); + } + } + } + + const result: ThreadFeedEntry[] = []; + for (const entry of sourceFeed) { + const fold = foldsByAnchorId.get(entry.id); + if (fold) { + result.push({ + type: "turn-fold", + id: `turn-fold:${fold.turnId}`, + createdAt: fold.createdAt, + turnId: fold.turnId, + label: fold.label, + expanded: expandedTurnIds.has(fold.turnId), + }); + } + if (!collapsedEntryIds.has(entry.id)) { + result.push(entry); + } + } + return result; +} + export function derivePendingApprovals( activities: ReadonlyArray, ): PendingApproval[] { @@ -893,10 +1211,7 @@ export function buildThreadFeed( const loadedMessages = options?.loadedMessages ?? thread.messages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; - const workLogEntries = deriveWorkLogEntries( - thread.activities, - thread.latestTurn?.turnId ?? undefined, - ); + const workLogEntries = deriveWorkLogEntries(thread.activities); const entries = Arr.sortWith( [ ...loadedMessages.map((message) => ({ @@ -921,18 +1236,36 @@ export function buildThreadFeed( oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt ); }) - .map((entry) => ({ - type: "activity", - id: entry.id, - createdAt: entry.createdAt, - activity: { + .map((entry) => { + const summary = workEntryHeading(entry); + const detail = workEntryPreview(entry); + const normalizedFullDetail = entry.detail + ? unwrapKnownShellCommandWrapper(entry.detail) + : null; + const fullDetail = + normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null; + return { + type: "activity", id: entry.id, createdAt: entry.createdAt, - summary: workEntryHeading(entry), - detail: workEntryPreview(entry), - status: null, - }, - })), + turnId: entry.turnId, + activity: { + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + summary, + detail, + fullDetail, + copyText: [summary, detail, fullDetail] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"), + toolLike: workLogEntryIsToolLike(entry), + status: workEntryStatus(entry), + }, + }; + }), ], (s) => new Date(s.createdAt), Order.Date, diff --git a/apps/mobile/src/lib/threadFeedLayout.test.ts b/apps/mobile/src/lib/threadFeedLayout.test.ts new file mode 100644 index 00000000000..73f113eac38 --- /dev/null +++ b/apps/mobile/src/lib/threadFeedLayout.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isThreadFeedNearEnd, + resolveThreadFeedBottomInset, + threadFeedDistanceFromEnd, +} from "./threadFeedLayout"; + +describe("thread feed layout", () => { + it("accounts for the bottom inset when measuring distance from the end", () => { + const metrics = { + contentHeight: 900, + viewportHeight: 600, + offsetY: 380, + bottomInset: 100, + }; + + expect(threadFeedDistanceFromEnd(metrics)).toBe(20); + expect(isThreadFeedNearEnd(metrics, 50)).toBe(true); + expect(isThreadFeedNearEnd(metrics, 10)).toBe(false); + }); + + it("does not double count chrome already included in the measured composer overlay", () => { + expect( + resolveThreadFeedBottomInset({ + estimatedOverlayHeight: 162, + measuredOverlayHeight: 182, + gap: 8, + }), + ).toBe(190); + }); +}); diff --git a/apps/mobile/src/lib/threadFeedLayout.ts b/apps/mobile/src/lib/threadFeedLayout.ts new file mode 100644 index 00000000000..de7946f866d --- /dev/null +++ b/apps/mobile/src/lib/threadFeedLayout.ts @@ -0,0 +1,22 @@ +export interface ThreadFeedScrollMetrics { + readonly contentHeight: number; + readonly viewportHeight: number; + readonly offsetY: number; + readonly bottomInset: number; +} + +export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number { + return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY; +} + +export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean { + return threadFeedDistanceFromEnd(metrics) <= threshold; +} + +export function resolveThreadFeedBottomInset(input: { + readonly estimatedOverlayHeight: number; + readonly measuredOverlayHeight: number; + readonly gap: number; +}): number { + return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx new file mode 100644 index 00000000000..488766f3695 --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -0,0 +1,21 @@ +import { + SelectableMarkdownText as T3SelectableMarkdownText, + type SelectableMarkdownTextProps, +} from "@t3tools/mobile-markdown-text/renderer"; + +import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { + return ; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx new file mode 100644 index 00000000000..403f32a1de4 --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -0,0 +1,16 @@ +import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return false; +} + +export function SelectableMarkdownText(_props: MobileSelectableMarkdownTextProps) { + return null; +} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx new file mode 100644 index 00000000000..6778b0455d5 --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -0,0 +1,180 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { requireNativeView } from "expo"; +import { useImperativeHandle, useMemo, useRef, type Ref } from "react"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; +import { Image, StyleSheet } from "react-native"; + +import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { useThemeColor } from "../lib/useThemeColor"; +import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; + +const NATIVE_MODULE_NAME = "T3ComposerEditor"; +const EMPTY_SKILLS: NonNullable = []; + +type NativeEditorEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly selection: ComposerEditorSelection; +}>; + +type NativeSelectionEvent = NativeSyntheticEvent<{ + readonly selection: ComposerEditorSelection; +}>; + +type NativePasteImagesEvent = NativeSyntheticEvent<{ + readonly uris: ReadonlyArray; +}>; + +interface NativeComposerEditorRef { + focus: () => Promise; + blur: () => Promise; + setSelection: (start: number, end: number) => Promise; +} + +interface NativeComposerEditorProps extends ViewProps { + readonly ref?: Ref; + readonly value: string; + readonly tokensJson: string; + readonly selectionJson: string; + readonly themeJson: string; + readonly placeholder: string; + readonly fontFamily: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly contentInsetVertical: number; + readonly editable: boolean; + readonly scrollEnabled: boolean; + readonly autoFocus: boolean; + readonly autoCorrect: boolean; + readonly spellCheck: boolean; + readonly onComposerChange: (event: NativeEditorEvent) => void; + readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; + readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; + readonly onComposerFocus?: () => void; + readonly onComposerBlur?: () => void; +} + +const NativeView = requireNativeView(NATIVE_MODULE_NAME); + +function basename(path: string): string { + const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separator >= 0 ? path.slice(separator + 1) : path; +} + +function fileIconUri(path: string): string { + return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri; +} + +export function ComposerEditor({ + ref, + skills = EMPTY_SKILLS, + selection, + style, + textStyle, + onChangeText, + onSelectionChange, + onPasteImages, + onFocus, + onBlur, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const nativeRef = useRef(null); + const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); + const textColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const chipBackground = useThemeColor("--color-subtle"); + const chipBorder = useThemeColor("--color-border"); + const chipText = useThemeColor("--color-foreground"); + const skillBackground = useThemeColor("--color-inline-skill-background"); + const skillBorder = useThemeColor("--color-inline-skill-border"); + const skillText = useThemeColor("--color-inline-skill-foreground"); + const fileTint = useThemeColor("--color-icon-muted"); + + useImperativeHandle( + ref, + () => ({ + focus: () => void nativeRef.current?.focus(), + blur: () => void nativeRef.current?.blur(), + setSelection: (nextSelection) => + void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + const skillLabels = useMemo( + () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])), + [skills], + ); + const tokensJson = useMemo(() => { + const tokens = collectComposerInlineTokens(props.value, { + preserveTrailingFrom: confirmedTokensRef.current, + }); + confirmedTokensRef.current = tokens; + return JSON.stringify( + tokens.map((token) => ({ + type: token.type, + source: token.source, + start: token.start, + end: token.end, + label: + token.type === "skill" + ? (skillLabels.get(token.value) ?? token.value) + : basename(token.value), + iconUri: token.type === "mention" ? fileIconUri(token.value) : null, + })), + ); + }, [props.value, skillLabels]); + const themeJson = JSON.stringify({ + text: String(textColor), + placeholder: String(placeholderColor), + chipBackground: String(chipBackground), + chipBorder: String(chipBorder), + chipText: String(chipText), + skillBackground: String(skillBackground), + skillBorder: String(skillBorder), + skillText: String(skillText), + fileTint: String(fileTint), + }); + const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; + return ( + } + onComposerChange={(event) => { + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + }} + onComposerSelectionChange={(event) => onSelectionChange?.(event.nativeEvent.selection)} + onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerFocus={onFocus} + onComposerBlur={onBlur} + /> + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx new file mode 100644 index 00000000000..0f20e9e042d --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -0,0 +1,65 @@ +import { TextInputWrapper } from "expo-paste-input"; +import { useImperativeHandle, useRef } from "react"; +import { TextInput, type TextInput as RNTextInput } from "react-native"; + +import { useThemeColor } from "../lib/useThemeColor"; +import { useNativePaste } from "../lib/useNativePaste"; +import type { ComposerEditorProps } from "./T3ComposerEditor.types"; + +export function ComposerEditor({ + ref, + skills: _skills, + selection, + onPasteImages, + style, + textStyle, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const inputRef = useRef(null); + const foregroundColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); + + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + setSelection: (nextSelection) => + inputRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + return ( + + props.onSelectionChange?.(event.nativeEvent.selection)} + multiline={props.multiline ?? true} + placeholderTextColor={placeholderColor} + style={[ + { + flex: 1, + minHeight: 0, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + fontSize: 15, + lineHeight: 22, + paddingVertical: contentInsetVertical, + }, + textStyle, + ]} + /> + + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts new file mode 100644 index 00000000000..d70d63fa437 --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -0,0 +1,38 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { Ref } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; + +export type ComposerEditorSelection = { + readonly start: number; + readonly end: number; +}; + +export interface ComposerEditorHandle { + focus: () => void; + blur: () => void; + setSelection: (selection: ComposerEditorSelection) => void; +} + +export interface ComposerEditorProps { + readonly ref?: Ref; + readonly value: string; + readonly skills?: ReadonlyArray< + Pick + >; + readonly selection?: ComposerEditorSelection; + readonly placeholder?: string; + readonly autoFocus?: boolean; + readonly editable?: boolean; + readonly scrollEnabled?: boolean; + readonly autoCorrect?: boolean; + readonly spellCheck?: boolean; + readonly multiline?: boolean; + readonly contentInsetVertical?: number; + readonly style?: StyleProp; + readonly textStyle?: StyleProp; + readonly onChangeText: (value: string) => void; + readonly onSelectionChange?: (selection: ComposerEditorSelection) => void; + readonly onPasteImages?: (uris: ReadonlyArray) => void; + readonly onFocus?: () => void; + readonly onBlur?: () => void; +} diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 5cbd6c442f5..56ada5f2a02 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -58,9 +58,9 @@ export function AgentActivity( : "now"; const activeLabel = `${props.activeCount} active`; const isLight = environment.colorScheme === "light"; - const primaryForeground = isLight ? "#0f172a" : "#ffffff"; - const secondaryForeground = isLight ? "#475569" : "#cbd5e1"; - const mutedForeground = isLight ? "#64748b" : "#94a3b8"; + const primaryForeground = isLight ? "#262626" : "#f5f5f5"; + const secondaryForeground = isLight ? "#525252" : "#a3a3a3"; + const mutedForeground = isLight ? "#737373" : "#8e8e93"; const tint = environment.isLuminanceReduced ? secondaryForeground : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" diff --git a/apps/web/package.json b/apps/web/package.json index 7704365b0ec..d6751c73486 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,8 +14,8 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", - "@clerk/clerk-js": "^6.13.0", - "@clerk/react": "^6.7.2", + "@clerk/clerk-js": "^6.16.0", + "@clerk/react": "^6.9.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 16846646652..8b9a53808f8 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -2,6 +2,10 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, } from "./lib/terminalContext"; +import { + collectComposerInlineTokens, + type ComposerInlineToken, +} from "@t3tools/shared/composerInlineTokens"; export type ComposerPromptSegment = | { @@ -22,12 +26,6 @@ export type ComposerPromptSegment = context: TerminalContextDraft | null; }; -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; -const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; -const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; -const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; -const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; - function rangeIncludesIndex(start: number, end: number, index: number): boolean { return start <= index && index < end; } @@ -42,84 +40,6 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void segments.push({ type: "text", text }); } -type InlineTokenMatch = - | { - type: "mention"; - value: string; - start: number; - end: number; - } - | { - type: "skill"; - value: string; - start: number; - end: number; - }; - -type MentionTokenMatch = Extract; - -function collectMentionTokenMatches(text: string): MentionTokenMatch[] { - const matches: MentionTokenMatch[] = []; - - for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); - const encodedPath = match[3] ?? ""; - let path = encodedPath; - try { - path = decodeURIComponent(encodedPath); - } catch { - // Keep the source value when malformed percent escapes are present. - } - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; - const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); - if (!path || hasExternalScheme || label !== basename) { - continue; - } - const matchIndex = match.index ?? 0; - const start = matchIndex + prefix.length; - const end = start + fullMatch.length - prefix.length; - matches.push({ type: "mention", value: path, start, end }); - } - - for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const quotedPath = match[2]; - const unquotedPath = match[3]; - const path = - quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (unquotedPath ?? ""); - const matchIndex = match.index ?? 0; - const start = matchIndex + prefix.length; - const end = start + fullMatch.length - prefix.length; - if (path.length > 0) { - matches.push({ type: "mention", value: path, start, end }); - } - } - - return matches; -} - -function collectInlineTokenMatches(text: string): InlineTokenMatch[] { - const matches: InlineTokenMatch[] = collectMentionTokenMatches(text); - - for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { - const fullMatch = match[0]; - const prefix = match[1] ?? ""; - const skillName = match[2] ?? ""; - const matchIndex = match.index ?? 0; - const start = matchIndex + prefix.length; - const end = start + fullMatch.length - prefix.length; - if (skillName.length > 0) { - matches.push({ type: "skill", value: skillName, start, end }); - } - } - - return matches.toSorted((left, right) => left.start - right.start); -} - function forEachPromptSegmentSlice( prompt: string, visitor: ( @@ -186,10 +106,16 @@ function forEachPromptTextSlice( function forEachMentionMatch( prompt: string, - visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void, + visitor: ( + match: Extract, + promptOffset: number, + ) => boolean | void, ): boolean { return forEachPromptTextSlice(prompt, (text, promptOffset) => { - for (const match of collectMentionTokenMatches(text)) { + for (const match of collectComposerInlineTokens(text)) { + if (match.type !== "mention") { + continue; + } if (visitor(match, promptOffset) === true) { return true; } @@ -204,7 +130,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen return segments; } - const tokenMatches = collectInlineTokenMatches(text); + const tokenMatches = collectComposerInlineTokens(text); let cursor = 0; for (const match of tokenMatches) { if (match.start < cursor) { @@ -219,7 +145,7 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen segments.push({ type: "mention", path: match.value, - source: text.slice(match.start, match.end), + source: match.source, }); } else { segments.push({ type: "skill", name: match.value }); diff --git a/infra/relay/package.json b/infra/relay/package.json index 4fa7686e76e..213c1fe5cc8 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -9,7 +9,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@clerk/backend": "3.4.14", + "@clerk/backend": "3.6.1", "@effect/sql-pg": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", diff --git a/packages/shared/package.json b/packages/shared/package.json index 97d51c89740..8daad0b9b8d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -143,6 +143,10 @@ "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" }, + "./composerInlineTokens": { + "types": "./src/composerInlineTokens.ts", + "import": "./src/composerInlineTokens.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts new file mode 100644 index 00000000000..f99d0b6654e --- /dev/null +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { collectComposerInlineTokens } from "./composerInlineTokens.ts"; + +describe("collectComposerInlineTokens", () => { + it("collects file links, mentions, and skills with source ranges", () => { + const text = "Use $ui and inspect [Chat.tsx](src/Chat.tsx) with @AGENTS.md please"; + + expect(collectComposerInlineTokens(text)).toEqual([ + { + type: "skill", + value: "ui", + source: "$ui", + start: 4, + end: 7, + }, + { + type: "mention", + value: "src/Chat.tsx", + source: "[Chat.tsx](src/Chat.tsx)", + start: 20, + end: 44, + }, + { + type: "mention", + value: "AGENTS.md", + source: "@AGENTS.md", + start: 50, + end: 60, + }, + ]); + }); + + it("does not convert incomplete trailing tokens", () => { + expect(collectComposerInlineTokens("Use $ui")).toEqual([]); + expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]); + }); + + it("keeps the delimiter after a token outside its source range", () => { + const text = "Inspect [package.json](package.json) next"; + + expect(collectComposerInlineTokens(text)).toEqual([ + { + type: "mention", + value: "package.json", + source: "[package.json](package.json)", + start: 8, + end: 36, + }, + ]); + expect(text.slice(36)).toBe(" next"); + }); + + it("preserves a confirmed pill when only its trailing delimiter is removed", () => { + const withDelimiter = "[package.json](package.json) "; + const confirmed = collectComposerInlineTokens(withDelimiter); + + expect( + collectComposerInlineTokens(withDelimiter.trimEnd(), { preserveTrailingFrom: confirmed }), + ).toEqual([ + { + type: "mention", + value: "package.json", + source: "[package.json](package.json)", + start: 0, + end: 28, + }, + ]); + }); + + it("does not preserve a pill after its source is edited", () => { + const confirmed = collectComposerInlineTokens("[package.json](package.json) "); + + expect( + collectComposerInlineTokens("[package.json](package-json)", { + preserveTrailingFrom: confirmed, + }), + ).toEqual([]); + }); + + it("ignores normal web links", () => { + expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]); + }); +}); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts new file mode 100644 index 00000000000..aa5e67d6fc8 --- /dev/null +++ b/packages/shared/src/composerInlineTokens.ts @@ -0,0 +1,118 @@ +export type ComposerInlineToken = + | { + readonly type: "mention"; + readonly value: string; + readonly source: string; + readonly start: number; + readonly end: number; + } + | { + readonly type: "skill"; + readonly value: string; + readonly source: string; + readonly start: number; + readonly end: number; + }; + +export interface CollectComposerInlineTokensOptions { + readonly preserveTrailingFrom?: ReadonlyArray; +} + +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; +const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; +const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; +const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; + +function collectMentionTokens(text: string): ComposerInlineToken[] { + const matches: ComposerInlineToken[] = []; + + for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); + const encodedPath = match[3] ?? ""; + let path = encodedPath; + try { + path = decodeURIComponent(encodedPath); + } catch { + // Preserve malformed source rather than dropping a user-authored token. + } + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); + if (!path || hasExternalScheme || label !== basename) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + fullMatch.length - prefix.length; + matches.push({ + type: "mention", + value: path, + source: text.slice(start, end), + start, + end, + }); + } + + for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const quotedPath = match[2]; + const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); + if (!path) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + fullMatch.length - prefix.length; + matches.push({ + type: "mention", + value: path, + source: text.slice(start, end), + start, + end, + }); + } + + return matches; +} + +export function collectComposerInlineTokens( + text: string, + options: CollectComposerInlineTokensOptions = {}, +): ReadonlyArray { + const matches = collectMentionTokens(text); + + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const value = match[2] ?? ""; + if (!value) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + fullMatch.length - prefix.length; + matches.push({ + type: "skill", + value, + source: text.slice(start, end), + start, + end, + }); + } + + for (const token of options.preserveTrailingFrom ?? []) { + if ( + token.end === text.length && + text.slice(token.start, token.end) === token.source && + !matches.some( + (match) => + match.type === token.type && match.start === token.start && match.end === token.end, + ) + ) { + matches.push(token); + } + } + + return [...matches].sort((left, right) => left.start - right.start); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fa241735ea..17d3f4d63e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,8 +179,8 @@ importers: specifier: ^0.7.1 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: ^3.3.0 - version: 3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ^3.4.1 + version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0) @@ -223,6 +223,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/mobile-markdown-text': + specifier: file:./modules/t3-markdown-text + version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -244,6 +247,9 @@ importers: expo: specifier: ^56.0.0 version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-asset: + specifier: ~56.0.15 + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-auth-session: specifier: ~56.0.12 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -368,6 +374,9 @@ importers: '@effect/vitest': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@pierre/trees': + specifier: 1.0.0-beta.4 + version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/react': specifier: ~19.2.0 version: 19.2.16 @@ -451,11 +460,11 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/clerk-js': - specifier: ^6.13.0 - version: 6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^6.16.0 + version: 6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: ^6.7.2 - version: 6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^6.9.0 + version: 6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -614,8 +623,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.4.14 - version: 3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.6.1 + version: 3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) @@ -1509,16 +1518,16 @@ packages: resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.4.14': - resolution: {integrity: sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==} + '@clerk/backend@3.6.1': + resolution: {integrity: sha512-LkfekzF/0UMXacX+17xy3ExRraO0mm+thXejC8Q32gWHd1wLdxK3YXDsLDF00E1r1InWBKIt2ZOxs6hTwZPJjA==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.14.0': - resolution: {integrity: sha512-xreDPw31OIk/VQj36qdgjzc4Rk2HwMar25nOu/ts2gf7PrbhU4XQdrtnt74g4fTmSMp8xeyjzHqa9adDXVjISw==} + '@clerk/clerk-js@6.16.0': + resolution: {integrity: sha512-8xv/XDsxhOZd1n4DNIRJ2EehIRUg6UiqKAnfd0L88R2t1g6sVnLi1FInJ5i8Qyx5oY/creXx6X1AZ1V5PobRkA==} engines: {node: '>=20.9.0'} - '@clerk/expo@3.3.1': - resolution: {integrity: sha512-c4g64z5sgJoGYjK0NeasNwOMy9Di7cEjICq56BHSowdOuB+6UGtWBNw+yHzgS1gxi2kJgl7WQCmmXRsoZNWxAg==} + '@clerk/expo@3.4.1': + resolution: {integrity: sha512-gpAXsuUnsUdUD0/2XjyxaC9quF5rT+2umkmV74nBLVAFurGMMMMHvnHqrQEtZ7tH5GHNXYw5+pgmnzd1HiMQbQ==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/expo-passkeys': '>=0.0.6' @@ -1550,16 +1559,18 @@ packages: optional: true expo-web-browser: optional: true + react-dom: + optional: true - '@clerk/react@6.7.3': - resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==} + '@clerk/react@6.9.0': + resolution: {integrity: sha512-M0QGyGS732tYBXeG+28UgElXM2TfoSZ+4mWGisC8yxJX8NjH4hEPJTAQuZmYRLNaCyGQCuzjYVQiQRC+GbDtmA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.15.0': - resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==} + '@clerk/shared@4.17.0': + resolution: {integrity: sha512-YeQ+6zDmqyor1mPHjZx18j+LssL6Pobvid8hb7HQMioSo8sGDBEVi/Z12bs+gUhe9KbdP+ygHsKOqqeGAPuPZA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -4168,6 +4179,17 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': + resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} + peerDependencies: + expo-asset: '*' + expo-clipboard: '*' + expo-haptics: '*' + expo-symbols: '*' + react: '*' + react-native: '*' + react-native-nitro-markdown: '*' + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} @@ -10829,18 +10851,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10855,9 +10877,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10872,15 +10894,14 @@ snapshots: - react - react-dom - '@clerk/expo@3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@clerk/clerk-js': 6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 @@ -10890,22 +10911,23 @@ snapshots: expo-crypto: 56.0.4(expo@56.0.8) expo-secure-store: 56.0.4(expo@56.0.8) expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-dom: 19.2.3(react@19.2.3) - '@clerk/react@6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -10915,7 +10937,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -12680,6 +12702,13 @@ snapshots: react-dom: 19.2.6(react@19.2.6) shiki: 3.23.0 + '@pierre/trees@1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + preact: 11.0.0-beta.0 + preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: preact: 11.0.0-beta.0 @@ -13452,6 +13481,16 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)': + dependencies: + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-haptics: 56.0.3(expo@56.0.8) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 01676087cbd..2fe67164666 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -26,6 +26,7 @@ const workspaceFiles = [ "apps/web/package.json", "apps/mobile/package.json", "apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz", + "apps/mobile/modules/t3-markdown-text/package.json", "apps/mobile/modules/t3-review-diff/package.json", "apps/mobile/modules/t3-terminal/package.json", "apps/marketing/package.json",