);
diff --git a/examples/nextjs-ai-chatbot/hooks/usetranscribe.ts b/examples/nextjs-ai-chatbot/hooks/usetranscribe.ts
index f918578..39cf438 100644
--- a/examples/nextjs-ai-chatbot/hooks/usetranscribe.ts
+++ b/examples/nextjs-ai-chatbot/hooks/usetranscribe.ts
@@ -112,7 +112,6 @@ export function useTranscriber(options: Options = {}) {
mr.onstop = handleStop;
mr.start(250);
}
-
} catch (e: any) {
setError(e?.message || 'Something went wrong');
console.error(e);
@@ -185,7 +184,6 @@ export function useTranscriber(options: Options = {}) {
});
streamRef.current = stream;
const ctx = new (window.AudioContext ||
-
(window as any).webkitAudioContext)();
audioCtxRef.current = ctx;
@@ -216,7 +214,6 @@ export function useTranscriber(options: Options = {}) {
recordingStartedAtRef.current = performance.now();
rafRef.current = requestAnimationFrame(checkSilence);
-
} catch (e: any) {
setError(e?.message || 'Mic access failed');
console.error(e);
diff --git a/examples/react-example/src/App.tsx b/examples/react-example/src/App.tsx
index 310a759..d0aac25 100644
--- a/examples/react-example/src/App.tsx
+++ b/examples/react-example/src/App.tsx
@@ -9,15 +9,15 @@ function App() {
return (
<>
+
@@ -25,7 +25,7 @@ function App() {
Edit src/App.tsx and save to test HMR
-
+
Click on the Vite and React logos to learn more
>
diff --git a/packages/react-native-sdk/android/build.gradle b/packages/react-native-sdk/android/build.gradle
new file mode 100644
index 0000000..0e25caa
--- /dev/null
+++ b/packages/react-native-sdk/android/build.gradle
@@ -0,0 +1,45 @@
+buildscript {
+ ext.kotlin_version = '1.9.24'
+ repositories { google(); mavenCentral() }
+ dependencies {
+ classpath "com.android.tools.build:gradle:8.3.2"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ }
+}
+
+def isNewArchitectureEnabled() {
+ return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true"
+}
+
+apply plugin: "com.android.library"
+apply plugin: "kotlin-android"
+
+if (isNewArchitectureEnabled()) {
+ apply plugin: "com.facebook.react"
+}
+
+android {
+ namespace "com.streamio.aicomponents.renderkit"
+ compileSdkVersion 34
+ defaultConfig { minSdk 21; targetSdk 34 }
+}
+
+repositories {
+ google()
+ mavenCentral()
+ // If your RN is < 0.73, also add the old RN maven dirs:
+ // maven { url("$rootDir/../node_modules/react-native/android") }
+ // maven { url("$rootDir/../node_modules/jsc-android/dist") }
+}
+
+dependencies {
+ // RN Android APIs for view managers, annotations, etc.
+ // Option A (simple): let Gradle pick whatever the app uses
+ compileOnly("com.facebook.react:react-android:+")
+ // Option B (pin to your app’s RN version, e.g. 0.78.x)
+ // compileOnly("com.facebook.react:react-android:0.78.0")
+
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ implementation "androidx.core:core-ktx:1.13.1"
+ implementation "androidx.appcompat:appcompat:1.7.0"
+}
diff --git a/packages/react-native-sdk/android/gradle.properties b/packages/react-native-sdk/android/gradle.properties
new file mode 100644
index 0000000..ddd9bc9
--- /dev/null
+++ b/packages/react-native-sdk/android/gradle.properties
@@ -0,0 +1,4 @@
+# android/gradle.properties
+android.useAndroidX=true
+android.enableJetifier=true
+org.gradle.jvmargs=-Xmx4096m
diff --git a/packages/react-native-sdk/android/gradle/wrapper/gradle-wrapper.jar b/packages/react-native-sdk/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..980502d
Binary files /dev/null and b/packages/react-native-sdk/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/packages/react-native-sdk/android/gradle/wrapper/gradle-wrapper.properties b/packages/react-native-sdk/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..128196a
--- /dev/null
+++ b/packages/react-native-sdk/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-milestone-1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/packages/react-native-sdk/android/gradlew b/packages/react-native-sdk/android/gradlew
new file mode 100755
index 0000000..faf9300
--- /dev/null
+++ b/packages/react-native-sdk/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/packages/react-native-sdk/android/gradlew.bat b/packages/react-native-sdk/android/gradlew.bat
new file mode 100644
index 0000000..9b42019
--- /dev/null
+++ b/packages/react-native-sdk/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/packages/react-native-sdk/android/src/main/AndroidManifest.xml b/packages/react-native-sdk/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..94e7548
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
diff --git a/packages/react-native-sdk/android/src/main/AndroidManifestNew.xml b/packages/react-native-sdk/android/src/main/AndroidManifestNew.xml
new file mode 100644
index 0000000..a2f47b6
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/AndroidManifestNew.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextView.kt b/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextView.kt
new file mode 100644
index 0000000..eb4ce1b
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextView.kt
@@ -0,0 +1,59 @@
+import android.content.Context
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.style.ForegroundColorSpan
+import android.graphics.Typeface
+import androidx.appcompat.widget.AppCompatTextView
+import com.facebook.react.common.assets.ReactFontManager
+
+class PerfTextView(context: Context) : AppCompatTextView(context) {
+
+ data class ColorRange(val start: Int, val end: Int, val color: Int)
+
+ private var colorRanges: List
= emptyList()
+
+ fun setTextValue(value: String) {
+ applyTextAndColors(value, colorRanges)
+ }
+
+ fun setColorRanges(ranges: List) {
+ colorRanges = ranges
+ applyTextAndColors(text?.toString().orEmpty(), colorRanges)
+ }
+
+ fun setFontFamilyCompat(fontFamily: String?) {
+ val style = typeface?.style ?: Typeface.NORMAL
+ val fontManager = ReactFontManager.getInstance()
+
+ val tf: Typeface = fontManager.getTypeface(
+ fontFamily ?: "monospace",
+ style,
+ context.assets,
+ )
+
+ typeface = tf
+ }
+
+ private fun applyTextAndColors(value: String, ranges: List) {
+ if (value.isEmpty()) {
+ setText(value)
+ return
+ }
+
+ val spannable = SpannableString(value)
+ for (r in ranges) {
+ val start = r.start.coerceAtLeast(0).coerceAtMost(value.length)
+ val end = r.end.coerceAtLeast(start).coerceAtMost(value.length)
+ if (start == end) continue
+ spannable.setSpan(
+ ForegroundColorSpan(r.color),
+ start,
+ end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
+ )
+ }
+ text = spannable
+ }
+}
+
+
diff --git a/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextViewManager.kt b/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextViewManager.kt
new file mode 100644
index 0000000..87192c9
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/PerfTextViewManager.kt
@@ -0,0 +1,148 @@
+package com.streamio.aicomponents.renderkit
+
+import PerfTextView
+import android.content.Context
+import android.view.View
+import com.facebook.react.bridge.ColorPropConverter
+import com.facebook.react.bridge.Dynamic
+import com.facebook.react.bridge.ReadableArray
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.ReadableType
+import com.facebook.react.module.annotations.ReactModule
+import com.facebook.react.uimanager.PixelUtil
+import com.facebook.react.uimanager.SimpleViewManager
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.annotations.ReactProp
+import com.facebook.yoga.YogaMeasureMode
+import com.facebook.yoga.YogaMeasureOutput
+import kotlin.math.ceil
+
+@ReactModule(name = "PerfText")
+class PerfTextViewManager : SimpleViewManager() {
+
+ override fun getName() = "PerfText"
+ override fun createViewInstance(rc: ThemedReactContext) = PerfTextView(rc)
+
+ @ReactProp(name = "text")
+ fun setText(v: PerfTextView, text: String?) {
+ v.setTextValue(text ?: "")
+ }
+
+ @ReactProp(name = "colorRanges")
+ fun setColorRanges(v: PerfTextView, arr: ReadableArray?) {
+ val out = mutableListOf()
+ if (arr != null) {
+ for (i in 0 until arr.size()) {
+ val m = arr.getMap(i)
+ val start = m!!.getInt("start")
+ val end = m.getInt("end")
+ val dyn: Dynamic = m.getDynamic("color")
+ val color = when (dyn.type) {
+ ReadableType.Number -> dyn.asInt()
+ ReadableType.String -> ColorPropConverter.getColor(dyn.asString(), v.context)
+ else -> 0xff000000.toInt()
+ } ?: 0xff000000.toInt()
+ out.add(PerfTextView.ColorRange(start, end, color))
+ }
+ }
+ v.setColorRanges(out)
+ }
+
+ @ReactProp(name = "fontSize")
+ fun setFontSize(v: PerfTextView, sizeSp: Double) {
+ if (sizeSp > 0) v.textSize = sizeSp.toFloat()
+ }
+
+
+ @ReactProp(name = "lineHeight")
+ fun setLineHeight(v: PerfTextView, height: Double) {
+ val targetPx = PixelUtil.toPixelFromDIP(height.toFloat())
+ if (height > 0) {
+ v.setLineSpacing(0f, targetPx / v.textSize)
+ } else {
+ // default to no multiplier rather than default line height
+ v.setLineSpacing(0.0f, 1.0f)
+ };
+ }
+
+
+
+ @ReactProp(name = "fontFamily")
+ fun setFontFamily(v: PerfTextView, family: String?) {
+ v.setFontFamilyCompat(family)
+ }
+
+ // A fabric measurement hook.
+ // Will only be called by FabricUIManager.measure(), via the C++ measurement manager.
+ override fun measure(
+ context: Context,
+ localData: ReadableMap?,
+ props: ReadableMap?,
+ state: ReadableMap?,
+ width: Float,
+ widthMode: YogaMeasureMode?,
+ height: Float,
+ heightMode: YogaMeasureMode?,
+ attachmentsPositions: FloatArray?
+ ): Long {
+ val view = PerfTextView(context)
+
+ // Apply props that influence size (text, fontSize, lineHeight)
+ props?.let { p ->
+ if (p.hasKey("fontSize") && !p.isNull("fontSize")) {
+ val fs = p.getDouble("fontSize")
+ if (fs > 0.0) {
+ view.textSize = fs.toFloat()
+ }
+ }
+
+ if (p.hasKey("fontFamily") && !p.isNull("fontFamily")) {
+ view.setFontFamilyCompat(p.getString("fontFamily"))
+ }
+
+ if (p.hasKey("lineHeight") && !p.isNull("lineHeight")) {
+ val lh = p.getDouble("lineHeight")
+ if (lh > 0.0) {
+ val targetPx = PixelUtil.toPixelFromDIP(lh.toFloat())
+ view.setLineSpacing(0.0f, targetPx / view.textSize)
+ } else {
+ view.setLineSpacing(0.0f, 1.0f)
+ }
+ }
+
+ if (p.hasKey("text") && !p.isNull("text")) {
+ view.setTextValue(p.getString("text") ?: "")
+ }
+ // colorRanges don’t affect size so we can ignore them for measurement
+ }
+ val widthPx = if (!width.isNaN()) PixelUtil.toPixelFromDIP(width) else 0f
+ val heightPx = if (!height.isNaN()) PixelUtil.toPixelFromDIP(height) else 0f
+
+ val widthSpec = when (widthMode) {
+ YogaMeasureMode.EXACTLY ->
+ View.MeasureSpec.makeMeasureSpec(widthPx.toInt(), View.MeasureSpec.EXACTLY)
+ YogaMeasureMode.AT_MOST ->
+ View.MeasureSpec.makeMeasureSpec(widthPx.toInt(), View.MeasureSpec.AT_MOST)
+ else ->
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ }
+
+ val heightSpec = when (heightMode) {
+ YogaMeasureMode.EXACTLY ->
+ View.MeasureSpec.makeMeasureSpec(heightPx.toInt(), View.MeasureSpec.EXACTLY)
+ YogaMeasureMode.AT_MOST ->
+ View.MeasureSpec.makeMeasureSpec(heightPx.toInt(), View.MeasureSpec.AT_MOST)
+ else ->
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ }
+
+ view.measure(widthSpec, heightSpec)
+
+ val measuredWidthDp = ceil(PixelUtil.toDIPFromPixel(view.measuredWidth.toFloat()).toDouble()).toInt()
+ val measuredHeightDp = ceil(PixelUtil.toDIPFromPixel(view.measuredHeight.toFloat()).toDouble()).toInt()
+
+ return YogaMeasureOutput.make(measuredWidthDp * 1.5f, measuredHeightDp * 1.0f)
+
+ }
+}
+
diff --git a/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/RenderKitPackage.kt b/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/RenderKitPackage.kt
new file mode 100644
index 0000000..0aa805a
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/java/com/streamio/aicomponents/renderkit/RenderKitPackage.kt
@@ -0,0 +1,12 @@
+package com.streamio.aicomponents.renderkit
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+class RenderKitPackage : ReactPackage {
+ override fun createNativeModules(rc: ReactApplicationContext): List = emptyList()
+ override fun createViewManagers(rc: ReactApplicationContext): List> =
+ listOf(PerfTextViewManager())
+}
diff --git a/packages/react-native-sdk/android/src/main/jni/AiComponentsReactNative.cpp b/packages/react-native-sdk/android/src/main/jni/AiComponentsReactNative.cpp
new file mode 100644
index 0000000..541dc22
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/jni/AiComponentsReactNative.cpp
@@ -0,0 +1,22 @@
+
+/**
+ * This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen).
+ *
+ * Do not edit this file as changes may cause incorrect behavior and will be lost
+ * once the code is regenerated.
+ *
+ * @generated by codegen project: GenerateModuleJniCpp.js
+ */
+
+#include "AiComponentsReactNative.h"
+
+namespace facebook::react {
+
+
+
+std::shared_ptr AiComponentsReactNative_ModuleProvider(const std::string &moduleName, const JavaTurboModule::InitParams ¶ms) {
+
+ return nullptr;
+}
+
+} // namespace facebook::react
diff --git a/packages/react-native-sdk/android/src/main/jni/AiComponentsReactNative.h b/packages/react-native-sdk/android/src/main/jni/AiComponentsReactNative.h
new file mode 100644
index 0000000..d5c2197
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/jni/AiComponentsReactNative.h
@@ -0,0 +1,26 @@
+
+/**
+ * This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen).
+ *
+ * Do not edit this file as changes may cause incorrect behavior and will be lost
+ * once the code is regenerated.
+ *
+ * @generated by codegen project: GenerateModuleJniH.js
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+#include "PerfTextComponentDescriptor.h"
+
+namespace facebook::react {
+
+
+
+JSI_EXPORT
+std::shared_ptr AiComponentsReactNative_ModuleProvider(const std::string &moduleName, const JavaTurboModule::InitParams ¶ms);
+
+} // namespace facebook::react
diff --git a/packages/react-native-sdk/android/src/main/jni/CMakeLists.txt b/packages/react-native-sdk/android/src/main/jni/CMakeLists.txt
new file mode 100644
index 0000000..e547eef
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/jni/CMakeLists.txt
@@ -0,0 +1,54 @@
+cmake_minimum_required(VERSION 3.13)
+set(CMAKE_VERBOSE_MAKEFILE on)
+
+set(LIB_LITERAL AiComponentsReactNative)
+set(LIB_TARGET_NAME react_codegen_${LIB_LITERAL})
+
+set(LIB_ANDROID_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
+set(LIB_COMPONENTS_DIR ${LIB_ANDROID_DIR}/src/main/jni)
+set(LIB_ANDROID_GENERATED_JNI_DIR ${LIB_ANDROID_DIR}/build/generated/source/codegen/jni)
+set(LIB_ANDROID_GENERATED_COMPONENTS_DIR ${LIB_ANDROID_GENERATED_JNI_DIR}/react/renderer/components/${LIB_LITERAL})
+
+file(GLOB LIB_COMPONENT_SRCS CONFIGURE_DEPENDS *.cpp ${LIB_COMPONENTS_DIR}/*.cpp)
+file(GLOB LIB_CODEGEN_SRCS CONFIGURE_DEPENDS ${LIB_ANDROID_GENERATED_COMPONENTS_DIR}/*.cpp)
+
+add_library(
+ ${LIB_TARGET_NAME}
+ OBJECT
+ ${LIB_COMPONENT_SRCS}
+ ${LIB_CODEGEN_SRCS}
+)
+
+target_include_directories(
+ ${LIB_TARGET_NAME}
+ PUBLIC
+ .
+ ${LIB_COMPONENTS_DIR}
+ ${LIB_ANDROID_GENERATED_JNI_DIR}
+ ${LIB_ANDROID_GENERATED_COMPONENTS_DIR}
+)
+
+target_link_libraries(
+ react_codegen_AiComponentsReactNative
+ fbjni
+ jsi
+ # We need to link different libraries based on whether we are building rncore or not, that's necessary
+ # because we want to break a circular dependency between react_codegen_rncore and reactnative
+ reactnative
+)
+
+target_compile_options(
+ react_codegen_AiComponentsReactNative
+ PRIVATE
+ -DLOG_TAG=\"ReactNative\"
+ -fexceptions
+ -frtti
+ -std=c++20
+ -Wall
+)
+
+target_include_directories(
+ ${CMAKE_PROJECT_NAME}
+ PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
diff --git a/packages/react-native-sdk/android/src/main/jni/PerfTextComponentDescriptor.h b/packages/react-native-sdk/android/src/main/jni/PerfTextComponentDescriptor.h
new file mode 100644
index 0000000..7e26055
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/jni/PerfTextComponentDescriptor.h
@@ -0,0 +1,29 @@
+#pragma once
+
+#include "PerfTextShadowNode.h"
+#include "PerfTextMeasurementManager.h"
+
+#include
+
+namespace facebook::react {
+
+class PerfTextComponentDescriptor final
+ : public ConcreteComponentDescriptor {
+ public:
+ using ConcreteComponentDescriptor::ConcreteComponentDescriptor;
+ PerfTextComponentDescriptor(
+ const ComponentDescriptorParameters ¶meters)
+ : ConcreteComponentDescriptor(parameters),
+ measurementManager_(std::make_shared(contextContainer_)) {}
+
+ void adopt(ShadowNode &shadowNode) const override {
+ ConcreteComponentDescriptor::adopt(shadowNode);
+ auto &node = static_cast(shadowNode);
+ node.setMeasurementManager(measurementManager_);
+ }
+
+ private:
+ const std::shared_ptr measurementManager_;
+};
+
+} // namespace facebook::react
diff --git a/packages/react-native-sdk/android/src/main/jni/PerfTextMeasurementManager.cpp b/packages/react-native-sdk/android/src/main/jni/PerfTextMeasurementManager.cpp
new file mode 100644
index 0000000..d0a5fb5
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/jni/PerfTextMeasurementManager.cpp
@@ -0,0 +1,70 @@
+#include "PerfTextMeasurementManager.h"
+
+#include
+#include
+#include
+#include
+
+using namespace facebook::jni;
+
+namespace facebook::react {
+
+Size PerfTextMeasurementManager::measure(
+ SurfaceId surfaceId,
+ LayoutConstraints layoutConstraints,
+ const PerfTextProps &props) const {
+
+ // This comes from ContextContainer set up by RN
+ const auto &fabricUIManager =
+ contextContainer_->at>("FabricUIManager");
+
+ static const auto measureMethod =
+ facebook::jni::findClassStatic("com/facebook/react/fabric/FabricUIManager")
+ ->getMethod("measure");
+
+ auto minimumSize = layoutConstraints.minimumSize;
+ auto maximumSize = layoutConstraints.maximumSize;
+
+ local_ref componentName = make_jstring("PerfText");
+
+ // Serialize relevant props. For now we'll handpick the ones we want
+ // to do this for and may extend later in the future.
+ folly::dynamic serializedProps = folly::dynamic::object();
+ serializedProps["text"] = props.text;
+ serializedProps["fontSize"] = props.fontSize;
+ serializedProps["lineHeight"] = props.lineHeight;
+
+ auto propsRNM = ReadableNativeMap::newObjectCxxArgs(serializedProps);
+ local_ref propsRM =
+ make_local(reinterpret_cast(propsRNM.get()));
+ // -----------------------------------------------
+
+ const auto measureResult = measureMethod(
+ fabricUIManager,
+ static_cast(surfaceId),
+ componentName.get(),
+ nullptr, // localData
+ propsRM.get(), // props
+ nullptr, // state
+ minimumSize.width,
+ maximumSize.width,
+ minimumSize.height,
+ maximumSize.height
+ );
+
+ // Use RN's yogaMeassureToSize(int64_t) from conversions.h
+ return yogaMeassureToSize(static_cast(measureResult));
+}
+
+} // namespace facebook::react
+
+
diff --git a/packages/react-native-sdk/android/src/main/jni/PerfTextMeasurementManager.h b/packages/react-native-sdk/android/src/main/jni/PerfTextMeasurementManager.h
new file mode 100644
index 0000000..5953811
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/jni/PerfTextMeasurementManager.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace facebook::react {
+
+class PerfTextMeasurementManager {
+ public:
+ PerfTextMeasurementManager(
+ const std::shared_ptr &contextContainer)
+ : contextContainer_(contextContainer) {}
+
+ // Props type name comes from codegen, hence why we don't override Props.h
+ Size measure(
+ SurfaceId surfaceId,
+ LayoutConstraints layoutConstraints,
+ const PerfTextProps &props) const;
+
+ private:
+ const std::shared_ptr contextContainer_;
+};
+
+} // namespace facebook::react
diff --git a/packages/react-native-sdk/android/src/main/jni/PerfTextShadowNode.cpp b/packages/react-native-sdk/android/src/main/jni/PerfTextShadowNode.cpp
new file mode 100644
index 0000000..f76fc23
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/jni/PerfTextShadowNode.cpp
@@ -0,0 +1,20 @@
+#include "PerfTextShadowNode.h"
+
+namespace facebook::react {
+
+extern const char PerfTextComponentName[] = "PerfText";
+
+void PerfTextShadowNode::setMeasurementManager(
+ const std::shared_ptr &mm) {
+ ensureUnsealed();
+ measurementManager_ = mm;
+}
+
+Size PerfTextShadowNode::measureContent(
+ const LayoutContext &layoutContext,
+ const LayoutConstraints &layoutConstraints) const {
+ // Ask measurement manager, drilling props so it can size based on text, fontSize, etc.
+ return measurementManager_->measure(getSurfaceId(), layoutConstraints, getConcreteProps());
+}
+
+} // namespace facebook::react
diff --git a/packages/react-native-sdk/android/src/main/jni/PerfTextShadowNode.h b/packages/react-native-sdk/android/src/main/jni/PerfTextShadowNode.h
new file mode 100644
index 0000000..fd005ba
--- /dev/null
+++ b/packages/react-native-sdk/android/src/main/jni/PerfTextShadowNode.h
@@ -0,0 +1,38 @@
+#pragma once
+
+#include "PerfTextMeasurementManager.h"
+
+#include
+#include
+#include
+
+namespace facebook::react {
+
+extern const char PerfTextComponentName[];
+
+class PerfTextShadowNode final : public ConcreteViewShadowNode<
+ PerfTextComponentName,
+ PerfTextProps,
+ PerfTextEventEmitter> {
+ public:
+ using ConcreteViewShadowNode::ConcreteViewShadowNode;
+
+ static ShadowNodeTraits BaseTraits() {
+ auto traits = ConcreteViewShadowNode::BaseTraits();
+ traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
+ traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode);
+ return traits;
+ }
+
+ void setMeasurementManager(
+ const std::shared_ptr &mm);
+
+ Size measureContent(
+ const LayoutContext &layoutContext,
+ const LayoutConstraints &layoutConstraints) const override;
+
+ private:
+ std::shared_ptr measurementManager_;
+};
+
+} // namespace facebook::react
diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json
index fbd98bd..8ad3acd 100644
--- a/packages/react-native-sdk/package.json
+++ b/packages/react-native-sdk/package.json
@@ -16,12 +16,25 @@
}
},
"files": [
- "dist",
"src",
- "!src/*.test.ts"
+ "dist",
+ "android",
+ "ios",
+ "cpp",
+ "*.podspec",
+ "!ios/build",
+ "!android/build",
+ "!android/gradle",
+ "!android/gradlew",
+ "!android/gradlew.bat",
+ "!android/local.properties",
+ "!**/__tests__",
+ "!**/__fixtures__",
+ "!**/__mocks__",
+ "!**/.*"
],
"scripts": {
- "build": "tsc --project tsconfig.json && pnpm run clean && bob build",
+ "build": "pnpm run clean && bob build",
"start": "tsc --project tsconfig.json --watch",
"eslint": "eslint 'src/**/*.{js,ts,tsx}' --max-warnings 0",
"lint": "prettier --list-different 'src/**/*.{js,ts,tsx,json}' && yarn run eslint",
@@ -45,13 +58,15 @@
"@khanacademy/simple-markdown": "^2.1.0",
"linkifyjs": "^4.3.2",
"lodash": "4.17.21",
- "react-native-syntax-highlighter": "^2.1.0",
- "react-syntax-highlighter": "15.5.0"
+ "react-syntax-highlighter": "15.5.0",
+ "zod": "^4.1.12"
},
"devDependencies": {
+ "@shopify/react-native-skia": "^2.3.10",
"@types/lodash": "4.17.20",
"@types/node": "^24",
"@types/react": "19.2.2",
+ "@types/react-syntax-highlighter": "^15.5.13",
"concurrently": "catalog:",
"react": "19.2.0",
"react-native": "^0.82.1",
@@ -60,6 +75,7 @@
"react-native-reanimated": "^4.1.3",
"rimraf": "^6.0.1",
"typescript": "catalog:",
+ "victory-native": "^41.20.2",
"vitest": "catalog:"
},
"peerDependencies": {
@@ -68,7 +84,8 @@
"react-native": ">=0.73.0",
"react-native-gesture-handler": ">=2.18.0",
"react-native-reanimated": ">=3.16.0",
- "react-native-svg": ">=15.8.0"
+ "react-native-svg": ">=15.8.0",
+ "react-syntax-highlighter": ">=15.0.0"
},
"peerDependenciesMeta": {
"expo": {
@@ -84,5 +101,13 @@
"typescript"
]
},
+ "codegenConfig": {
+ "name": "AiComponentsReactNative",
+ "type": "components",
+ "jsSrcsDir": "src/native-specs",
+ "android": {
+ "javaPackageName": "com.streamio.aicomponents.renderkit"
+ }
+ },
"packageManager": "pnpm@10.13.1"
}
diff --git a/packages/react-native-sdk/react-native.config.js b/packages/react-native-sdk/react-native.config.js
new file mode 100644
index 0000000..748c036
--- /dev/null
+++ b/packages/react-native-sdk/react-native.config.js
@@ -0,0 +1,11 @@
+module.exports = {
+ dependency: {
+ platforms: {
+ android: {
+ componentDescriptors: ['PerfTextComponentDescriptor'],
+ cmakeListsPath: '../android/src/main/jni/CMakeLists.txt',
+ },
+ ios: null,
+ },
+ },
+};
diff --git a/packages/react-native-sdk/src/MarkdownRichText.tsx b/packages/react-native-sdk/src/MarkdownRichText.tsx
index 1e700fa..ab5e649 100644
--- a/packages/react-native-sdk/src/MarkdownRichText.tsx
+++ b/packages/react-native-sdk/src/MarkdownRichText.tsx
@@ -1,10 +1,43 @@
-import { useMemo } from 'react';
+import React, { useMemo } from 'react';
import { generateMarkdownText } from './markdown';
import { Markdown } from './markdown';
import type { MarkdownRules, MarkdownStyle } from './markdown';
import { Linking } from 'react-native';
-import { useStableCallback } from './internal/hooks/useStableCallback.ts';
+import { useStableCallback } from './internal/hooks/useStableCallback';
+
+// export const useStreamingMessage = ({
+// letterInterval = 0,
+// renderingLetterCount = 2,
+// text,
+// }: UseStreamingMessageProps): { streamedMessageText: string } => {
+// const [streamedMessageText, setStreamedMessageText] = useState(text);
+// const textCursor = useRef(text.length);
+//
+// useEffect(() => {
+// const textLength = text.length;
+// const interval = setInterval(() => {
+// if (!text || textCursor.current >= textLength) {
+// clearInterval(interval);
+// }
+// const newCursorValue = textCursor.current + renderingLetterCount;
+// const newText = text.substring(0, newCursorValue);
+// textCursor.current += newText.length - textCursor.current;
+// const codeBlockCounts = (newText.match(/```/g) || []).length;
+// const shouldOptimisticallyCloseCodeBlock =
+// codeBlockCounts > 0 && codeBlockCounts % 2 > 0;
+// setStreamedMessageText(
+// shouldOptimisticallyCloseCodeBlock ? newText + '```' : newText,
+// );
+// }, letterInterval);
+//
+// return () => {
+// clearInterval(interval);
+// };
+// }, [letterInterval, renderingLetterCount, text]);
+//
+// return { streamedMessageText };
+// };
export const MarkdownRichText = ({
text,
@@ -21,6 +54,8 @@ export const MarkdownRichText = ({
}) => {
const markdownText = useMemo(() => generateMarkdownText(text), [text]);
+ // const { streamedMessageText } = useStreamingMessage({ text: markdownText });
+
const onLink = useStableCallback((url: string) =>
onLinkParam
? onLinkParam(url)
diff --git a/packages/react-native-sdk/src/charts/Chart.tsx b/packages/react-native-sdk/src/charts/Chart.tsx
new file mode 100644
index 0000000..0d93d0c
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/Chart.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import type { ChartSpec } from './types';
+
+import { VictoryChart } from './components';
+
+export type ChartFromBlockProps = {
+ spec: ChartSpec;
+ height?: number;
+ width?: number;
+};
+
+const Chart = ({ spec, height = 260, width = 225 }: ChartFromBlockProps) => {
+ if (!spec) {
+ return null;
+ }
+
+ return ;
+};
+
+export default Chart;
diff --git a/packages/react-native-sdk/src/charts/components/PieLegend.tsx b/packages/react-native-sdk/src/charts/components/PieLegend.tsx
new file mode 100644
index 0000000..86e19f0
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/components/PieLegend.tsx
@@ -0,0 +1,55 @@
+import type { Datum } from '../types';
+import { StyleSheet, Text, View } from 'react-native';
+import React from 'react';
+
+export const PieLegend = ({
+ items,
+ maxRows = 3,
+}: {
+ items: Datum[];
+ maxRows?: number;
+}) => {
+ return (
+
+ {items.slice(0, maxRows * 4).map((item, i) => (
+
+
+
+ {item.dimension}
+
+
+ ))}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ marginTop: 8,
+ flexDirection: 'row',
+ justifyContent: 'center',
+ flexWrap: 'wrap',
+ gap: 10,
+ },
+ item: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ maxWidth: '48%', // lets two items sit per row, tweak as needed
+ },
+ swatch: {
+ borderRadius: 2,
+ width: 12,
+ height: 12,
+ },
+ label: {
+ fontSize: 13,
+ },
+});
diff --git a/packages/react-native-sdk/src/charts/components/VictoryChart.tsx b/packages/react-native-sdk/src/charts/components/VictoryChart.tsx
new file mode 100644
index 0000000..20507fc
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/components/VictoryChart.tsx
@@ -0,0 +1,205 @@
+import React from 'react';
+import { Platform, View } from 'react-native';
+import type { ChartSpec } from '../types';
+import {
+ Area,
+ Bar,
+ CartesianChart,
+ Line,
+ Pie,
+ PolarChart,
+ Scatter,
+ // useChartTransformState,
+} from 'victory-native';
+import { LinearGradient, matchFont, vec } from '@shopify/react-native-skia';
+import { PieLegend } from './PieLegend';
+
+const font = matchFont({
+ fontFamily: Platform.select({
+ ios: 'Helvetica',
+ android: 'sans-serif',
+ default: 'serif',
+ }),
+ fontSize: 12,
+ fontWeight: 'normal',
+ fontStyle: 'normal',
+});
+
+export type VictoryChartProps = {
+ spec: ChartSpec;
+ width: number;
+ height: number;
+};
+
+const DEFAULT_HEIGHT = 260;
+const DEFAULT_WIDTH = 225;
+
+export const VictoryChart = (props: VictoryChartProps) => {
+ const { spec, height = DEFAULT_HEIGHT, width = DEFAULT_WIDTH } = props;
+
+ // const { state: transformState } = useChartTransformState({
+ // scaleX: 1.5,
+ // scaleY: 1.0,
+ // });
+
+ if (spec.type === 'pie') {
+ if (!spec?.data.length) return null;
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+
+ // Group by color into series
+ const seriesNames = Array.from(
+ new Set(spec.data.map((d) => d.color ?? 'series')),
+ );
+ const yKeys = seriesNames.map((_name, idx) => `y${idx}` as const);
+
+ // Normalize data into one row per x, with columns y0,y1,... for each series
+ const rowsMap = new Map>();
+ for (const s of seriesNames) {
+ const keyIndex = seriesNames.indexOf(s);
+ const yKey = yKeys[keyIndex];
+ if (yKey) {
+ for (const pt of spec.data.filter((d) => (d.color ?? 'series') === s)) {
+ const xKey = spec.isTemporalDim
+ ? new Date(pt.dimension).getTime()
+ : pt.dimension;
+ const row = rowsMap.get(xKey) ?? { x: pt.dimension };
+ row[yKey] = pt.value;
+ rowsMap.set(xKey, row);
+ }
+ }
+ }
+
+ const table = Array.from(rowsMap.values());
+
+ const visibleBars = table.length;
+
+ const estimatedBarWidth = (width - 30 - visibleBars * 2) / visibleBars;
+
+ const maxTickCharCount = spec.isTemporalDim
+ ? 3
+ : Math.max(
+ ...table.map((entry) =>
+ typeof entry.x === 'string' ? entry.x.length : String(entry.x).length,
+ ),
+ );
+
+ const hasLongLabels =
+ (visibleBars > 4 ? maxTickCharCount > 1 : maxTickCharCount > 5) &&
+ !spec.isNumericDim;
+
+ return (
+
+ {
+ const parsedLabel =
+ (typeof label === 'number' ? String(label) : label) ?? '';
+ if (hasLongLabels && parsedLabel.length > 4) {
+ return `${parsedLabel.slice(0, 3)}...`;
+ }
+ return parsedLabel;
+ },
+ labelRotate: hasLongLabels ? 90 : 0,
+ tickCount: visibleBars,
+ labelOffset: hasLongLabels ? 0 : 2,
+ }}
+ yAxis={[{ font }]}
+ padding={{ bottom: hasLongLabels ? 2 * maxTickCharCount + 12 : 0 }}
+ // transformState={transformState}
+ // transformConfig={{
+ // pinch: { enabled: false },
+ // pan: { enabled: true, dimensions: 'x', activateAfterLongPress: 10 },
+ // }}
+ >
+ {({ points, chartBounds }) => (
+ <>
+ {/* One primitive per series */}
+ {yKeys.map((yk) => {
+ const pts = (points as Record)[yk]!;
+ switch (spec.type) {
+ case 'bar':
+ return (
+
+
+
+ );
+ case 'area':
+ return (
+
+
+
+ );
+ case 'point':
+ return (
+
+ );
+ case 'line':
+ default:
+ return (
+
+ );
+ }
+ })}
+ >
+ )}
+
+
+ );
+};
diff --git a/packages/react-native-sdk/src/charts/components/index.ts b/packages/react-native-sdk/src/charts/components/index.ts
new file mode 100644
index 0000000..64b1810
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/components/index.ts
@@ -0,0 +1,2 @@
+export * from './PieLegend';
+export * from './VictoryChart';
diff --git a/packages/react-native-sdk/src/charts/index.ts b/packages/react-native-sdk/src/charts/index.ts
new file mode 100644
index 0000000..2eb7939
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/index.ts
@@ -0,0 +1,13 @@
+// components
+export * from './Chart';
+export * from './components';
+
+// schemas and parsers
+export * from './vega-lite';
+export * from './mermaid';
+
+// utils
+export * from './utils';
+
+// types
+export * from './types';
diff --git a/packages/react-native-sdk/src/charts/mermaid/index.ts b/packages/react-native-sdk/src/charts/mermaid/index.ts
new file mode 100644
index 0000000..75aa8b9
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/mermaid/index.ts
@@ -0,0 +1 @@
+export * from './parser';
diff --git a/packages/react-native-sdk/src/charts/mermaid/parser.ts b/packages/react-native-sdk/src/charts/mermaid/parser.ts
new file mode 100644
index 0000000..f36893e
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/mermaid/parser.ts
@@ -0,0 +1,33 @@
+import type { ChartSpec, Datum } from '../types';
+import { colorFromLabel } from '../utils';
+
+export const parseMermaidPie = (lines: string[]): ChartSpec => {
+ let title: string | undefined;
+ const data: Datum[] = [];
+ const usedHues: number[] = [];
+ for (const ln of lines.slice(1)) {
+ const t = ln.match(/^title\s+(.+)$/i);
+ // TODO: Check if this is correct ? We only have one group.
+ if (t && t[1]) {
+ title = t[1]?.trim();
+ continue;
+ }
+ const d = ln.match(/^(?:"([^"]+)"|([^"]+))\s*:\s*([+-]?\d+(?:\.\d+)?)/);
+ if (d) {
+ const label = (d[1] ?? d[2] ?? '').trim();
+ data.push({
+ dimension: label,
+ value: Number(d[3]),
+ color: colorFromLabel(label, usedHues),
+ });
+ }
+ }
+ return { type: 'pie', title, data };
+};
+
+export const parseMermaid = (code: string) => {
+ const lines = code.split(/\r?\n/).map((l) => l.trim());
+ if (/^pie\b/i.test(lines[0] || '')) return parseMermaidPie(lines);
+
+ throw new Error('Unknown mermaid diagram type');
+};
diff --git a/packages/react-native-sdk/src/charts/types.ts b/packages/react-native-sdk/src/charts/types.ts
new file mode 100644
index 0000000..889599a
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/types.ts
@@ -0,0 +1,25 @@
+export type Dim = string;
+
+export type Datum = {
+ /** The categorical/temporal dimension (label, category, or time). */
+ dimension: Dim;
+ /** The numeric measure/value. */
+ value: number;
+ /** Color for this datum; the renderer will fall back to series color. */
+ color: string;
+ /** Optional series key for multi-series charts (e.g., region). */
+ series?: string;
+};
+
+export type ChartType = 'bar' | 'line' | 'area' | 'point' | 'pie';
+
+export type ChartSpec = {
+ type: ChartType;
+ data: Datum[];
+ seriesColors?: Record;
+ title?: string;
+ orientation?: 'vertical' | 'horizontal';
+ isTemporalDim?: boolean;
+ isNumericDim?: boolean;
+ dimDomain?: Dim[];
+};
diff --git a/packages/react-native-sdk/src/charts/utils/generate-pie-colors.ts b/packages/react-native-sdk/src/charts/utils/generate-pie-colors.ts
new file mode 100644
index 0000000..10db530
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/utils/generate-pie-colors.ts
@@ -0,0 +1,81 @@
+// hashing and rng
+export const hashLabel = (raw: string) => {
+ const s = raw.trim().toLowerCase().normalize('NFKC');
+ let h = 2166136261 >>> 0; // FNV-1a
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619) >>> 0;
+ }
+ return h >>> 0;
+};
+
+export const xorshift32 = (seed: number) => {
+ let x = seed || 123456789;
+ return () => {
+ x ^= x << 13;
+ x ^= x >>> 17;
+ x ^= x << 5;
+ return (x >>> 0) / 0xffffffff;
+ };
+};
+
+// color utils
+export const hslToHex = (h: number, s = 68, l = 52) => {
+ const S = s / 100,
+ L = l / 100;
+ const C = (1 - Math.abs(2 * L - 1)) * S;
+ const X = C * (1 - Math.abs(((h / 60) % 2) - 1));
+ const m = L - C / 2;
+ const [r1, g1, b1] =
+ h < 60
+ ? [C, X, 0]
+ : h < 120
+ ? [X, C, 0]
+ : h < 180
+ ? [0, C, X]
+ : h < 240
+ ? [0, X, C]
+ : h < 300
+ ? [X, 0, C]
+ : [C, 0, X];
+ const r = Math.round((r1 + m) * 255)
+ .toString(16)
+ .padStart(2, '0');
+ const g = Math.round((g1 + m) * 255)
+ .toString(16)
+ .padStart(2, '0');
+ const b = Math.round((b1 + m) * 255)
+ .toString(16)
+ .padStart(2, '0');
+ return `#${r}${g}${b}`;
+};
+
+// ensure hues not closer than MIN_SEP degrees within the same chart
+export const colorFromLabel = (
+ label: string,
+ usedHues: number[],
+ opts?: { minSep?: number; sat?: number; light?: number },
+) => {
+ const { minSep = 25, sat = 68, light = 52 } = opts || {};
+ const seed = hashLabel(label);
+ const rand = xorshift32(seed);
+
+ // base hue from RNG (looks random, but stable)
+ let hue = Math.floor(rand() * 360);
+
+ // nudge away from already used hues in THIS chart
+ let attempts = 0;
+ const tooClose = (h: number) =>
+ usedHues.some((u) => {
+ const d = Math.abs(h - u);
+ return Math.min(d, 360 - d) < minSep;
+ });
+
+ while (tooClose(hue) && attempts < 48) {
+ hue = (hue + 137.508) % 360; // golden-angle hop
+ attempts++;
+ }
+
+ usedHues.push(hue);
+ return hslToHex(hue, sat, light);
+};
diff --git a/packages/react-native-sdk/src/charts/utils/index.ts b/packages/react-native-sdk/src/charts/utils/index.ts
new file mode 100644
index 0000000..47bd877
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/utils/index.ts
@@ -0,0 +1,2 @@
+export * from './generate-pie-colors';
+export * from './parse-json-chart';
diff --git a/packages/react-native-sdk/src/charts/utils/parse-json-chart.ts b/packages/react-native-sdk/src/charts/utils/parse-json-chart.ts
new file mode 100644
index 0000000..e777883
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/utils/parse-json-chart.ts
@@ -0,0 +1,15 @@
+import { parseVegalite, VegaLiteSchema } from '../vega-lite';
+import type { ChartSpec } from '../types';
+
+export const parseJsonChart = (code: string): ChartSpec => {
+ const json = JSON.parse(code);
+
+ try {
+ const parsedVegaLite = VegaLiteSchema.parse(json);
+ return parseVegalite(parsedVegaLite);
+ } catch (_error) {
+ /* do nothing */
+ }
+
+ throw new Error('Unknown type of JSON formatted chart.');
+};
diff --git a/packages/react-native-sdk/src/charts/vega-lite/index.ts b/packages/react-native-sdk/src/charts/vega-lite/index.ts
new file mode 100644
index 0000000..2747256
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/vega-lite/index.ts
@@ -0,0 +1,2 @@
+export * from './parser';
+export * from './schema';
diff --git a/packages/react-native-sdk/src/charts/vega-lite/parser.ts b/packages/react-native-sdk/src/charts/vega-lite/parser.ts
new file mode 100644
index 0000000..ad97da9
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/vega-lite/parser.ts
@@ -0,0 +1,51 @@
+import type { VegaLite } from './schema';
+import type { ChartSpec, Datum } from '../types';
+import { colorFromLabel } from '../utils';
+
+export const parseVegalite = (spec: VegaLite): ChartSpec => {
+ if (spec.type === 'pie') {
+ const tField = spec.encoding?.theta?.field;
+ const cField = spec.encoding?.color?.field;
+ const usedHues: number[] = [];
+ const data = (spec.data?.values ?? [])
+ .map((d) => {
+ const y = Number(d[tField]);
+ const x = cField ? String(d[cField]) : '';
+ return Number.isFinite(y)
+ ? {
+ dimension: x.trim(),
+ value: y,
+ color: colorFromLabel(x, usedHues),
+ }
+ : null;
+ })
+ .filter(Boolean) as Datum[];
+ return { type: 'pie', data };
+ }
+
+ const { x, y, color } = spec.encoding;
+ const xField = x?.field,
+ yField = y?.field;
+ const xIsTime = (x?.type ?? '').toLowerCase() === 'temporal';
+ const xIsNumeric = (x?.type ?? '').toLowerCase() === 'quantitative';
+
+ const data = (spec.data?.values ?? [])
+ .map((d) => {
+ const xv =
+ xIsTime && typeof d[xField] === 'string'
+ ? new Date(d[xField])
+ : d[xField];
+ const yv = Number(d[yField]);
+ if (!Number.isFinite(yv)) return null;
+ const c = color?.field ? String(d[color.field]) : undefined;
+ return { dimension: xv, value: yv, color: c };
+ })
+ .filter(Boolean) as Datum[];
+
+ return {
+ type: spec.type === 'xy' && spec.markName !== 'arc' ? spec.markName : 'pie',
+ data,
+ isTemporalDim: xIsTime,
+ isNumericDim: xIsNumeric,
+ };
+};
diff --git a/packages/react-native-sdk/src/charts/vega-lite/schema.ts b/packages/react-native-sdk/src/charts/vega-lite/schema.ts
new file mode 100644
index 0000000..d7cbf4e
--- /dev/null
+++ b/packages/react-native-sdk/src/charts/vega-lite/schema.ts
@@ -0,0 +1,119 @@
+import { z } from 'zod';
+
+/* primitives */
+export const VLType = z.enum([
+ 'quantitative',
+ 'ordinal',
+ 'nominal',
+ 'temporal',
+]);
+
+const FieldRef = z.object({
+ field: z.string(),
+ type: VLType.optional(),
+});
+
+const ColorXY = z.object({
+ field: z.string().optional(),
+ type: VLType.optional(),
+});
+const ColorPie = z.object({ field: z.string(), type: VLType.optional() });
+
+/* we support only these currently */
+const MarkName = z.enum([
+ 'bar',
+ 'line',
+ 'area',
+ 'point',
+ 'arc',
+ // 'rect',
+ // 'rule',
+ // 'text',
+ // 'tick',
+ // 'circle',
+ // 'square',
+ // 'trail',
+ // 'geoshape',
+ // 'image',
+]);
+
+const MarkDef = z.looseObject({
+ type: z.union([MarkName, z.string()]).optional(),
+});
+
+/* encodings */
+const XyEncoding = z.object({
+ x: FieldRef,
+ y: FieldRef,
+ color: ColorXY.optional(),
+});
+
+const PieEncoding = z.object({
+ theta: FieldRef,
+ color: ColorPie.optional(),
+});
+
+/* base spec */
+const BaseVL = z.object({
+ archetype: z.literal('vegalite'),
+ $schema: z.string().optional(),
+ data: z.object({
+ values: z.array(z.record(z.string(), z.unknown())),
+ }),
+ mark: z.union([MarkName, MarkDef, z.string()]).optional(),
+ layer: z.array(z.looseObject({ mark: MarkDef })).optional(),
+ marks: z.array(MarkDef).optional(),
+ markName: MarkName,
+});
+
+/* branches (with explicit kind) */
+const VL_XY = BaseVL.extend({
+ type: z.literal('xy'),
+ encoding: XyEncoding,
+}).strip();
+
+const VL_PIE = BaseVL.extend({
+ type: z.literal('pie'),
+ encoding: PieEncoding,
+}).strip();
+
+const normalizeMarkName = (m: unknown): string | undefined => {
+ if (typeof m === 'string') return m.toLowerCase();
+ if (m && typeof m === 'object' && typeof (m as any).type === 'string') {
+ return String((m as any).type).toLowerCase();
+ }
+ return undefined;
+};
+
+const deriveMarkName = (o: any): string | undefined => {
+ // spec.mark (string or MarkDef)
+ const direct = normalizeMarkName(o?.mark);
+ if (direct) return direct;
+
+ // first layer's mark.type
+ const firstLayerMark =
+ Array.isArray(o?.layer) && o.layer[0]?.mark
+ ? normalizeMarkName(o.layer[0].mark)
+ : undefined;
+ if (firstLayerMark) return firstLayerMark;
+
+ // first marks[] item .type
+ return Array.isArray(o?.marks) && o.marks.length > 0
+ ? normalizeMarkName(o.marks[0])
+ : undefined;
+};
+
+export const VegaLiteCore = z.discriminatedUnion('type', [VL_XY, VL_PIE]);
+
+/* final schema: preprocess to inject needed metadata, then discriminated union */
+export const VegaLiteSchema = z.preprocess((raw) => {
+ const o: any = raw ?? {};
+ const markName = deriveMarkName(o);
+ const hasTheta = !!o?.encoding?.theta;
+
+ const type = markName === 'arc' || hasTheta ? 'pie' : 'xy';
+
+ return { ...o, type, archetype: 'vegalite', markName };
+}, VegaLiteCore);
+
+export type VegaLite = z.infer;
diff --git a/packages/react-native-sdk/src/components/MarkdownReactiveScrollView.tsx b/packages/react-native-sdk/src/components/MarkdownReactiveScrollView.tsx
index 15dd4f7..bd35cfc 100644
--- a/packages/react-native-sdk/src/components/MarkdownReactiveScrollView.tsx
+++ b/packages/react-native-sdk/src/components/MarkdownReactiveScrollView.tsx
@@ -7,7 +7,7 @@ import Animated, {
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { type LayoutChangeEvent, StyleSheet, View } from 'react-native';
-import { useStableCallback } from '../internal/hooks/useStableCallback.ts';
+import { useStableCallback } from '../internal/hooks/useStableCallback';
export const MarkdownReactiveScrollView = ({ children }: PropsWithChildren) => {
const scrollViewRef = useAnimatedRef();
diff --git a/packages/react-native-sdk/src/components/PerfText.tsx b/packages/react-native-sdk/src/components/PerfText.tsx
new file mode 100644
index 0000000..e1b44c3
--- /dev/null
+++ b/packages/react-native-sdk/src/components/PerfText.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { type StyleProp, type ViewStyle } from 'react-native';
+import PerfTextNative, {
+ type ColorRange as NativeColorRange,
+} from '../native-specs/PerfTextNativeComponent';
+import type { ColorRange } from '../syntax-highlighting';
+
+export type PerfTextProps = {
+ text: string;
+ ranges: ColorRange[];
+ style?: StyleProp;
+ selectable?: boolean;
+ fontSize?: number;
+ lineHeight?: number;
+};
+
+export const PerfText = ({
+ style,
+ fontSize,
+ lineHeight,
+ ranges,
+ text,
+}: PerfTextProps) => (
+
+);
diff --git a/packages/react-native-sdk/src/components/index.ts b/packages/react-native-sdk/src/components/index.ts
index 78395d3..18e8a56 100644
--- a/packages/react-native-sdk/src/components/index.ts
+++ b/packages/react-native-sdk/src/components/index.ts
@@ -1 +1,2 @@
-export * from './MarkdownReactiveScrollView.tsx';
+export * from './MarkdownReactiveScrollView';
+export * from './PerfText';
diff --git a/packages/react-native-sdk/src/index.ts b/packages/react-native-sdk/src/index.ts
index f666465..2b91220 100644
--- a/packages/react-native-sdk/src/index.ts
+++ b/packages/react-native-sdk/src/index.ts
@@ -1,2 +1,8 @@
+// primitives
export * from './markdown';
+export * from './syntax-highlighting';
+export * from './charts';
+
+// components
+export * from './components';
export * from './MarkdownRichText';
diff --git a/packages/react-native-sdk/src/markdown/components/Autolink.tsx b/packages/react-native-sdk/src/markdown/components/Autolink.tsx
index 859abd2..facd4c7 100644
--- a/packages/react-native-sdk/src/markdown/components/Autolink.tsx
+++ b/packages/react-native-sdk/src/markdown/components/Autolink.tsx
@@ -1,5 +1,5 @@
import { Text } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const Autolink = ({
children,
diff --git a/packages/react-native-sdk/src/markdown/components/BlockQuote.tsx b/packages/react-native-sdk/src/markdown/components/BlockQuote.tsx
index 009ae10..8a40b72 100644
--- a/packages/react-native-sdk/src/markdown/components/BlockQuote.tsx
+++ b/packages/react-native-sdk/src/markdown/components/BlockQuote.tsx
@@ -1,5 +1,5 @@
import { Text, View } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const BlockQuote = ({ children, styles }: MarkdownComponentProps) => (
diff --git a/packages/react-native-sdk/src/markdown/components/Bold.tsx b/packages/react-native-sdk/src/markdown/components/Bold.tsx
index c2d5466..b8f9c67 100644
--- a/packages/react-native-sdk/src/markdown/components/Bold.tsx
+++ b/packages/react-native-sdk/src/markdown/components/Bold.tsx
@@ -1,5 +1,5 @@
import { Text } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const Bold = ({ children, styles }: MarkdownComponentProps) => (
{children}
diff --git a/packages/react-native-sdk/src/markdown/components/CodeBlock.tsx b/packages/react-native-sdk/src/markdown/components/CodeBlock.tsx
index d5a9efc..4a13129 100644
--- a/packages/react-native-sdk/src/markdown/components/CodeBlock.tsx
+++ b/packages/react-native-sdk/src/markdown/components/CodeBlock.tsx
@@ -1,9 +1,11 @@
import { Pressable, type PressableProps, Text, View } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
import { MarkdownReactiveScrollView } from '../../components';
-// @ts-expect-error need to check what's up with the lib
-import SyntaxHighlighter from 'react-native-syntax-highlighter';
-import { type PropsWithChildren, useCallback, useMemo } from 'react';
+import { SyntaxHighlighter } from '../../syntax-highlighting';
+import React, { type PropsWithChildren, useCallback, useMemo } from 'react';
+import Chart from '../../charts/Chart';
+import { parseJsonChart } from '../../charts';
+import { parseMermaid } from '../../charts';
export const CodeBlockCopyButton = ({
onPress,
@@ -20,25 +22,14 @@ export const CodeBlockCopyButton = ({
export const CodeBlock = ({ styles, node }: MarkdownComponentProps) => {
const text = useMemo(() => node.content?.trim(), [node.content]);
- const lineNumbers = useMemo(
- () => Array.from({ length: text?.split('\n').length ?? 0 }, (_, i) => i),
- [text],
- );
const CodeTag = useCallback(
({ children }: PropsWithChildren) => (
-
- {lineNumbers.map((idx) => (
-
- {`${idx + 1}.`}
-
- ))}
-
{children}
),
- [styles, lineNumbers],
+ [styles],
);
const CodeBlockHeader = useCallback(
@@ -61,6 +52,28 @@ export const CodeBlock = ({ styles, node }: MarkdownComponentProps) => {
[CodeBlockHeader, styles.codeBlockWrapper],
);
+ if (node.lang === 'mermaid') {
+ try {
+ const parsed = parseMermaid(text);
+ if (parsed) {
+ return ;
+ }
+ } catch (_e) {
+ /* do nothing */
+ }
+ }
+
+ if (node.lang === 'json') {
+ try {
+ const parsed = parseJsonChart(text);
+ if (parsed) {
+ return ;
+ }
+ } catch (_e) {
+ /* do nothing */
+ }
+ }
+
return (
(
{children}
diff --git a/packages/react-native-sdk/src/markdown/components/Heading.tsx b/packages/react-native-sdk/src/markdown/components/Heading.tsx
index 762f2f8..eba7dd8 100644
--- a/packages/react-native-sdk/src/markdown/components/Heading.tsx
+++ b/packages/react-native-sdk/src/markdown/components/Heading.tsx
@@ -3,7 +3,7 @@ import type {
HeadingLevel,
MarkdownComponentProps,
RuleRenderFunction,
-} from '../types.ts';
+} from '../types';
import { useMemo } from 'react';
const DEFAULT_HEADING_LEVEL = undefined;
diff --git a/packages/react-native-sdk/src/markdown/components/HorizontalRule.tsx b/packages/react-native-sdk/src/markdown/components/HorizontalRule.tsx
index f4f2665..3290d42 100644
--- a/packages/react-native-sdk/src/markdown/components/HorizontalRule.tsx
+++ b/packages/react-native-sdk/src/markdown/components/HorizontalRule.tsx
@@ -1,5 +1,5 @@
import { View } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const HorizontalRule = ({ styles }: MarkdownComponentProps) => (
diff --git a/packages/react-native-sdk/src/markdown/components/InlineCode.tsx b/packages/react-native-sdk/src/markdown/components/InlineCode.tsx
index 30bca60..f6d5547 100644
--- a/packages/react-native-sdk/src/markdown/components/InlineCode.tsx
+++ b/packages/react-native-sdk/src/markdown/components/InlineCode.tsx
@@ -1,5 +1,5 @@
import { Text } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const InlineCode = ({ children, styles }: MarkdownComponentProps) => (
{children}
diff --git a/packages/react-native-sdk/src/markdown/components/LineBreak.tsx b/packages/react-native-sdk/src/markdown/components/LineBreak.tsx
index f280171..48dc972 100644
--- a/packages/react-native-sdk/src/markdown/components/LineBreak.tsx
+++ b/packages/react-native-sdk/src/markdown/components/LineBreak.tsx
@@ -1,5 +1,5 @@
import { Text } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const LineBreak = ({ styles }: MarkdownComponentProps) => (
{'\n\n'}
diff --git a/packages/react-native-sdk/src/markdown/components/Link.tsx b/packages/react-native-sdk/src/markdown/components/Link.tsx
index 229dc72..75f40d0 100644
--- a/packages/react-native-sdk/src/markdown/components/Link.tsx
+++ b/packages/react-native-sdk/src/markdown/components/Link.tsx
@@ -1,5 +1,5 @@
import { Text } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const Link = ({ children, styles, onPress }: MarkdownComponentProps) => (
diff --git a/packages/react-native-sdk/src/markdown/components/List.tsx b/packages/react-native-sdk/src/markdown/components/List.tsx
index 84e0047..ac6f6dd 100644
--- a/packages/react-native-sdk/src/markdown/components/List.tsx
+++ b/packages/react-native-sdk/src/markdown/components/List.tsx
@@ -2,7 +2,7 @@ import type {
BulletProps,
MarkdownComponentProps,
RuleRenderFunction,
-} from '../types.ts';
+} from '../types';
import { Text, type TextProps, View, type ViewProps } from 'react-native';
import type { SingleASTNode } from '@khanacademy/simple-markdown';
import type { PropsWithChildren } from 'react';
@@ -23,7 +23,7 @@ export const List = ({
if (item === null) {
return (
-
+
+
) => {
diff --git a/packages/react-native-sdk/src/markdown/components/NewLine.tsx b/packages/react-native-sdk/src/markdown/components/NewLine.tsx
index 4fdc406..d5945b6 100644
--- a/packages/react-native-sdk/src/markdown/components/NewLine.tsx
+++ b/packages/react-native-sdk/src/markdown/components/NewLine.tsx
@@ -1,5 +1,5 @@
import { Text } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const NewLine = ({ styles }: MarkdownComponentProps) => (
{'\n'}
diff --git a/packages/react-native-sdk/src/markdown/components/Paragraph.tsx b/packages/react-native-sdk/src/markdown/components/Paragraph.tsx
index 3e9a593..3b80f92 100644
--- a/packages/react-native-sdk/src/markdown/components/Paragraph.tsx
+++ b/packages/react-native-sdk/src/markdown/components/Paragraph.tsx
@@ -1,5 +1,5 @@
import { Text, type TextStyle, View } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
import { size, some } from 'lodash';
import { useMemo } from 'react';
diff --git a/packages/react-native-sdk/src/markdown/components/Strikethrough.tsx b/packages/react-native-sdk/src/markdown/components/Strikethrough.tsx
index 6ede91e..0c3aa13 100644
--- a/packages/react-native-sdk/src/markdown/components/Strikethrough.tsx
+++ b/packages/react-native-sdk/src/markdown/components/Strikethrough.tsx
@@ -1,5 +1,5 @@
import { Text } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const Strikethrough = ({ children, styles }: MarkdownComponentProps) => (
{children}
diff --git a/packages/react-native-sdk/src/markdown/components/Table.tsx b/packages/react-native-sdk/src/markdown/components/Table.tsx
index f44e522..7ec8694 100644
--- a/packages/react-native-sdk/src/markdown/components/Table.tsx
+++ b/packages/react-native-sdk/src/markdown/components/Table.tsx
@@ -4,7 +4,7 @@ import type {
MarkdownOutputProps,
MarkdownTableRowProps,
RuleRenderFunction,
-} from '../types.ts';
+} from '../types';
import { MarkdownReactiveScrollView } from '../../components';
import type { SingleASTNode } from '@khanacademy/simple-markdown';
import { useCallback, useMemo } from 'react';
diff --git a/packages/react-native-sdk/src/markdown/components/Text.tsx b/packages/react-native-sdk/src/markdown/components/Text.tsx
index 4a2335b..1da63f3 100644
--- a/packages/react-native-sdk/src/markdown/components/Text.tsx
+++ b/packages/react-native-sdk/src/markdown/components/Text.tsx
@@ -1,5 +1,5 @@
import { Text } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
import { useMemo } from 'react';
export const TextUI = ({ children, styles, state }: MarkdownComponentProps) => {
diff --git a/packages/react-native-sdk/src/markdown/components/Url.tsx b/packages/react-native-sdk/src/markdown/components/Url.tsx
index 51df77d..fa8ae78 100644
--- a/packages/react-native-sdk/src/markdown/components/Url.tsx
+++ b/packages/react-native-sdk/src/markdown/components/Url.tsx
@@ -1,5 +1,5 @@
import { Text } from 'react-native';
-import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
+import type { MarkdownComponentProps, RuleRenderFunction } from '../types';
export const Url = ({ children, styles, onPress }: MarkdownComponentProps) => (
diff --git a/packages/react-native-sdk/src/markdown/utils/generateMarkdownText.ts b/packages/react-native-sdk/src/markdown/utils/generate-markdown-text.ts
similarity index 98%
rename from packages/react-native-sdk/src/markdown/utils/generateMarkdownText.ts
rename to packages/react-native-sdk/src/markdown/utils/generate-markdown-text.ts
index 4a22f07..21da17b 100644
--- a/packages/react-native-sdk/src/markdown/utils/generateMarkdownText.ts
+++ b/packages/react-native-sdk/src/markdown/utils/generate-markdown-text.ts
@@ -1,7 +1,7 @@
import truncate from 'lodash/truncate';
import { find } from 'linkifyjs';
-import type { LinkInfo } from '../index.tsx';
+import type { LinkInfo } from '../index';
export function escapeRegExp(text: string) {
return text.replace(/[-[\]{}()*+?.,/\\^$|#]/g, '\\$&');
diff --git a/packages/react-native-sdk/src/markdown/utils/index.ts b/packages/react-native-sdk/src/markdown/utils/index.ts
index d581e86..dce5931 100644
--- a/packages/react-native-sdk/src/markdown/utils/index.ts
+++ b/packages/react-native-sdk/src/markdown/utils/index.ts
@@ -1 +1 @@
-export * from './generateMarkdownText';
+export * from './generate-markdown-text';
diff --git a/packages/react-native-sdk/src/native-specs/PerfTextNativeComponent.ts b/packages/react-native-sdk/src/native-specs/PerfTextNativeComponent.ts
new file mode 100644
index 0000000..1820468
--- /dev/null
+++ b/packages/react-native-sdk/src/native-specs/PerfTextNativeComponent.ts
@@ -0,0 +1,27 @@
+import type { ViewProps } from 'react-native';
+import type { Float, Int32 } from 'react-native/Libraries/Types/CodegenTypes';
+import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
+
+export type ColorRange = {
+ start: Int32;
+ end: Int32;
+ color: Int32; // processed to Android int on JS side
+};
+
+export interface NativeProps extends ViewProps {
+ text: string;
+ colorRanges?: ReadonlyArray;
+ fontSize?: Float;
+ fontFamily?: string;
+ lineHeight?: Float;
+}
+
+// Applying interfaceOnly is extremely important, as we are going to
+// override some of the codegen-generated code within our C++ bindings
+// and without it it won't compile as there will be naming collisions.
+// This essentially allows the compiler to pick the interface that's
+// defined first (which we make sure is ours within CMakeLists.txt) and
+// stick with that, ignoring anything else.
+export default codegenNativeComponent('PerfText', {
+ interfaceOnly: true,
+});
diff --git a/packages/react-native-sdk/src/syntax-highlighting/SyntaxHighlighter.tsx b/packages/react-native-sdk/src/syntax-highlighting/SyntaxHighlighter.tsx
new file mode 100644
index 0000000..0cf3829
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/SyntaxHighlighter.tsx
@@ -0,0 +1,71 @@
+import React, { type PropsWithChildren, useMemo } from 'react';
+import { Platform, type ProcessedColorValue } from 'react-native';
+
+import './prism-config';
+
+import SyntaxHighlighterHljs, {
+ Prism as SyntaxHighlighterPrism,
+} from 'react-syntax-highlighter';
+import { defaultStyle } from 'react-syntax-highlighter/dist/esm/styles/hljs';
+import { prism as prismDefaultStyle } from 'react-syntax-highlighter/dist/esm/styles/prism';
+import {
+ DEFAULT_FONT_SIZE,
+ generateNewStylesheet,
+ nativeRenderer,
+} from './utils';
+import type {
+ NativeSyntaxHighlighterProps,
+ SyntaxHighlighterStylesheet,
+} from './types';
+import { MarkdownReactiveScrollView } from '../components';
+
+export type ColorRange = {
+ start: number;
+ end: number;
+ color: ProcessedColorValue;
+};
+
+export const SyntaxHighlighter = ({
+ fontFamily = Platform.OS === 'ios' ? 'Courier' : 'Monospace',
+ fontSize = DEFAULT_FONT_SIZE,
+ children,
+ highlighter = 'highlightjs',
+ style = highlighter === 'prism' ? prismDefaultStyle : defaultStyle,
+ PreTag = MarkdownReactiveScrollView,
+ CodeTag = MarkdownReactiveScrollView,
+ ...rest
+}: PropsWithChildren) => {
+ const { transformedStyle, defaultColor } = useMemo(
+ () =>
+ generateNewStylesheet({
+ stylesheet: style,
+ highlighter,
+ }),
+ [highlighter, style],
+ );
+ const renderer = useMemo(
+ () =>
+ nativeRenderer({
+ defaultColor: defaultColor as string,
+ fontFamily,
+ fontSize,
+ }),
+ [defaultColor, fontFamily, fontSize],
+ );
+
+ const Highlighter =
+ highlighter === 'prism' ? SyntaxHighlighterPrism : SyntaxHighlighterHljs;
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/react-native-sdk/src/syntax-highlighting/components/AndroidNativeRenderer.tsx b/packages/react-native-sdk/src/syntax-highlighting/components/AndroidNativeRenderer.tsx
new file mode 100644
index 0000000..37a7feb
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/components/AndroidNativeRenderer.tsx
@@ -0,0 +1,35 @@
+import { flattenRowsToTextAndColorRanges } from '../utils';
+import { PerfText } from '../../components';
+import React from 'react';
+
+export const AndroidNativeRenderer = ({
+ rows,
+ stylesheet,
+ defaultColor,
+ fontFamily,
+ fontSize,
+ lineHeight = 17,
+}: {
+ rows: rendererNode[];
+ stylesheet: rendererProps['stylesheet'];
+ defaultColor: string;
+ fontFamily?: string;
+ fontSize: number;
+ lineHeight: number;
+}) => {
+ const { text, ranges } = flattenRowsToTextAndColorRanges(rows, {
+ stylesheet,
+ defaultColor,
+ fontFamily,
+ fontSize,
+ });
+
+ return (
+
+ );
+};
diff --git a/packages/react-native-sdk/src/syntax-highlighting/components/index.ts b/packages/react-native-sdk/src/syntax-highlighting/components/index.ts
new file mode 100644
index 0000000..294c4c3
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/components/index.ts
@@ -0,0 +1 @@
+export * from './AndroidNativeRenderer';
diff --git a/packages/react-native-sdk/src/syntax-highlighting/index.ts b/packages/react-native-sdk/src/syntax-highlighting/index.ts
new file mode 100644
index 0000000..f48e59c
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/index.ts
@@ -0,0 +1,11 @@
+/**
+ * This sub-library is a port/took inspiration from https://github.com/conorhastings/react-native-syntax-highlighter.
+ * We create our own since we don't want the version of https://github.com/react-syntax-highlighter/react-syntax-highlighter
+ * to be pinned to version 6 but rather use whatever we see fit.
+ */
+
+export * from './SyntaxHighlighter';
+export * from './prism-config';
+export * from './utils';
+
+export * from './types';
diff --git a/packages/react-native-sdk/src/syntax-highlighting/prism-config.ts b/packages/react-native-sdk/src/syntax-highlighting/prism-config.ts
new file mode 100644
index 0000000..c83c126
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/prism-config.ts
@@ -0,0 +1,2 @@
+// @ts-expect-error This negates the global variable set by react-syntax-highlighter
+global.Prism = { disableWorkerMessageHandler: true };
diff --git a/packages/react-native-sdk/src/syntax-highlighting/types.ts b/packages/react-native-sdk/src/syntax-highlighting/types.ts
new file mode 100644
index 0000000..5ad2f04
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/types.ts
@@ -0,0 +1,29 @@
+import type { TextStyle } from 'react-native';
+import type { SyntaxHighlighterProps } from 'react-syntax-highlighter';
+import type React from 'react';
+
+export type RNStyle = TextStyle;
+export type RNSheet = Record;
+
+export type SyntaxHighlighterStylesheet = NonNullable<
+ NativeSyntaxHighlighterProps['style']
+>;
+
+export type ExtraSyntaxHighlighterProps = {
+ fontFamily?: string;
+ fontSize?: number;
+ highlighter?: 'highlightjs' | 'prism';
+};
+
+export type NativeSyntaxHighlighterProps = SyntaxHighlighterProps &
+ ExtraSyntaxHighlighterProps;
+
+export type CSSP = React.CSSProperties;
+
+/** Options for unit conversion */
+export type ConvertOpts = {
+ /** base for `em` (defaults to 16) */
+ baseFontSize?: number;
+ /** root base for `rem` (defaults to baseFontSize) */
+ rootFontSize?: number;
+};
diff --git a/packages/react-native-sdk/src/syntax-highlighting/utils/converter.ts b/packages/react-native-sdk/src/syntax-highlighting/utils/converter.ts
new file mode 100644
index 0000000..bd4424e
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/utils/converter.ts
@@ -0,0 +1,152 @@
+import type { TextStyle } from 'react-native';
+import type { ConvertOpts, CSSP } from '../types';
+
+const EM_DEFAULT = 16;
+
+/** Parse CSS lengths like 14, "14", "14px", "1.25em", "0.875rem" */
+const len = (
+ v: unknown,
+ { baseFontSize, rootFontSize }: ConvertOpts,
+): number | undefined => {
+ if (v == null) return undefined;
+ if (typeof v === 'number') return v;
+ if (typeof v !== 'string') return undefined;
+
+ const s = v.trim().toLowerCase();
+ if (s.endsWith('px')) {
+ const n = Number(s.slice(0, -2));
+ return Number.isFinite(n) ? n : undefined;
+ }
+ if (s.endsWith('em')) {
+ const n = Number(s.slice(0, -2));
+ return Number.isFinite(n) ? n * (baseFontSize ?? EM_DEFAULT) : undefined;
+ }
+ if (s.endsWith('rem')) {
+ const n = Number(s.slice(0, -3));
+ const root = rootFontSize ?? baseFontSize ?? EM_DEFAULT;
+ return Number.isFinite(n) ? n * root : undefined;
+ }
+ // plain number-like string
+ const n = Number(s);
+ return Number.isFinite(n) ? n : undefined;
+};
+
+/** Font shorthand: e.g. "italic small-caps 700 14px/20px Menlo, monospace" */
+const parseFontShorthand = (v: string, opts: ConvertOpts) => {
+ const out: Partial = {};
+ const parts = v.split(/\s+/);
+ // Very light parser: pick out style, weight, size[/lineHeight], and family
+ // Strategy:
+ // - style: "normal|italic"
+ // - weight: "normal|bold|100..900"
+ // - when we hit a token like "14px" or "14px/20px" → size & optional lineHeight
+ // - everything after size token is font family (may contain commas/spaces)
+ let i = 0;
+
+ // style
+ if (parts[i] === 'italic' || parts[i] === 'normal') {
+ if (parts[i] !== 'normal') out.fontStyle = 'italic';
+ i++;
+ }
+
+ // (skip small-caps if present; RN uses fontVariant which is separate)
+ if (parts[i] === 'small-caps') {
+ out.fontVariant = ['small-caps'];
+ i++;
+ }
+
+ // weight
+ if (/^(normal|bold|[1-9]00)$/.test(parts[i] ?? '')) {
+ const token = parts[i]!;
+ out.fontWeight = (
+ token === 'normal' ? undefined : token
+ ) as TextStyle['fontWeight'];
+ i++;
+ }
+
+ // size[/lineHeight]
+ const sizeToken = parts[i];
+ if (
+ sizeToken &&
+ (/.+(px|em|rem)$/.test(sizeToken) ||
+ /^[\d.]+(\/[\d.]+)?(px|em|rem)?$/.test(sizeToken))
+ ) {
+ const [fs, lh] = sizeToken.split('/');
+ const fontSize = len(fs, opts);
+ if (fontSize != null) out.fontSize = fontSize;
+ if (lh) {
+ const lineHeight = len(lh, opts);
+ if (lineHeight != null) out.lineHeight = lineHeight;
+ }
+ i++;
+ }
+
+ // family
+ const family = parts.slice(i).join(' ').replace(/^,|,$/g, '').trim();
+ if (family) {
+ // basic cleanup: take first family; strip quotes
+ const first = family
+ .split(',')[0]
+ ?.trim()
+ .replace(/^["']|["']$/g, '');
+ if (first) out.fontFamily = first;
+ }
+
+ return out;
+};
+
+/** map CSS (web) to RN TextStyle */
+export const cssToRNTextStyle = (
+ css: Partial,
+ options: ConvertOpts = {},
+): TextStyle => {
+ const opts: ConvertOpts = {
+ baseFontSize: options.baseFontSize ?? EM_DEFAULT,
+ rootFontSize: options.rootFontSize ?? options.baseFontSize ?? EM_DEFAULT,
+ };
+
+ const out: Partial = {};
+
+ // Simple 1:1 or unit-converted mappings
+ if (css.color) out.color = String(css.color);
+
+ if (css.fontFamily) out.fontFamily = String(css.fontFamily);
+ if (css.fontStyle) out.fontStyle = css.fontStyle as TextStyle['fontStyle'];
+ if (css.fontWeight)
+ out.fontWeight = String(css.fontWeight) as TextStyle['fontWeight'];
+
+ if (css.fontSize != null) {
+ const n = len(css.fontSize, opts);
+ if (n != null) out.fontSize = n;
+ }
+
+ if (css.lineHeight != null) {
+ const n = len(css.lineHeight, opts);
+ if (n != null) out.lineHeight = n;
+ }
+
+ if (css.letterSpacing != null) {
+ const n = len(css.letterSpacing, opts);
+ if (n != null) out.letterSpacing = n;
+ }
+
+ if (css.textAlign) out.textAlign = css.textAlign as TextStyle['textAlign'];
+
+ if (css.font && typeof css.font === 'string') {
+ Object.assign(out, parseFontShorthand(css.font, opts));
+ }
+
+ // Simulating the closest we can get to a CSS overflow-like functionality
+ const overflowLike =
+ (css as any).overflow ?? (css as any).overflowX ?? (css as any).overflowY;
+ if (typeof overflowLike === 'string') {
+ out.overflow =
+ overflowLike === 'hidden'
+ ? 'hidden'
+ : overflowLike === 'scroll' || overflowLike === 'auto'
+ ? 'scroll'
+ : 'visible';
+ }
+
+ return out as TextStyle;
+};
diff --git a/packages/react-native-sdk/src/syntax-highlighting/utils/flatten-nodes.ts b/packages/react-native-sdk/src/syntax-highlighting/utils/flatten-nodes.ts
new file mode 100644
index 0000000..9104e23
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/utils/flatten-nodes.ts
@@ -0,0 +1,158 @@
+import type { SyntaxHighlighterStylesheet } from '../types';
+// @ts-expect-error createStyleObject is not available in the type exports, it is still exported though
+import { createStyleObject } from 'react-syntax-highlighter/dist/esm/create-element';
+import { processColor } from 'react-native';
+import type { ColorRange } from '../SyntaxHighlighter';
+
+type FlattenOptions = {
+ nodes: rendererNode | rendererNode[];
+ stylesheet: SyntaxHighlighterStylesheet;
+ defaultColor: string;
+ fontFamily?: string;
+ fontSize?: number;
+};
+
+export const flattenNodesToTextAndColorRanges = ({
+ nodes,
+ stylesheet,
+ defaultColor,
+ fontFamily,
+ fontSize = 13,
+}: FlattenOptions): { text: string; ranges: ColorRange[] } => {
+ let text = '';
+ const rawRanges: { start: number; end: number; color: string | number }[] =
+ [];
+
+ const startingStyle = { fontFamily, fontSize };
+
+ const walk = (
+ node: rendererNode | null | undefined,
+ inheritedColor: string | number,
+ ) => {
+ if (!node) return;
+
+ const { type, tagName, value, properties } = node;
+
+ // leaf node
+ if (type === 'text') {
+ if (value == null || value === '') return;
+ const chunk = String(value);
+ const start = text.length;
+ text += chunk;
+
+ const color = inheritedColor ?? defaultColor;
+ if (color != null && chunk.length) {
+ rawRanges.push({ start, end: start + chunk.length, color });
+ }
+ return;
+ }
+
+ // node with children
+ if (tagName) {
+ const baseStyle =
+ properties && properties.className
+ ? createStyleObject(
+ properties.className,
+ Object.assign(
+ { color: defaultColor },
+ properties.style,
+ startingStyle,
+ ),
+ stylesheet,
+ )
+ : { ...startingStyle, color: defaultColor };
+
+ // we take the node's color if present, then its inherited color
+ // if present and finally the block's default color if all else
+ // fails.
+ const effectiveColor =
+ (baseStyle as any).color ?? inheritedColor ?? defaultColor;
+
+ const children = node.children || [];
+ const childArray = Array.isArray(children) ? children : [children];
+ for (let i = 0; i < childArray.length; i++) {
+ walk(childArray[i], effectiveColor);
+ }
+ return;
+ }
+
+ // fallback
+ if (value != null) {
+ const chunk = String(value);
+ const start = text.length;
+ text += chunk;
+ const color = inheritedColor ?? defaultColor;
+ if (color != null && chunk.length) {
+ rawRanges.push({ start, end: start + chunk.length, color });
+ }
+ }
+ };
+
+ // Recursively flatten nodes
+ const nodeArray = Array.isArray(nodes) ? nodes : [nodes];
+ for (let i = 0; i < nodeArray.length; i++) {
+ walk(nodeArray[i], defaultColor);
+ }
+
+ // Merge adjacent ranges with same color and convert them to integers
+ rawRanges.sort((a, b) => a.start - b.start);
+ const merged: ColorRange[] = [];
+ for (let i = 0; i < rawRanges.length; i++) {
+ const r = rawRanges[i]!;
+ // in case the color is malformed for some reason, use the defaultColor
+ // as this one should always be non-nullish.
+ const colorInt = processColor(r.color)!; // ?? processColor(defaultColor))!;
+
+ if (!merged.length) {
+ merged.push({ start: r.start, end: r.end, color: colorInt });
+ continue;
+ }
+
+ const last = merged[merged.length - 1];
+ if (last && last.end === r.start && last.color === colorInt) {
+ last.end = r.end;
+ } else {
+ merged.push({ start: r.start, end: r.end, color: colorInt });
+ }
+ }
+
+ return { text, ranges: merged };
+};
+
+export const flattenRowsToTextAndColorRanges = (
+ rows: rendererNode[],
+ opts: Omit,
+): { text: string; ranges: ColorRange[]; longestLine: number } => {
+ let fullText = '';
+ let longestLine = 0;
+ const fullRanges: ColorRange[] = [];
+
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
+ const rowNodes = rows[rowIndex];
+
+ const { text, ranges } = flattenNodesToTextAndColorRanges({
+ ...opts,
+ nodes: rowNodes ?? [],
+ });
+
+ const offset = fullText.length;
+ fullText += text;
+ longestLine = Math.max(text.length, longestLine);
+
+ // Add newline between rows (no color)
+ if (rowIndex < rows.length - 1) {
+ fullText += '\n';
+ }
+
+ // Shift ranges by current offset & push
+ for (const r of ranges) {
+ fullRanges.push({
+ start: r.start + offset,
+ end: r.end + offset,
+ color: r.color,
+ });
+ }
+ }
+
+ return { text: fullText, ranges: fullRanges, longestLine };
+};
diff --git a/packages/react-native-sdk/src/syntax-highlighting/utils/index.ts b/packages/react-native-sdk/src/syntax-highlighting/utils/index.ts
new file mode 100644
index 0000000..8a1144d
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/utils/index.ts
@@ -0,0 +1,3 @@
+export * from './converter';
+export * from './flatten-nodes';
+export * from './renderer';
diff --git a/packages/react-native-sdk/src/syntax-highlighting/utils/renderer.tsx b/packages/react-native-sdk/src/syntax-highlighting/utils/renderer.tsx
new file mode 100644
index 0000000..2227cac
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/utils/renderer.tsx
@@ -0,0 +1,158 @@
+import type {
+ NativeSyntaxHighlighterProps,
+ RNSheet,
+ RNStyle,
+ SyntaxHighlighterStylesheet,
+} from '../types';
+import { cssToRNTextStyle } from './converter';
+import { Platform, Text } from 'react-native';
+// @ts-expect-error createStyleObject is not available in the type exports, it is still exported though
+import { createStyleObject } from 'react-syntax-highlighter/dist/esm/create-element';
+import type { SyntaxHighlighterProps } from 'react-syntax-highlighter';
+import { AndroidNativeRenderer } from '../components';
+import React from 'react';
+
+export const DEFAULT_FONT_SIZE = 13;
+
+export const generateNewStylesheet = ({
+ stylesheet,
+ highlighter,
+}: {
+ stylesheet: SyntaxHighlighterStylesheet;
+ highlighter: NativeSyntaxHighlighterProps['highlighter'];
+}) => {
+ stylesheet = Array.isArray(stylesheet) ? stylesheet[0] : stylesheet;
+
+ const transformedStyle = Object.entries(stylesheet ?? {}).reduce(
+ (newStylesheet, [className, style]) => {
+ const rn = cssToRNTextStyle(style);
+
+ newStylesheet[className] = rn as RNStyle;
+ return newStylesheet;
+ },
+ {},
+ );
+
+ const topLevel = (
+ highlighter === 'prism'
+ ? transformedStyle['pre[class*="language-"]']
+ : transformedStyle.hljs
+ ) as RNStyle;
+
+ const defaultColor = (topLevel && topLevel.color) || '#000';
+
+ return { transformedStyle, defaultColor };
+};
+
+export const createChildren = ({
+ stylesheet,
+ fontSize,
+ fontFamily,
+}: {
+ stylesheet: SyntaxHighlighterStylesheet;
+ fontSize?: number;
+ fontFamily?: string;
+}) => {
+ let childrenCount = 0;
+ return (children: rendererNode['children'], defaultColor: string) => {
+ childrenCount += 1;
+ return (children ?? []).map((child, i) =>
+ createNativeElement({
+ node: child,
+ stylesheet,
+ key: `code-segment-${childrenCount}-${i}`,
+ defaultColor,
+ fontSize,
+ fontFamily,
+ }),
+ );
+ };
+};
+
+export const createNativeElement = ({
+ node,
+ stylesheet,
+ key,
+ defaultColor,
+ fontFamily,
+ fontSize = DEFAULT_FONT_SIZE,
+}: {
+ node: rendererNode;
+ stylesheet: SyntaxHighlighterStylesheet;
+ key: string;
+ defaultColor: string;
+ fontFamily?: string;
+ fontSize?: number;
+}) => {
+ const { properties, type, tagName: TagName, value } = node;
+ const startingStyle = { fontFamily, fontSize };
+ if (type === 'text') {
+ return (
+
+ {value}
+
+ );
+ } else if (TagName) {
+ const childrenFactory = createChildren({
+ stylesheet,
+ fontSize,
+ fontFamily,
+ });
+ const style = properties
+ ? createStyleObject(
+ properties.className,
+ Object.assign(
+ { color: defaultColor },
+ properties.style,
+ startingStyle,
+ ),
+ stylesheet,
+ )
+ : {};
+ const children = childrenFactory(
+ node.children,
+ style.color || defaultColor,
+ );
+ return (
+
+ {children}
+
+ );
+ }
+};
+
+export const nativeRenderer = ({
+ defaultColor,
+ fontFamily,
+ fontSize = 13,
+}: {
+ defaultColor: string;
+ fontFamily?: string;
+ fontSize?: number;
+}): SyntaxHighlighterProps['renderer'] => {
+ return ({ rows, stylesheet }) =>
+ Platform.OS === 'android' ? (
+
+ ) : (
+ rows.map((node, i) =>
+ createNativeElement({
+ node,
+ stylesheet,
+ key: `code-segment-${i}`,
+ defaultColor,
+ fontFamily,
+ fontSize,
+ }),
+ )
+ );
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 93c9860..e1c8c15 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -221,13 +221,16 @@ importers:
react-native-svg:
specifier: '>=15.8.0'
version: 15.14.0(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
- react-native-syntax-highlighter:
- specifier: ^2.1.0
- version: 2.1.0(react-syntax-highlighter@15.5.0(react@19.2.0))
react-syntax-highlighter:
specifier: 15.5.0
version: 15.5.0(react@19.2.0)
+ zod:
+ specifier: ^4.1.12
+ version: 4.1.12
devDependencies:
+ '@shopify/react-native-skia':
+ specifier: ^2.3.10
+ version: 2.3.10(react-native-reanimated@4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
'@types/lodash':
specifier: 4.17.20
version: 4.17.20
@@ -237,6 +240,9 @@ importers:
'@types/react':
specifier: 19.2.2
version: 19.2.2
+ '@types/react-syntax-highlighter':
+ specifier: ^15.5.13
+ version: 15.5.13
concurrently:
specifier: 'catalog:'
version: 9.2.1
@@ -261,6 +267,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 5.9.3
+ victory-native:
+ specifier: ^41.20.2
+ version: 41.20.2(@shopify/react-native-skia@2.3.10(react-native-reanimated@4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react-native-gesture-handler@2.29.0(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
vitest:
specifier: 'catalog:'
version: 4.0.6(@types/debug@4.1.12)(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
@@ -2013,6 +2022,19 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+ '@shopify/react-native-skia@2.3.10':
+ resolution: {integrity: sha512-W1NEvkJgOQ+B0v0GUaDbHA32GNakGwvs0KBHN0TAAeVZpc9/hC8ilAMkBkR9I0kUo5L6eO3MgbmhpZ4HoGuWaA==}
+ hasBin: true
+ peerDependencies:
+ react: '>=19.0'
+ react-native: '>=0.78'
+ react-native-reanimated: '>=3.19.1'
+ peerDependenciesMeta:
+ react-native:
+ optional: true
+ react-native-reanimated:
+ optional: true
+
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@@ -2212,6 +2234,11 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
+ '@types/react-reconciler@0.28.9':
+ resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
+ peerDependencies:
+ '@types/react': '*'
+
'@types/react-syntax-highlighter@15.5.13':
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
@@ -2446,6 +2473,9 @@ packages:
'@vitest/utils@4.0.6':
resolution: {integrity: sha512-bG43VS3iYKrMIZXBo+y8Pti0O7uNju3KvNn6DrQWhQQKcLavMB+0NZfO1/QBAEbq0MaQ3QjNsnnXlGQvsh0Z6A==}
+ '@webgpu/types@0.1.21':
+ resolution: {integrity: sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==}
+
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
@@ -2797,6 +2827,9 @@ packages:
caniuse-lite@1.0.30001751:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
+ canvaskit-wasm@0.40.0:
+ resolution: {integrity: sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==}
+
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -2969,6 +3002,72 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+
+ d3-drag@3.0.0:
+ resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.0:
+ resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-selection@3.0.0:
+ resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
+ d3-transition@3.0.1:
+ resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ d3-selection: 2 - 3
+
+ d3-zoom@3.0.0:
+ resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+ engines: {node: '>=12'}
+
daisyui@5.4.2:
resolution: {integrity: sha512-yLoRFlx5hKvn5ODpT7CVb9oU/fAF2X1BGuLmVZo4LN33r7hcmO8v+gcxB6l33mcMas5jut3lZwHj9erqbMvvEA==}
@@ -3837,6 +3936,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@@ -4046,6 +4149,11 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
+ its-fine@1.2.5:
+ resolution: {integrity: sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==}
+ peerDependencies:
+ react: '>=18.0'
+
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@@ -5076,6 +5184,9 @@ packages:
peerDependencies:
react: ^19.2.0
+ react-fast-compare@3.2.2:
+ resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -5122,11 +5233,6 @@ packages:
react: '*'
react-native: '*'
- react-native-syntax-highlighter@2.1.0:
- resolution: {integrity: sha512-upu8gpKT2ZeslXn2d763KwtzzhM9OUHGgJjIKKIUw1JnFAzVwQmKCaFGoI6PkQa7T1LVggBW5k5VoaLFhZDb+g==}
- peerDependencies:
- react-syntax-highlighter: ^6.0.4
-
react-native-worklets@0.5.2:
resolution: {integrity: sha512-lCzmuIPAK/UaOJYEPgYpVqrsxby1I54f7PyyZUMEO04nwc00CDrCvv9lCTY1daLHYTF8lS3f9zlzErfVsIKqkA==}
peerDependencies:
@@ -5145,6 +5251,12 @@ packages:
'@types/react':
optional: true
+ react-reconciler@0.31.0:
+ resolution: {integrity: sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==}
+ engines: {node: '>=0.10.0'}
+ peerDependencies:
+ react: ^19.0.0
+
react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
@@ -5315,6 +5427,9 @@ packages:
sax@1.4.1:
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
+ scheduler@0.25.0:
+ resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
+
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
@@ -5852,6 +5967,15 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+ victory-native@41.20.2:
+ resolution: {integrity: sha512-gc6DPnfoAHYnaWndXfrITlBvKdLKtoq3J4muBn/Sawu528+MfByAI8LlJBboDGHE1rLBWsgiS58h67D3Ulmtqg==}
+ peerDependencies:
+ '@shopify/react-native-skia': '>=1.2.3'
+ react: '*'
+ react-native: '*'
+ react-native-gesture-handler: '>=2.0.0'
+ react-native-reanimated: '>=3.0.0'
+
vite@7.1.11:
resolution: {integrity: sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -8228,6 +8352,15 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
+ '@shopify/react-native-skia@2.3.10(react-native-reanimated@4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ canvaskit-wasm: 0.40.0
+ react: 19.2.0
+ react-reconciler: 0.31.0(react@19.2.0)
+ optionalDependencies:
+ react-native: 0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0)
+ react-native-reanimated: 4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
+
'@sinclair/typebox@0.27.8': {}
'@sinonjs/commons@3.0.1':
@@ -8422,6 +8555,10 @@ snapshots:
dependencies:
'@types/react': 19.2.2
+ '@types/react-reconciler@0.28.9(@types/react@19.2.2)':
+ dependencies:
+ '@types/react': 19.2.2
+
'@types/react-syntax-highlighter@15.5.13':
dependencies:
'@types/react': 19.2.2
@@ -8673,6 +8810,8 @@ snapshots:
'@vitest/pretty-format': 4.0.6
tinyrainbow: 3.0.3
+ '@webgpu/types@0.1.21': {}
+
'@xmldom/xmldom@0.8.11': {}
abort-controller@3.0.0:
@@ -9100,6 +9239,10 @@ snapshots:
caniuse-lite@1.0.30001751: {}
+ canvaskit-wasm@0.40.0:
+ dependencies:
+ '@webgpu/types': 0.1.21
+
ccount@2.0.1: {}
chai@6.2.0: {}
@@ -9272,6 +9415,70 @@ snapshots:
csstype@3.1.3: {}
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-color@3.1.0: {}
+
+ d3-dispatch@3.0.1: {}
+
+ d3-drag@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-selection: 3.0.0
+
+ d3-ease@3.0.1: {}
+
+ d3-format@3.1.0: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@3.1.0: {}
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.0
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-selection@3.0.0: {}
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
+ d3-transition@3.0.1(d3-selection@3.0.0):
+ dependencies:
+ d3-color: 3.1.0
+ d3-dispatch: 3.0.1
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-timer: 3.0.1
+
+ d3-zoom@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
daisyui@5.4.2: {}
damerau-levenshtein@1.0.8: {}
@@ -10345,6 +10552,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
+ internmap@2.0.3: {}
+
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
@@ -10557,6 +10766,13 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
+ its-fine@1.2.5(@types/react@19.2.2)(react@19.2.0):
+ dependencies:
+ '@types/react-reconciler': 0.28.9(@types/react@19.2.2)
+ react: 19.2.0
+ transitivePeerDependencies:
+ - '@types/react'
+
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -12019,6 +12235,8 @@ snapshots:
react: 19.2.0
scheduler: 0.27.0
+ react-fast-compare@3.2.2: {}
+
react-is@16.13.1: {}
react-is@18.3.1: {}
@@ -12103,10 +12321,6 @@ snapshots:
react-native: 0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0)
warn-once: 0.1.1
- react-native-syntax-highlighter@2.1.0(react-syntax-highlighter@15.5.0(react@19.2.0)):
- dependencies:
- react-syntax-highlighter: 15.5.0(react@19.2.0)
-
react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/core': 7.28.4
@@ -12174,6 +12388,11 @@ snapshots:
- supports-color
- utf-8-validate
+ react-reconciler@0.31.0(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ scheduler: 0.25.0
+
react-refresh@0.14.2: {}
react-refresh@0.17.0: {}
@@ -12406,6 +12625,8 @@ snapshots:
sax@1.4.1: {}
+ scheduler@0.25.0: {}
+
scheduler@0.26.0: {}
scheduler@0.27.0: {}
@@ -13035,6 +13256,21 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ victory-native@41.20.2(@shopify/react-native-skia@2.3.10(react-native-reanimated@4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react-native-gesture-handler@2.29.0(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0):
+ dependencies:
+ '@shopify/react-native-skia': 2.3.10(react-native-reanimated@4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ d3-zoom: 3.0.0
+ its-fine: 1.2.5(@types/react@19.2.2)(react@19.2.0)
+ react: 19.2.0
+ react-fast-compare: 3.2.2
+ react-native: 0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0)
+ react-native-gesture-handler: 2.29.0(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
+ react-native-reanimated: 4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0))(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
+ transitivePeerDependencies:
+ - '@types/react'
+
vite@7.1.11(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
esbuild: 0.25.11