Merge pull request #1545 from selfxyz/staging

Release to Production - 2026-01-04
This commit is contained in:
Justin Hernandez
2026-01-15 08:44:32 -08:00
committed by GitHub
140 changed files with 9127 additions and 1256 deletions

View File

@@ -1,16 +1,4 @@
{
"mcpServers": {
"giga": {
"command": "npx",
"args": [
"-y",
"mcp-remote@latest",
"https://mcp.gigamind.dev/mcp"
]
}
},
"settings": {
"disableAutoPRAnalysis": true,
"manualReviewEnabled": true
}
"mcpServers": {},
"settings": {}
}

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,37 @@
---
name: Bug Report
about: Report a bug or unexpected behavior
title: '[Bug] '
labels: bug
assignees: ''
---
> **⚠️ Security Issues**: If you've discovered a security vulnerability, **do not** open a public issue. Please report it responsibly by emailing **team@self.xyz** instead.
## Description
_A clear and concise description of what the bug is._
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
_What you expected to happen._
## Actual Behavior
_What actually happened._
## Environment (optional)
- Workspace: _e.g., app, circuits, contracts, sdk/core, etc._
- Platform: _e.g., iOS, Android, Web_
- Version: _if applicable_
## Additional Context
_Any other context, logs, or screenshots that might help._

View File

@@ -0,0 +1,29 @@
---
name: Feature Request / Contribution
about: Suggest a new feature or propose a contribution
title: '[Feature] '
labels: enhancement
assignees: ''
---
> **💡 For Complex Features**: If your contribution targets core components or introduces complex features, please open an issue first to discuss your implementation plan before starting development. See [contribute.md](https://github.com/selfxyz/self/blob/dev/contribute.md) for guidelines.
## Description
_A clear description of what you want to build or contribute._
## Motivation
_Why is this feature useful? What problem does it solve?_
## Proposed Solution (optional)
_If you have ideas on how to implement this, describe them here._
## Workspace (optional)
_Which workspace(s) would this affect? (e.g., app, circuits, contracts, sdk/core, etc.)_
## Additional Context
_Any other context, mockups, or examples._

View File

@@ -24,6 +24,8 @@ jobs:
echo "Running for ${{ github.base_ref }} - no path filter"
else
# For dev branch, check if circuits files changed
# Fetch the base branch to ensure it's available for comparison
git fetch origin ${{ github.base_ref }} --depth=1
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || {
echo "Error: Failed to diff against base branch"
exit 1

View File

@@ -29,6 +29,8 @@ jobs:
echo "Running for ${{ github.base_ref }} - no path filter"
else
# For dev branch, check if contracts or common files changed
# Fetch the base branch to ensure it's available for comparison
git fetch origin ${{ github.base_ref }} --depth=1
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || {
echo "Error: Failed to diff against base branch"
exit 1

View File

@@ -2,14 +2,48 @@ name: Core SDK CI
on:
pull_request:
paths:
- "sdk/core/**"
- "common/**"
- ".github/workflows/core-sdk-ci.yml"
- ".github/actions/**"
branches:
- dev
- staging
- main
jobs:
check_changes:
runs-on: ubuntu-slim
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check if should run
id: filter
run: |
set -e
if [[ "${{ github.base_ref }}" == "main" ]] || [[ "${{ github.base_ref }}" == "staging" ]]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Running for ${{ github.base_ref }} - no path filter"
else
# For dev branch, check if relevant files changed
# Fetch the base branch to ensure it's available for comparison
git fetch origin ${{ github.base_ref }} --depth=1
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || {
echo "Error: Failed to diff against base branch"
exit 1
}
if echo "$CHANGED_FILES" | grep -qE "^(sdk/core/|common/|\.github/workflows/core-sdk-ci\.yml|\.github/actions/)"; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Running for dev - relevant files changed"
else
echo "should_run=false" >> $GITHUB_OUTPUT
echo "Skipping for dev - no relevant files changed"
fi
fi
build:
needs: check_changes
if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -34,7 +68,8 @@ jobs:
lint:
runs-on: ubuntu-latest
needs: build
needs: [check_changes, build]
if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true'
permissions:
contents: read
steps:
@@ -63,7 +98,8 @@ jobs:
types:
runs-on: ubuntu-latest
needs: build
needs: [check_changes, build]
if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true'
permissions:
contents: read
steps:
@@ -92,7 +128,8 @@ jobs:
test:
runs-on: ubuntu-latest
needs: build
needs: [check_changes, build]
if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true'
permissions:
contents: read
steps:

View File

@@ -71,8 +71,34 @@ jobs:
echo "📈 Version bump: build only"
fi
# Always deploy both platforms for now (can be enhanced later)
echo 'platforms=["ios", "android"]' >> $GITHUB_OUTPUT
# Determine platforms based on deploy labels
# If both deploy:ios and deploy:android labels exist, deploy both
# If neither label exists, deploy both (default behavior)
# If only one label exists, deploy only that platform
has_ios_label=false
has_android_label=false
if [[ "$labels" =~ deploy:ios ]]; then
has_ios_label=true
fi
if [[ "$labels" =~ deploy:android ]]; then
has_android_label=true
fi
if [[ "$has_ios_label" == "true" && "$has_android_label" == "true" ]]; then
echo 'platforms=["ios", "android"]' >> $GITHUB_OUTPUT
echo "📱 Deploying both iOS and Android (both labels present)"
elif [[ "$has_ios_label" == "true" ]]; then
echo 'platforms=["ios"]' >> $GITHUB_OUTPUT
echo "📱 Deploying iOS only (deploy:ios label present)"
elif [[ "$has_android_label" == "true" ]]; then
echo 'platforms=["android"]' >> $GITHUB_OUTPUT
echo "📱 Deploying Android only (deploy:android label present)"
else
echo 'platforms=["ios", "android"]' >> $GITHUB_OUTPUT
echo "📱 Deploying both iOS and Android (no platform labels, default behavior)"
fi
echo "should_deploy=true" >> $GITHUB_OUTPUT
- name: Log deployment info

View File

@@ -16,7 +16,7 @@ name: Mobile Deploy
# - This allows testing from feature branches before merging to dev/staging
#
# Version Bump PR: After successful build, creates PR to bump version
# - Default target: 'dev' branch (can be overridden with bump_target_branch input)
# - Target: 'dev' branch
# - Workflow checks out the target branch, applies version changes, and creates PR
# - This separates the build source from the version bump destination
#
@@ -25,8 +25,6 @@ name: Mobile Deploy
# - Merge PR to staging → builds from staging → creates version bump PR to dev
# 2. Testing from feature branch:
# - Manually trigger from feature branch → builds from feature branch → creates version bump PR to dev
# 3. Custom version bump target:
# - Set bump_target_branch input → creates version bump PR to specified branch instead of dev
env:
# Build environment versions
@@ -99,11 +97,6 @@ on:
required: false
type: boolean
default: false
bump_target_branch:
description: "Target branch for version bump PR (default: dev). NOTE: This is where the version bump PR will be created, NOT the branch to build from. The workflow always builds from the triggering branch."
required: false
type: string
default: "dev"
pull_request:
types: [closed]
@@ -1301,8 +1294,8 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Checkout target branch for version bump PR (default: dev, override with bump_target_branch input)
ref: ${{ inputs.bump_target_branch || 'dev' }}
# Checkout target branch for version bump PR (always dev)
ref: "dev"
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -1414,7 +1407,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.bump-version.outputs.version }}"
TARGET_BRANCH="${{ inputs.bump_target_branch || 'dev' }}"
TARGET_BRANCH="dev"
# Add timestamp to branch name to avoid collisions
TIMESTAMP=$(date +%s%N | cut -b1-13) # Milliseconds since epoch (13 digits)
BRANCH_NAME="ci/bump-mobile-version-${VERSION}-${TIMESTAMP}"
@@ -1490,8 +1483,8 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Checkout target branch for tagging (usually dev)
ref: ${{ inputs.bump_target_branch || 'dev' }}
# Checkout target branch for tagging (always dev)
ref: "dev"
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git

View File

@@ -14,15 +14,45 @@ on:
- dev
- staging
- main
paths:
- "sdk/qrcode/**"
- "common/**"
- ".github/workflows/qrcode-sdk-ci.yml"
- ".github/actions/**"
jobs:
check_changes:
runs-on: ubuntu-slim
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check if should run
id: filter
run: |
set -e
if [[ "${{ github.base_ref }}" == "main" ]] || [[ "${{ github.base_ref }}" == "staging" ]]; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Running for ${{ github.base_ref }} - no path filter"
else
# For dev branch, check if relevant files changed
# Fetch the base branch to ensure it's available for comparison
git fetch origin ${{ github.base_ref }} --depth=1
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || {
echo "Error: Failed to diff against base branch"
exit 1
}
if echo "$CHANGED_FILES" | grep -qE "^(sdk/qrcode/|common/|\.github/workflows/qrcode-sdk-ci\.yml|\.github/actions/)"; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Running for dev - relevant files changed"
else
echo "should_run=false" >> $GITHUB_OUTPUT
echo "Skipping for dev - no relevant files changed"
fi
fi
# Build dependencies once and cache for other jobs
build:
needs: check_changes
if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -83,7 +113,8 @@ jobs:
# Quality checks job
quality-checks:
runs-on: ubuntu-latest
needs: build
needs: [check_changes, build]
if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true'
steps:
- uses: actions/checkout@v6
- name: Read and sanitize Node.js version
@@ -151,7 +182,8 @@ jobs:
# Build verification job
build-verification:
runs-on: ubuntu-latest
needs: build
needs: [check_changes, build]
if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true'
steps:
- uses: actions/checkout@v6
- name: Read and sanitize Node.js version
@@ -213,7 +245,8 @@ jobs:
# Integration test job
integration-test:
runs-on: ubuntu-latest
needs: build
needs: [check_changes, build]
if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true'
steps:
- uses: actions/checkout@v6
- name: Read and sanitize Node.js version

View File

@@ -8,7 +8,7 @@ gem "cocoapods", ">= 1.13", "!= 1.15.0", "!= 1.15.1"
gem "activesupport", ">= 6.1.7.5", "!= 7.1.0"
# Add fastlane for CI/CD
gem "fastlane", "~> 2.228.0"
gem "fastlane", "~> 2.230.0"
group :development do
gem "dotenv"

View File

@@ -2,6 +2,7 @@ GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
activesupport (7.2.3)
base64
benchmark (>= 0.3)
@@ -22,8 +23,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1198.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1201.0)
aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -31,17 +32,17 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
claide (1.1.0)
@@ -88,6 +89,7 @@ GEM
highline (~> 2.0.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
@@ -128,15 +130,18 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
fastlane (2.230.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@@ -154,9 +159,12 @@ GEM
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
@@ -174,7 +182,7 @@ GEM
fastlane-plugin-versioning_android (0.1.1)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
ffi (1.17.2)
ffi (1.17.3)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
@@ -229,17 +237,18 @@ GEM
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (6.0.0)
minitest (6.0.1)
prism (~> 1.5)
molinillo (0.8.0)
multi_json (1.18.0)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
nap (1.1.0)
naturally (2.3.0)
netrc (0.11.0)
nokogiri (1.18.10)
nkf (0.2.0)
nokogiri (1.19.0)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
optparse (0.8.1)
@@ -304,7 +313,7 @@ DEPENDENCIES
activesupport (>= 6.1.7.5, != 7.1.0)
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
dotenv
fastlane (~> 2.228.0)
fastlane (~> 2.230.0)
fastlane-plugin-increment_version_code
fastlane-plugin-versioning_android
nokogiri (~> 1.18)

View File

@@ -1,32 +1,80 @@
# Self.xyz Mobile App
## Quick Start
Run the interactive setup script to check and install all dependencies:
```bash
./scripts/setup-macos.sh
```
The script will prompt you to choose between:
1. **Check only** - Just show what's installed/missing
2. **Interactive setup** - Check and confirm before installing (recommended)
3. **Auto-install** - Install everything without prompts
You can also pass flags directly: `--check-only` or `--yes`
## Requirements
| Requirement | Version | Installation Guide |
| ----------- | -------- | ------------------------------------------------------------------------ |
| nodejs | >= 22 | [Install nodejs](https://nodejs.org/) |
| ruby | >= 3.1.0 | [Install ruby](https://www.ruby-lang.org/en/documentation/installation/) |
| circom | Latest | [Install circom](https://docs.circom.io/) |
| snarkjs | Latest | [Install snarkjs](https://github.com/iden3/snarkjs) |
| watchman | Latest | [Install watchman](https://facebook.github.io/watchman/) |
### macOS Setup
### Android
#### Core Dependencies
| Requirement | Version | Installation Guide |
| --------------------------- | ------------- | ------------------------------------------------------------------------------------ |
| Java | 17 | [Install Java](https://www.oracle.com/java/technologies/javase-jdk17-downloads.html) |
| Android Studio (Optional)\* | Latest | [Install Android Studio](https://developer.android.com/studio) |
| Android SDK | Latest | See instructions for Android below |
| Android NDK | 27.0.12077973 | See instructions for Android below |
```bash
# Node.js 22+ (via nvm)
nvm install 22
nvm use 22
\* To facilitate the installation of the SDK and the NDK, and to pair with development devices with a conventient QR code, you can use Android Studio.
# Watchman
brew install watchman
### iOS
# Ruby (via rbenv) - version specified in .ruby-version
brew install rbenv
echo 'eval "$(rbenv init -)"' >> ~/.zshrc
source ~/.zshrc
rbenv install # Reads version from .ruby-version
rbenv rehash
| Requirement | Version | Installation Guide |
| ----------- | ------- | --------------------------------------------------- |
| Xcode | Latest | [Install Xcode](https://developer.apple.com/xcode/) |
| cocoapods | Latest | [Install cocoapods](https://cocoapods.org/) |
# Ruby gems
gem install cocoapods bundler
# circom and snarkjs (for ZK circuits)
# Follow: https://docs.circom.io/ and https://github.com/iden3/snarkjs
```
#### Android Dependencies
```bash
# Java 17
brew install openjdk@17
```
Then install [Android Studio](https://developer.android.com/studio) and configure SDK/NDK (see [Android Setup](#android) below).
#### iOS Dependencies
Install [Xcode](https://developer.apple.com/xcode/) from the App Store (includes Command Line Tools).
### Shell Configuration
Add the following to your `~/.zshrc` (or `~/.bashrc`):
```bash
# Java
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
# Android
export ANDROID_HOME=~/Library/Android/sdk
export ANDROID_SDK_ROOT=$ANDROID_HOME
export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools
```
Then reload your shell:
```bash
source ~/.zshrc
```
## Installation
@@ -54,47 +102,41 @@ and rerun the command.
### Android
#### Using Android Studio
#### Using Android Studio (Recommended)
In Android Studio, go to **Tools** > **SDK Manager** in the menu
1. Download and install [Android Studio](https://developer.android.com/studio)
2. Open Android Studio → **Settings** (or **Preferences** on macOS) → **SDK Manager**
3. Under **SDK Platforms**, install the platform with the highest API number
4. Under **SDK Tools**, check **Show Package Details**, expand **NDK (Side by side)**, select version **27.0.12077973** and install
5. Enable **USB debugging** on your Android device (Settings → Developer options → USB debugging)
Under **SDK Platforms**, install the platform with the highest API number
#### Using sdkmanager via CLI (Alternative)
Under **SDK Tools**, check the **Show Package Details** checkbox, expand **NDK (Side by side)**, select version **27.0.12077973** and install.
If you prefer not to use Android Studio, you can install the SDK via command line:
#### Using sdkmanager via CLI
Create a directory for the Android SDK. For example `~/android_sdk`. Define the environment variable `ANDROID_HOME` to point that directory.
Install sdkmanager under `ANDROID_HOME` according to the instructions on https://developer.android.com/tools/sdkmanager
List available SDK platforms
1. Create a directory for the Android SDK (e.g., `~/android_sdk`) and set `ANDROID_HOME` to point to it
2. Install sdkmanager according to the [official instructions](https://developer.android.com/tools/sdkmanager)
```bash
# List available SDK platforms
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --list | grep platforms
```
In the list of platforms, find the latest version and install it. (Replace _NN_ with the latest version number)
```bash
# Install the latest platform (replace NN with version number)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "platforms;android-NN"
```
Install the NDK
```bash
# Install the NDK
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.12077973"
```
Define the environment variable `ANDROID_NDK_VERSION` to `27.0.12077973` and `ANDROID_NDK` to `$ANDROID_HOME/ndk/27.0.12077973`
Install Platform Tools, needed for the `adb` tool
```bash
# Install Platform Tools (for adb)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install platform-tools
```
Add `$ANDROID_HOME/platform-tools` to your `$PATH` variable
Set additional environment variables:
```bash
export ANDROID_NDK_VERSION=27.0.12077973
export ANDROID_NDK=$ANDROID_HOME/ndk/27.0.12077973
```
## Run the app
@@ -149,13 +191,14 @@ To view the Android logs, use the Logcat feature in Android Studio, or use the `
> :warning: To run the app on iOS, you will need a paying Apple Developer account. Free accounts can't run apps that use NFC reading.<br/>
> Contact us if you need it to contribute.
Open the ios project on Xcode and add your provisioning profile in Targets > OpenPassport > Signing and Capabilities
Open the ios project in Xcode and add your provisioning profile in Targets > OpenPassport > Signing and Capabilities.
Then, install pods:
Then, install Ruby dependencies and CocoaPods:
```
```bash
cd ios
pod install
bundle install
bundle exec pod install
```
And run the app in Xcode.

View File

@@ -135,7 +135,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 121
versionName "2.9.7"
versionName "2.9.11"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {
cmake {

View File

@@ -172,6 +172,10 @@ platform :ios do
Fastlane::Helpers.verify_env_vars(required_env_vars)
if local_development && version_bump != "skip"
Fastlane::Helpers.bump_local_build_number("ios")
end
# Read build number from version.json (already set by CI or local version-manager.cjs)
build_number = Fastlane::Helpers.get_ios_build_number
UI.message("📦 Using iOS build number: #{build_number}")
@@ -310,18 +314,14 @@ platform :android do
# Uploads must be done by CI/CD machines with proper authentication
if local_development && !skip_upload
skip_upload = true
UI.important("🏠 LOCAL DEVELOPMENT: Automatically skipping Play Store upload")
UI.important(" Uploads require CI/CD machine permissions and will be handled automatically")
UI.important("🏠 LOCAL DEVELOPMENT: Play Store uploads are disabled")
UI.important(" Upload the AAB manually in the Play Console after the build finishes")
end
if local_development
if ENV["ANDROID_KEYSTORE_PATH"].nil?
ENV["ANDROID_KEYSTORE_PATH"] = Fastlane::Helpers.android_create_keystore(android_keystore_path)
end
if ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"].nil?
ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"] = Fastlane::Helpers.android_create_play_store_key(android_play_store_json_key_path)
end
end
required_env_vars = [
@@ -332,11 +332,13 @@ platform :android do
"ANDROID_KEY_PASSWORD",
"ANDROID_PACKAGE_NAME",
]
# Only require JSON key path when not running in CI (local development)
required_env_vars << "ANDROID_PLAY_STORE_JSON_KEY_PATH" if local_development
Fastlane::Helpers.verify_env_vars(required_env_vars)
if local_development && version_bump != "skip"
Fastlane::Helpers.bump_local_build_number("android")
end
# Read version code from version.json (already set by CI or local version-manager.cjs)
version_code = Fastlane::Helpers.get_android_build_number
UI.message("📦 Using Android build number: #{version_code}")
@@ -353,13 +355,6 @@ platform :android do
target_platform = options[:track] == "production" ? "Google Play" : "Internal Testing"
should_upload = Fastlane::Helpers.should_upload_app(target_platform)
# Validate JSON key only in local development; CI uses Workload Identity Federation (ADC)
if local_development
validate_play_store_json_key(
json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"],
)
end
Fastlane::Helpers.with_retry(max_retries: 3, delay: 10) do
gradle(
task: "clean bundleRelease --stacktrace --info",
@@ -376,6 +371,9 @@ platform :android do
if test_mode || skip_upload
if skip_upload
UI.important("🔨 BUILD ONLY: Skipping Play Store upload")
if local_development
UI.important("📦 Manual upload required: #{android_aab_path}")
end
else
UI.important("🧪 TEST MODE: Skipping Play Store upload")
end

View File

@@ -44,6 +44,20 @@ module Fastlane
data["android"]["build"]
end
def bump_local_build_number(platform)
unless %w[ios android].include?(platform)
UI.user_error!("Invalid platform: #{platform}. Must be 'ios' or 'android'")
end
data = read_version_file
data[platform]["build"] += 1
write_version_file(data)
UI.success("Bumped #{platform} build number to #{data[platform]["build"]}")
data[platform]["build"]
end
def verify_ci_version_match
# Verify that versions were pre-set by CI
unless ENV["CI_VERSION"] && ENV["CI_IOS_BUILD"] && ENV["CI_ANDROID_BUILD"]

View File

@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.9.7</string>
<string>2.9.11</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -6,6 +6,8 @@ PODS:
- AppAuth/ExternalUserAgent (2.0.0):
- AppAuth/Core
- boost (1.84.0)
- BVLinearGradient (2.8.3):
- React-Core
- DoubleConversion (1.1.6)
- fast_float (6.1.4)
- FBLazyVector (0.76.9)
@@ -1512,7 +1514,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-compat (2.23.0):
- react-native-compat (2.23.1):
- DoubleConversion
- glog
- hermes-engine
@@ -2174,7 +2176,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- segment-analytics-react-native (2.21.3):
- segment-analytics-react-native (2.21.4):
- React-Core
- sovran-react-native
- Sentry/HybridSDK (8.53.2)
@@ -2187,6 +2189,7 @@ PODS:
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
@@ -2323,6 +2326,8 @@ SPEC REPOS:
EXTERNAL SOURCES:
boost:
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
BVLinearGradient:
:path: "../node_modules/react-native-linear-gradient"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
fast_float:
@@ -2525,6 +2530,7 @@ CHECKOUT OPTIONS:
SPEC CHECKSUMS:
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
boost: 1dca942403ed9342f98334bf4c3621f011aa7946
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
@@ -2587,7 +2593,7 @@ SPEC CHECKSUMS:
react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d
react-native-blur: 6334d934a9b5e67718b8f5725c44cc0a12946009
react-native-cloud-storage: 8d89f2bc574cf11068dfd90933905974087fb9e9
react-native-compat: 44e82a19b6130e3965d6c8ff37dbc1546d477f0f
react-native-compat: b80530ebcd3d574be5dd99cb27b984a17c119abc
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-nfc-manager: 66a00e5ddab9704efebe19d605b1b8afb0bb1bd7
@@ -2636,7 +2642,7 @@ SPEC CHECKSUMS:
RNScreens: 806e1449a8ec63c2a4e4cf8a63cc80203ccda9b8
RNSentry: 6ad982be2c8e32dab912afb4132b6a0d88484ea0
RNSVG: e1cf5a9a5aa12c69f2ec47031defbd87ae7fb697
segment-analytics-react-native: a0c29c75ede1989118b50cac96b9495ea5c91a1d
segment-analytics-react-native: 0eae155b0e9fa560fa6b17d78941df64537c35b7
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
sovran-react-native: a3ad3f8ff90c2002b2aa9790001a78b0b0a38594

View File

@@ -546,7 +546,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.7;
MARKETING_VERSION = 2.9.11;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -686,7 +686,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.7;
MARKETING_VERSION = 2.9.11;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View File

@@ -100,6 +100,7 @@ jest.mock('react-native', () => {
get NativeModules() {
return global.NativeModules || {};
},
useColorScheme: jest.fn(() => 'light'),
NativeEventEmitter: jest.fn().mockImplementation(nativeModule => {
return {
addListener: jest.fn(),
@@ -110,10 +111,15 @@ jest.mock('react-native', () => {
}),
PixelRatio: mockPixelRatio,
Dimensions: {
get: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
})),
get: jest.fn(dimension => {
const dimensions = {
window: { width: 375, height: 667, scale: 2, fontScale: 1 },
screen: { width: 375, height: 667, scale: 2, fontScale: 1 },
};
return dimension ? dimensions[dimension] : dimensions;
}),
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
removeEventListener: jest.fn(),
},
Linking: {
getInitialURL: jest.fn().mockResolvedValue(null),
@@ -139,6 +145,7 @@ jest.mock('react-native', () => {
ScrollView: 'ScrollView',
TouchableOpacity: 'TouchableOpacity',
TouchableHighlight: 'TouchableHighlight',
Pressable: 'Pressable',
Image: 'Image',
ActivityIndicator: 'ActivityIndicator',
SafeAreaView: 'SafeAreaView',
@@ -273,10 +280,15 @@ jest.mock(
Version: 14,
},
Dimensions: {
get: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
})),
get: jest.fn(dimension => {
const dimensions = {
window: { width: 375, height: 667, scale: 2, fontScale: 1 },
screen: { width: 375, height: 667, scale: 2, fontScale: 1 },
};
return dimension ? dimensions[dimension] : dimensions;
}),
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
removeEventListener: jest.fn(),
},
StyleSheet: {
create: jest.fn(styles => styles),
@@ -359,15 +371,18 @@ jest.mock(
'../packages/mobile-sdk-alpha/node_modules/react-native/Libraries/Utilities/Dimensions',
() => ({
getConstants: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
window: { width: 375, height: 667, scale: 2, fontScale: 1 },
screen: { width: 375, height: 667, scale: 2, fontScale: 1 },
})),
set: jest.fn(),
get: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
})),
addEventListener: jest.fn(),
get: jest.fn(dimension => {
const dimensions = {
window: { width: 375, height: 667, scale: 2, fontScale: 1 },
screen: { width: 375, height: 667, scale: 2, fontScale: 1 },
};
return dimension ? dimensions[dimension] : dimensions;
}),
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
removeEventListener: jest.fn(),
}),
{ virtual: true },
@@ -550,8 +565,14 @@ jest.mock(
{ virtual: true },
);
// Mock the hooks subpath from mobile-sdk-alpha
jest.mock('@selfxyz/mobile-sdk-alpha/hooks', () => ({
useSafeBottomPadding: jest.fn((basePadding = 20) => basePadding + 50),
}));
// Mock problematic mobile-sdk-alpha components that use React Native StyleSheet
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
// Override only the specific mocks we need
NFCScannerScreen: jest.fn(() => null),
SelfClientProvider: jest.fn(({ children }) => children),
useSelfClient: jest.fn(() => {
@@ -1031,6 +1052,9 @@ jest.mock('@react-native-clipboard/clipboard', () => ({
hasString: jest.fn().mockResolvedValue(false),
}));
// Mock react-native-linear-gradient
jest.mock('react-native-linear-gradient', () => 'LinearGradient');
// Mock react-native-localize
jest.mock('react-native-localize', () => ({
getLocales: jest.fn().mockReturnValue([

View File

@@ -409,6 +409,7 @@ const config = {
'react-native-reanimated',
'@react-native-masked-view/masked-view',
'@react-native-firebase/analytics',
'react-native-b4a',
];
if (optionalPeerDependencies.includes(moduleName)) {

View File

@@ -1,6 +1,6 @@
{
"name": "@selfxyz/mobile-app",
"version": "2.9.7",
"version": "2.9.11",
"private": true,
"type": "module",
"scripts": {
@@ -148,12 +148,13 @@
"react-native-cloud-storage": "^2.2.2",
"react-native-device-info": "^14.0.4",
"react-native-dotenv": "^3.4.11",
"react-native-edge-to-edge": "^1.6.2",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "2.19.0",
"react-native-get-random-values": "^1.11.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-inappbrowser-reborn": "^3.7.0",
"react-native-keychain": "^10.0.0",
"react-native-linear-gradient": "^2.8.3",
"react-native-localize": "^3.5.2",
"react-native-logs": "^5.3.0",
"react-native-nfc-manager": "3.16.3",

View File

@@ -441,6 +441,14 @@ function displayWarningsAndGitStatus() {
function displayFullConfirmation(platform, versions, deploymentMethod) {
displayDeploymentHeader(platform);
displayDeploymentMethod(deploymentMethod);
if (
deploymentMethod === DEPLOYMENT_METHODS.LOCAL_FASTLANE &&
(platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH)
) {
console.log(
`${CONSOLE_SYMBOLS.WARNING} Local Android uploads are disabled. You'll need to manually upload the AAB in Play Console.`,
);
}
displayPlatformVersions(platform, versions);
displayWarningsAndGitStatus();
}

184
app/scripts/setup-macos.sh Executable file
View File

@@ -0,0 +1,184 @@
#!/bin/bash
# Self.xyz macOS Development Environment Setup
# Usage: ./scripts/setup-macos.sh [--check-only] [--yes]
set -e
# Config
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
APP_DIR="$REPO_ROOT/app"
RUBY_VERSION=$(cat "$APP_DIR/.ruby-version" 2>/dev/null | tr -d '[:space:]')
NODE_MAJOR=22
# Args (can be overridden interactively)
CHECK_ONLY=false; AUTO_YES=false
for arg in "$@"; do
case $arg in
--check-only) CHECK_ONLY=true ;;
--yes|-y) AUTO_YES=true ;;
--interactive|-i) ;; # default behavior
esac
done
# Colors
R='\033[31m' G='\033[32m' Y='\033[33m' B='\033[34m' C='\033[36m' NC='\033[0m' BOLD='\033[1m'
ok() { echo -e "${G}${NC} $1"; }
err() { echo -e "${R}${NC} $1"; }
warn() { echo -e "${Y}${NC} $1"; }
info() { echo -e "${C}${NC} $1"; }
confirm() {
$AUTO_YES && return 0
read -p "$1 [Y/n] " -n 1 -r; echo
[[ ! $REPLY =~ ^[Nn]$ ]]
}
# Check functions - return "ok:version" or "missing" or "wrong:version"
chk_brew() { command -v brew &>/dev/null && echo "ok:$(brew --version | head -1 | cut -d' ' -f2)" || echo "missing"; }
chk_nvm() { [[ -s "$HOME/.nvm/nvm.sh" ]] && echo "ok" || echo "missing"; }
chk_node() { command -v node &>/dev/null && { v=$(node -v 2>/dev/null | tr -d 'v'); [[ -n "$v" && "${v%%.*}" -ge $NODE_MAJOR ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"; }
chk_watch() { command -v watchman &>/dev/null && echo "ok:$(watchman --version 2>/dev/null)" || echo "missing"; }
chk_rbenv() { command -v rbenv &>/dev/null && echo "ok" || echo "missing"; }
chk_ruby() { command -v ruby &>/dev/null && { v=$(ruby -v 2>/dev/null | cut -d' ' -f2); [[ "$v" == "$RUBY_VERSION"* ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"; }
chk_pods() { command -v pod &>/dev/null && echo "ok:$(pod --version 2>/dev/null)" || echo "missing"; }
chk_bundler() { command -v bundle &>/dev/null && echo "ok" || echo "missing"; }
chk_java() { command -v java &>/dev/null && { v=$(java -version 2>&1 | head -1 | cut -d'"' -f2); [[ "$v" == 17* ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"; }
chk_xcode() { xcode-select -p &>/dev/null && [[ "$(xcode-select -p)" == *Xcode.app* ]] && echo "ok" || echo "missing"; }
chk_studio() { [[ -d "/Applications/Android Studio.app" ]] && echo "ok" || echo "missing"; }
chk_sdk() { [[ -d "${ANDROID_HOME:-$HOME/Library/Android/sdk}" ]] && echo "ok" || echo "missing"; }
chk_ndk() { [[ -d "${ANDROID_HOME:-$HOME/Library/Android/sdk}/ndk/27.0.12077973" ]] && echo "ok" || echo "missing"; }
chk_shell() { local rc=~/.zshrc; [[ "$SHELL" == *bash* ]] && rc=~/.bashrc; grep -q "ANDROID_HOME" "$rc" 2>/dev/null && echo "ok" || echo "missing"; }
# Install functions
inst_brew() { /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; }
inst_nvm() { curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash; export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; }
inst_node() { export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; nvm install $NODE_MAJOR; }
inst_watch() { brew install watchman; }
inst_rbenv() { brew install rbenv; eval "$(rbenv init -)"; }
inst_ruby() { eval "$(rbenv init -)" 2>/dev/null; rbenv install "$RUBY_VERSION"; rbenv rehash; }
inst_pods() { gem install cocoapods; }
inst_bundler() { gem install bundler; }
inst_java() { brew install openjdk@17; sudo ln -sfn "$(brew --prefix openjdk@17)/libexec/openjdk.jdk" /Library/Java/JavaVirtualMachines/openjdk-17.jdk 2>/dev/null || true; }
inst_shell() {
local rc=~/.zshrc
[[ "$SHELL" == *bash* ]] && rc=~/.bashrc
# Check if already configured
if grep -q "# Self.xyz Dev Environment" "$rc" 2>/dev/null; then
ok "Shell already configured"
return 0
fi
info "Adding environment to $rc..."
cat >> "$rc" << 'EOF'
# Self.xyz Dev Environment
export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || echo "")
export ANDROID_HOME=~/Library/Android/sdk
export ANDROID_SDK_ROOT=$ANDROID_HOME
export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools
command -v rbenv &>/dev/null && eval "$(rbenv init -)"
export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
EOF
ok "Shell configured. Run: source $rc"
}
# Main
echo -e "\n${C}${BOLD}═══ Self.xyz macOS Setup ═══${NC}\n"
# Interactive mode selection (if no flags provided)
if [[ "$CHECK_ONLY" == false && "$AUTO_YES" == false ]]; then
echo "How would you like to run the setup?"
echo ""
echo " 1) Check only - just show what's installed/missing"
echo " 2) Interactive setup - check and confirm before installing (recommended)"
echo " 3) Auto-install - install everything without prompts"
echo ""
read -p "Enter choice [1]: " -n 1 -r choice
echo ""
case $choice in
2) ;; # interactive setup
3) AUTO_YES=true ;;
*) CHECK_ONLY=true ;; # default: check only
esac
echo ""
fi
# Define deps: name|check_fn|install_fn|manual_msg
DEPS=(
"Homebrew|chk_brew|inst_brew|"
"nvm|chk_nvm|inst_nvm|"
"Node.js $NODE_MAJOR|chk_node|inst_node|"
"Watchman|chk_watch|inst_watch|"
"rbenv|chk_rbenv|inst_rbenv|"
"Ruby $RUBY_VERSION|chk_ruby|inst_ruby|"
"CocoaPods|chk_pods|inst_pods|"
"Bundler|chk_bundler|inst_bundler|"
"Java 17|chk_java|inst_java|"
"Xcode|chk_xcode||Install from App Store: https://apps.apple.com/app/xcode/id497799835"
"Android Studio|chk_studio||Download: https://developer.android.com/studio"
"Android SDK|chk_sdk||Open Android Studio → SDK Manager"
"Android NDK|chk_ndk||SDK Manager → SDK Tools → NDK 27.0.12077973"
"Shell Config|chk_shell|inst_shell|"
)
MISSING=()
MANUAL=()
info "Checking dependencies...\n"
for dep in "${DEPS[@]}"; do
IFS='|' read -r name chk inst manual <<< "$dep"
status=$($chk)
if [[ "$status" == ok* ]]; then
ver="${status#ok:}"; [[ -n "$ver" && "$ver" != "ok" ]] && ok "$name ($ver)" || ok "$name"
elif [[ -n "$manual" ]]; then
warn "$name - manual install required"
MANUAL+=("$name|$manual")
else
err "$name - not installed"
[[ -n "$inst" ]] && MISSING+=("$name|$inst")
fi
done
$CHECK_ONLY && {
[[ ${#MANUAL[@]} -gt 0 ]] && { echo -e "\n${Y}Manual installs:${NC}"; for m in "${MANUAL[@]}"; do IFS='|' read -r n msg <<< "$m"; echo " $n: $msg"; done; }
exit 0
}
# Install missing
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo -e "\n${B}Missing:${NC} $(printf '%s\n' "${MISSING[@]}" | cut -d'|' -f1 | tr '\n' ', ' | sed 's/, $//')"
if confirm "Install all?"; then
for m in "${MISSING[@]}"; do
IFS='|' read -r name fn <<< "$m"
info "Installing $name..."
$fn && ok "$name installed" || err "Failed: $name"
done
fi
fi
# Manual instructions
[[ ${#MANUAL[@]} -gt 0 ]] && { echo -e "\n${Y}Manual installs needed:${NC}"; for m in "${MANUAL[@]}"; do IFS='|' read -r n msg <<< "$m"; echo " $n: $msg"; done; }
# Yarn install
echo ""
if confirm "Run 'yarn install' in repo root?"; then
info "Running yarn install..."
set +e # Temporarily disable exit-on-error
cd "$REPO_ROOT" && yarn install
yarn_exit=$?
set -e # Re-enable exit-on-error
if [[ $yarn_exit -eq 0 ]]; then
ok "Done!"
else
err "Yarn install failed (exit code: $yarn_exit)"
warn "This may be due to network issues or registry timeouts"
info "Try running manually: cd $REPO_ROOT && yarn install"
fi
fi
echo -e "\n${G}${BOLD}Setup complete!${NC} Open a new terminal, then: cd $APP_DIR && yarn ios\n"

View File

@@ -0,0 +1,3 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.0459 12.3135C0.360352 11.6221 0.0146484 10.9219 0.00878906 10.2129C0.00292969 9.50391 0.342773 8.80664 1.02832 8.12109L8.12988 1.02832C8.80957 0.342773 9.50391 0.00292969 10.2129 0.00878906C10.9277 0.0146484 11.6309 0.360352 12.3223 1.0459L19.3799 8.10352C20.0654 8.79492 20.4111 9.49805 20.417 10.2129C20.4229 10.9219 20.083 11.6191 19.3975 12.3047L12.3047 19.3975C11.6191 20.083 10.9219 20.4229 10.2129 20.417C9.50391 20.4111 8.80371 20.0654 8.1123 19.3799L1.0459 12.3135ZM9.24609 14.5723C9.42188 14.5723 9.58301 14.5312 9.72949 14.4492C9.88184 14.3613 10.0107 14.2412 10.1162 14.0889L14.2207 7.74316C14.2852 7.64355 14.3379 7.53809 14.3789 7.42676C14.4258 7.31543 14.4492 7.2041 14.4492 7.09277C14.4492 6.84668 14.3555 6.64746 14.168 6.49512C13.9863 6.34277 13.7783 6.2666 13.5439 6.2666C13.2334 6.2666 12.9727 6.43359 12.7617 6.76758L9.21973 12.4277L7.59375 10.3887C7.47656 10.2422 7.35938 10.1396 7.24219 10.0811C7.125 10.0166 6.99316 9.98438 6.84668 9.98438C6.60059 9.98438 6.39258 10.0723 6.22266 10.248C6.05273 10.418 5.96777 10.626 5.96777 10.8721C5.96777 10.9893 5.98828 11.1035 6.0293 11.2148C6.07617 11.3203 6.14062 11.4287 6.22266 11.54L8.34082 14.0889C8.46973 14.2529 8.60742 14.376 8.75391 14.458C8.90039 14.5342 9.06445 14.5723 9.24609 14.5723Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -5,45 +5,24 @@
import React from 'react';
import { XStack, YStack } from 'tamagui';
import type { Country3LetterCode } from '@selfxyz/common/constants';
import { countryCodes } from '@selfxyz/common/constants';
import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils';
import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
import { slate200, slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import CheckMark from '@/assets/icons/checkmark.svg';
import {
getDisclosureText,
ORDERED_DISCLOSURE_KEYS,
} from '@/utils/disclosureUtils';
interface DisclosureProps {
disclosures: SelfAppDisclosureConfig;
}
function listToString(list: string[]): string {
if (list.length === 1) {
return list[0];
} else if (list.length === 2) {
return list.join(' nor ');
}
return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`;
}
export default function Disclosures({ disclosures }: DisclosureProps) {
// Define the order in which disclosures should appear.
const ORDERED_KEYS: Array<keyof SelfAppDisclosureConfig> = [
'issuing_state',
'name',
'passport_number',
'nationality',
'date_of_birth',
'gender',
'expiry_date',
'ofac',
'excludedCountries',
'minimumAge',
] as const;
return (
<YStack>
{ORDERED_KEYS.map(key => {
{ORDERED_DISCLOSURE_KEYS.map(key => {
const isEnabled = disclosures[key];
if (
!isEnabled ||
@@ -52,53 +31,17 @@ export default function Disclosures({ disclosures }: DisclosureProps) {
return null;
}
let text = '';
switch (key) {
case 'ofac':
text = 'I am not on the OFAC sanction list';
break;
case 'excludedCountries':
text = `I am not a citizen of the following countries: ${countriesToSentence(
(disclosures.excludedCountries as Country3LetterCode[]) || [],
)}`;
break;
case 'minimumAge':
text = `Age is over ${disclosures.minimumAge}`;
break;
case 'name':
text = 'Name';
break;
case 'passport_number':
text = 'Passport Number';
break;
case 'date_of_birth':
text = 'Date of Birth';
break;
case 'gender':
text = 'Gender';
break;
case 'expiry_date':
text = 'Passport Expiry Date';
break;
case 'issuing_state':
text = 'Issuing State';
break;
case 'nationality':
text = 'Nationality';
break;
default:
return null;
const text = getDisclosureText(key, disclosures);
if (!text) {
return null;
}
return <DisclosureItem key={key} text={text} />;
})}
</YStack>
);
}
function countriesToSentence(countries: Array<Country3LetterCode>): string {
return listToString(countries.map(country => countryCodes[country]));
}
interface DisclosureItemProps {
text: string;
}

View File

@@ -0,0 +1,132 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Pressable } from 'react-native';
import { Separator, Text, View, XStack, YStack } from 'tamagui';
import { Check } from '@tamagui/lucide-icons';
import {
black,
green500,
green600,
iosSeparator,
slate200,
slate300,
slate400,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
export interface IDSelectorItemProps {
documentName: string;
state: IDSelectorState;
onPress?: () => void;
disabled?: boolean;
isLastItem?: boolean;
testID?: string;
}
export type IDSelectorState = 'active' | 'verified' | 'expired' | 'mock';
function getSubtitleText(state: IDSelectorState): string {
switch (state) {
case 'active':
return 'Currently active';
case 'verified':
return 'Verified ID';
case 'expired':
return 'Expired';
case 'mock':
return 'Testing document';
}
}
function getSubtitleColor(state: IDSelectorState): string {
switch (state) {
case 'active':
return green600;
case 'verified':
return slate400;
case 'expired':
return slate400;
case 'mock':
return slate400;
}
}
export const IDSelectorItem: React.FC<IDSelectorItemProps> = ({
documentName,
state,
onPress,
disabled,
isLastItem,
testID,
}) => {
const isDisabled = disabled || isDisabledState(state);
const isActive = state === 'active';
const subtitleText = getSubtitleText(state);
const subtitleColor = getSubtitleColor(state);
const textColor = isDisabled ? slate400 : black;
// Determine circle color based on state
const circleColor = isDisabled ? slate200 : slate300;
return (
<>
<Pressable
onPress={isDisabled ? undefined : onPress}
disabled={isDisabled}
testID={testID}
>
<XStack
paddingVertical={6}
paddingHorizontal={0}
alignItems="center"
gap={13}
opacity={isDisabled ? 0.6 : 1}
>
{/* Radio button indicator */}
<View
width={29}
height={24}
alignItems="center"
justifyContent="center"
>
<View
width={24}
height={24}
borderRadius={12}
borderWidth={isActive ? 0 : 2}
borderColor={circleColor}
backgroundColor={isActive ? green500 : 'transparent'}
alignItems="center"
justifyContent="center"
>
{isActive && <Check size={16} color="white" strokeWidth={3} />}
</View>
</View>
{/* Document info */}
<YStack flex={1} gap={2} paddingVertical={8} paddingBottom={9}>
<Text
fontFamily={dinot}
fontSize={18}
fontWeight="500"
color={textColor}
>
{documentName}
</Text>
<Text fontFamily={dinot} fontSize={14} color={subtitleColor}>
{subtitleText}
</Text>
</YStack>
</XStack>
</Pressable>
{!isLastItem && <Separator borderColor={iosSeparator} />}
</>
);
};
export function isDisabledState(state: IDSelectorState): boolean {
return state === 'expired';
}

View File

@@ -0,0 +1,174 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Button, ScrollView, Sheet, Text, View, XStack, YStack } from 'tamagui';
import {
black,
blue600,
slate200,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import type { IDSelectorState } from '@/components/documents/IDSelectorItem';
import {
IDSelectorItem,
isDisabledState,
} from '@/components/documents/IDSelectorItem';
export interface IDSelectorDocument {
id: string;
name: string;
state: IDSelectorState;
}
export interface IDSelectorSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
documents: IDSelectorDocument[];
selectedId?: string;
onSelect: (documentId: string) => void;
onDismiss: () => void;
onApprove: () => void;
testID?: string;
}
export const IDSelectorSheet: React.FC<IDSelectorSheetProps> = ({
open,
onOpenChange,
documents,
selectedId,
onSelect,
onDismiss,
onApprove,
testID = 'id-selector-sheet',
}) => {
const bottomPadding = useSafeBottomPadding(16);
// Check if the selected document is valid (not expired or unregistered)
const selectedDoc = documents.find(d => d.id === selectedId);
const canApprove = selectedDoc && !isDisabledState(selectedDoc.state);
return (
<Sheet
modal
open={open}
onOpenChange={onOpenChange}
snapPoints={[55]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay
backgroundColor="rgba(0, 0, 0, 0.5)"
animation="lazy"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
testID={testID}
>
<YStack padding={20} paddingTop={30} flex={1}>
{/* Header */}
<Text
fontSize={20}
fontFamily={dinot}
fontWeight="500"
color={black}
marginBottom={32}
>
Select an ID
</Text>
{/* Document List Container with border radius */}
<View
flex={1}
backgroundColor={white}
borderRadius={10}
overflow="hidden"
marginBottom={32}
>
<ScrollView
flex={1}
showsVerticalScrollIndicator={false}
testID={`${testID}-list`}
>
{documents.map((doc, index) => {
const isSelected = doc.id === selectedId;
// Don't override to 'active' if the document is in a disabled state
const itemState: IDSelectorState =
isSelected && !isDisabledState(doc.state)
? 'active'
: doc.state;
return (
<IDSelectorItem
key={doc.id}
documentName={doc.name}
state={itemState}
onPress={() => onSelect(doc.id)}
isLastItem={index === documents.length - 1}
testID={`${testID}-item-${doc.id}`}
/>
);
})}
</ScrollView>
</View>
{/* Footer Buttons */}
<XStack gap={10} paddingBottom={bottomPadding}>
<Button
flex={1}
backgroundColor={white}
borderWidth={1}
borderColor={slate200}
borderRadius={4}
height={48}
alignItems="center"
justifyContent="center"
onPress={onDismiss}
testID={`${testID}-dismiss-button`}
pressStyle={{ opacity: 0.7 }}
>
<Text
fontFamily={dinot}
fontSize={18}
fontWeight="500"
color={black}
>
Dismiss
</Text>
</Button>
<Button
flex={1}
backgroundColor={blue600}
borderRadius={4}
height={48}
alignItems="center"
justifyContent="center"
onPress={onApprove}
disabled={!canApprove}
opacity={canApprove ? 1 : 0.5}
testID={`${testID}-select-button`}
pressStyle={{ opacity: 0.7 }}
>
<Text
fontFamily={dinot}
fontSize={18}
fontWeight="500"
color={white}
>
Select
</Text>
</Button>
</XStack>
</YStack>
</Sheet.Frame>
</Sheet>
);
};

View File

@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export type {
IDSelectorDocument,
IDSelectorSheetProps,
} from '@/components/documents/IDSelectorSheet';
export type {
IDSelectorItemProps,
IDSelectorState,
} from '@/components/documents/IDSelectorItem';
export {
IDSelectorItem,
isDisabledState,
} from '@/components/documents/IDSelectorItem';
export { IDSelectorSheet } from '@/components/documents/IDSelectorSheet';

View File

@@ -33,6 +33,7 @@ interface RightActionProps extends ViewProps {
interface NavBarTitleProps extends TextProps {
children?: React.ReactNode;
size?: 'large' | undefined;
color?: string;
}
export const LeftAction: React.FC<LeftActionProps> = ({
@@ -84,13 +85,20 @@ export const LeftAction: React.FC<LeftActionProps> = ({
return <View {...props}>{children}</View>;
};
const NavBarTitle: React.FC<NavBarTitleProps> = ({ children, ...props }) => {
const NavBarTitle: React.FC<NavBarTitleProps> = ({
children,
color,
style,
...props
}) => {
if (!children) {
return null;
}
return typeof children === 'string' ? (
<Title {...props}>{children}</Title>
<Title style={[color ? { color } : undefined, style]} {...props}>
{children}
</Title>
) : (
children
);

View File

@@ -18,6 +18,8 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => {
const { options } = props;
const headerStyle = (options.headerStyle || {}) as ViewStyle;
const insets = useSafeAreaInsets();
const headerTitleStyle = (options.headerTitleStyle || {}) as TextStyle;
return (
<NavBar.Container
gap={14}
@@ -26,8 +28,7 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => {
paddingBottom={20}
backgroundColor={headerStyle.backgroundColor as string}
barStyle={
options.headerTintColor === white ||
(options.headerTitleStyle as TextStyle)?.color === white
options.headerTintColor === white || headerTitleStyle?.color === white
? 'light'
: 'dark'
}
@@ -40,9 +41,12 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => {
buttonTap();
goBack();
}}
{...(options.headerTitleStyle as ViewStyle)}
color={options.headerTintColor as string}
/>
<NavBar.Title {...(options.headerTitleStyle as ViewStyle)}>
<NavBar.Title
color={headerTitleStyle.color as string}
style={headerTitleStyle}
>
{props.options.title}
</NavBar.Title>
</NavBar.Container>

View File

@@ -56,7 +56,7 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => {
try {
Clipboard.setString('');
} catch {}
props.navigation.navigate('Prove');
props.navigation.navigate('ProvingScreenRouter');
} catch (error) {
console.error('Error consuming token:', error);
if (

View File

@@ -33,7 +33,7 @@ import { appsUrl } from '@/consts/links';
import { useIncomingPoints, usePoints } from '@/hooks/usePoints';
import { usePointsGuardrail } from '@/hooks/usePointsGuardrail';
import type { RootStackParamList } from '@/navigation';
import analytics from '@/services/analytics';
import { trackScreenView } from '@/services/analytics';
import {
isTopicSubscribed,
requestNotificationPermission,
@@ -70,7 +70,6 @@ const Points: React.FC = () => {
// Track NavBar view analytics
useFocusEffect(
React.useCallback(() => {
const { trackScreenView } = analytics();
trackScreenView('Points NavBar', {
screenName: 'Points NavBar',
});

View File

@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useMemo } from 'react';
import {
ActivityIndicator,
Dimensions,
Pressable,
StyleSheet,
} from 'react-native';
import { Text, View, XStack } from 'tamagui';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import { ChevronUpDownIcon } from '@/components/proof-request/icons';
export interface BottomActionBarProps {
selectedDocumentName: string;
onDocumentSelectorPress: () => void;
onApprovePress: () => void;
approveDisabled?: boolean;
approving?: boolean;
testID?: string;
}
/**
* Bottom action bar with document selector and approve button.
* Matches Figma design 15234:9322.
*/
export const BottomActionBar: React.FC<BottomActionBarProps> = ({
selectedDocumentName,
onDocumentSelectorPress,
onApprovePress,
approveDisabled = false,
approving = false,
testID = 'bottom-action-bar',
}) => {
// Reduce top padding to balance with safe area bottom padding
// The safe area hook adds significant padding on small screens for system UI
const topPadding = 8;
// Calculate dynamic bottom padding based on screen height
// Scales proportionally to better center the select box beneath the disclosure list
const { height: screenHeight } = Dimensions.get('window');
const basePadding = 12;
// Get safe area padding (handles small screens < 900px with extra padding)
const safeAreaPadding = useSafeBottomPadding(basePadding);
// Dynamic padding calculation:
// - Start with safe area padding (includes base + small screen adjustment)
// - Add additional padding that scales with screen height
// - Formula: safeAreaPadding + (screenHeight - 800) * 0.12
// - This provides base padding, safe area handling, plus 0-50px extra on larger screens
// - The multiplier (0.12) ensures smooth scaling across different screen sizes
const dynamicPadding = useMemo(() => {
const heightMultiplier = Math.max(0, (screenHeight - 800) * 0.12);
return Math.round(safeAreaPadding + heightMultiplier);
}, [screenHeight, safeAreaPadding]);
const bottomPadding = dynamicPadding;
return (
<View
backgroundColor={proofRequestColors.white}
paddingHorizontal={16}
paddingTop={topPadding}
paddingBottom={bottomPadding}
testID={testID}
>
<XStack gap={12}>
{/* Document Selector Button */}
<Pressable
onPress={onDocumentSelectorPress}
style={({ pressed }) => [
styles.documentButton,
pressed && styles.documentButtonPressed,
]}
testID={`${testID}-document-selector`}
>
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal={12}
paddingVertical={12}
>
<Text
fontFamily={dinot}
fontSize={18}
color={proofRequestColors.slate900}
numberOfLines={1}
>
{selectedDocumentName}
</Text>
<View marginLeft={8}>
<ChevronUpDownIcon
size={20}
color={proofRequestColors.slate400}
/>
</View>
</XStack>
</Pressable>
{/* Select Button */}
<Pressable
onPress={onApprovePress}
disabled={approveDisabled || approving}
style={({ pressed }) => [
styles.approveButton,
(approveDisabled || approving) && styles.approveButtonDisabled,
pressed &&
!approveDisabled &&
!approving &&
styles.approveButtonPressed,
]}
testID={`${testID}-approve`}
>
<View
alignItems="center"
justifyContent="center"
paddingHorizontal={12}
paddingVertical={12}
>
{approving ? (
<ActivityIndicator
color={proofRequestColors.white}
size="small"
/>
) : (
<Text
fontFamily={dinot}
fontSize={18}
color={proofRequestColors.white}
textAlign="center"
>
Select
</Text>
)}
</View>
</Pressable>
</XStack>
</View>
);
};
const styles = StyleSheet.create({
documentButton: {
backgroundColor: proofRequestColors.white,
borderWidth: 1,
borderColor: proofRequestColors.slate200,
borderRadius: 4,
},
documentButtonPressed: {
backgroundColor: proofRequestColors.slate100,
},
approveButton: {
flex: 1,
backgroundColor: proofRequestColors.blue600,
borderRadius: 4,
},
approveButtonDisabled: {
opacity: 0.5,
},
approveButtonPressed: {
backgroundColor: proofRequestColors.blue700,
},
});

View File

@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { View } from 'tamagui';
import { HeldPrimaryButtonProveScreen } from '@selfxyz/mobile-sdk-alpha/components';
import { proofRequestColors } from '@/components/proof-request/designTokens';
export interface BottomVerifyBarProps {
onVerify: () => void;
selectedAppSessionId: string | undefined | null;
hasScrolledToBottom: boolean;
isScrollable: boolean;
isReadyToProve: boolean;
isDocumentExpired: boolean;
testID?: string;
}
export const BottomVerifyBar: React.FC<BottomVerifyBarProps> = ({
onVerify,
selectedAppSessionId,
hasScrolledToBottom,
isScrollable,
isReadyToProve,
isDocumentExpired,
testID = 'bottom-verify-bar',
}) => {
const insets = useSafeAreaInsets();
return (
<View
backgroundColor={proofRequestColors.white}
paddingHorizontal={16}
paddingTop={12}
paddingBottom={Math.max(insets.bottom, 12) + 20}
testID={testID}
>
<HeldPrimaryButtonProveScreen
onVerify={onVerify}
selectedAppSessionId={selectedAppSessionId}
hasScrolledToBottom={hasScrolledToBottom}
isScrollable={isScrollable}
isReadyToProve={isReadyToProve}
isDocumentExpired={isDocumentExpired}
/>
</View>
);
};

View File

@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Pressable } from 'react-native';
import { Text, View, XStack } from 'tamagui';
import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import { WalletIcon } from '@/components/proof-request/icons';
export interface ConnectedWalletBadgeProps {
address: string;
userIdType?: string;
onToggle?: () => void;
testID?: string;
}
/**
* Blue badge showing connected wallet address.
* Matches Figma design 15234:9295 (icon).
*/
export const ConnectedWalletBadge: React.FC<ConnectedWalletBadgeProps> = ({
address,
userIdType,
onToggle,
testID = 'connected-wallet-badge',
}) => {
const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID';
const content = (
<XStack
backgroundColor={proofRequestColors.blue600}
paddingLeft={10}
paddingRight={20}
paddingVertical={12}
borderRadius={4}
alignItems="center"
gap={10}
testID={testID}
>
{/* Label with icon */}
<XStack
backgroundColor={proofRequestColors.blue700}
paddingHorizontal={6}
paddingVertical={4}
borderRadius={3}
alignItems="center"
gap={6}
>
<WalletIcon size={11} color={proofRequestColors.white} />
<Text
fontFamily={plexMono}
fontSize={12}
color={proofRequestColors.white}
textTransform="uppercase"
>
{label}
</Text>
</XStack>
{/* Address */}
<View flex={1}>
<Text
fontFamily={plexMono}
fontSize={12}
color={proofRequestColors.white}
textAlign="right"
testID={`${testID}-address`}
>
{truncateAddress(address)}
</Text>
</View>
</XStack>
);
if (onToggle) {
return (
<Pressable onPress={onToggle} testID={`${testID}-pressable`}>
{content}
</Pressable>
);
}
return content;
};
/**
* Truncates a wallet address for display.
* @example truncateAddress("0x1234567890abcdef1234567890abcdef12345678") // "0x12..5678"
*/
export function truncateAddress(
address: string,
startChars = 4,
endChars = 4,
): string {
if (address.length <= startChars + endChars + 2) {
return address;
}
return `${address.slice(0, startChars)}..${address.slice(-endChars)}`;
}

View File

@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Pressable } from 'react-native';
import { Text, View, XStack } from 'tamagui';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import {
FilledCircleIcon,
InfoCircleIcon,
} from '@/components/proof-request/icons';
export interface DisclosureItemProps {
text: string;
verified?: boolean;
onInfoPress?: () => void;
isLast?: boolean;
testID?: string;
}
/**
* Individual disclosure row with green checkmark and optional info button.
* Matches Figma design 15234:9267.
*/
export const DisclosureItem: React.FC<DisclosureItemProps> = ({
text,
verified = true,
onInfoPress,
isLast = false,
testID = 'disclosure-item',
}) => {
return (
<XStack
paddingVertical={16}
alignItems="center"
gap={10}
borderBottomWidth={isLast ? 0 : 1}
borderBottomColor={proofRequestColors.slate200}
testID={testID}
>
{/* Status Icon */}
<View width={20} alignItems="center" justifyContent="center">
<FilledCircleIcon
size={9}
color={
verified
? proofRequestColors.emerald500
: proofRequestColors.slate400
}
/>
</View>
{/* Disclosure Text */}
<View flex={1}>
<Text
fontFamily={dinot}
fontSize={12}
color={proofRequestColors.slate900}
textTransform="uppercase"
letterSpacing={0.48}
testID={`${testID}-text`}
>
{text}
</Text>
</View>
{/* Info Button */}
{onInfoPress && (
<Pressable
onPress={onInfoPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
testID={`${testID}-info-button`}
>
<View width={25} alignItems="center" justifyContent="center">
<InfoCircleIcon size={20} color={proofRequestColors.blue500} />
</View>
</Pressable>
)}
</XStack>
);
};

View File

@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Text, View, XStack } from 'tamagui';
import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import { DocumentIcon } from '@/components/proof-request/icons';
export interface ProofMetadataBarProps {
timestamp: string;
testID?: string;
}
/**
* Gray metadata bar showing "PROOFS REQUESTED" label and timestamp.
* Matches Figma design 15234:9281.
*/
export const ProofMetadataBar: React.FC<ProofMetadataBarProps> = ({
timestamp,
testID = 'proof-metadata-bar',
}) => {
return (
<View
backgroundColor={proofRequestColors.slate200}
paddingVertical={6}
borderBottomWidth={1}
borderBottomColor={proofRequestColors.slate200}
testID={testID}
>
<XStack gap={10} alignItems="center" justifyContent="center" width="100%">
{/* Icon + Label group */}
<XStack gap={6} alignItems="center">
<DocumentIcon size={14} color={proofRequestColors.slate400} />
<Text
fontFamily={plexMono}
fontSize={12}
fontWeight="500"
color={proofRequestColors.slate400}
textTransform="uppercase"
>
Proofs Requested
</Text>
</XStack>
{/* Dot separator */}
<Text
fontFamily={plexMono}
fontSize={12}
fontWeight="500"
color={proofRequestColors.slate400}
>
</Text>
{/* Timestamp */}
<Text
fontFamily={plexMono}
fontSize={12}
fontWeight="500"
color={proofRequestColors.slate400}
testID={`${testID}-timestamp`}
>
{timestamp}
</Text>
</XStack>
</View>
);
};
/**
* Formats a Date object to match the Figma timestamp format.
* @example formatTimestamp(new Date()) // "4/7/2025 11:44 AM"
*/
export function formatTimestamp(date: Date): string {
const month = date.getMonth() + 1;
const day = date.getDate();
const year = date.getFullYear();
const hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
const displayMinutes = minutes.toString().padStart(2, '0');
return `${month}/${day}/${year} ${displayHours}:${displayMinutes} ${ampm}`;
}

View File

@@ -0,0 +1,161 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useMemo } from 'react';
import type {
ImageSourcePropType,
LayoutChangeEvent,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView as ScrollViewType,
} from 'react-native';
import { ScrollView } from 'react-native';
import { Text, View } from 'tamagui';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import {
proofRequestColors,
proofRequestSpacing,
} from '@/components/proof-request/designTokens';
import {
formatTimestamp,
ProofMetadataBar,
} from '@/components/proof-request/ProofMetadataBar';
import { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader';
export interface ProofRequestCardProps {
logoSource: ImageSourcePropType | null;
appName: string;
appUrl: string | null;
documentType?: string;
timestamp?: Date;
children?: React.ReactNode;
connectedWalletBadge?: React.ReactNode;
testID?: string;
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
scrollViewRef?: React.RefObject<ScrollViewType>;
onContentSizeChange?: (width: number, height: number) => void;
onLayout?: (event: LayoutChangeEvent) => void;
initialScrollOffset?: number;
}
/**
* Main card container for proof request screens.
* Combines header, metadata bar, and content section.
* Matches Figma design 15234:9267.
*/
export const ProofRequestCard: React.FC<ProofRequestCardProps> = ({
logoSource,
appName,
appUrl,
documentType = '',
timestamp,
children,
connectedWalletBadge,
testID = 'proof-request-card',
onScroll,
scrollViewRef,
onContentSizeChange,
onLayout,
initialScrollOffset,
}) => {
// Create default timestamp once and reuse it to avoid unnecessary re-renders
const defaultTimestamp = useMemo(() => new Date(), []);
const effectiveTimestamp = timestamp ?? defaultTimestamp;
// Build request message with highlighted app name and document type
const requestMessage = (
<>
<Text color={proofRequestColors.white} fontFamily={dinot}>
{appName}
</Text>
<Text color={proofRequestColors.slate400} fontFamily={dinot}>
{
' is requesting access to the following information from your verified '
}
</Text>
<Text color={proofRequestColors.white} fontFamily={dinot}>
{documentType}
</Text>
<Text color={proofRequestColors.slate400} fontFamily={dinot}>
.
</Text>
</>
);
return (
<View flex={1} paddingVertical={20} paddingHorizontal={20} testID={testID}>
<View
borderRadius={proofRequestSpacing.borderRadius}
borderWidth={1}
borderColor={proofRequestColors.slate200}
overflow="hidden"
flex={1}
>
{/* Black Header */}
<ProofRequestHeader
logoSource={logoSource}
appName={appName}
appUrl={appUrl}
requestMessage={requestMessage}
testID={`${testID}-header`}
/>
{/* Metadata Bar */}
<ProofMetadataBar
timestamp={formatTimestamp(effectiveTimestamp)}
testID={`${testID}-metadata`}
/>
{/* White Content Area */}
<View
flex={1}
backgroundColor={proofRequestColors.slate100}
borderBottomLeftRadius={proofRequestSpacing.borderRadius}
borderBottomRightRadius={proofRequestSpacing.borderRadius}
>
{/* Connected Wallet Badge - Fixed position under metadata bar */}
{connectedWalletBadge && (
<View
paddingHorizontal={proofRequestSpacing.cardPadding}
paddingTop={proofRequestSpacing.cardPadding}
paddingBottom={0}
>
{connectedWalletBadge}
</View>
)}
{/* Scrollable Content */}
<View
flex={1}
padding={proofRequestSpacing.cardPadding}
paddingTop={
connectedWalletBadge
? proofRequestSpacing.itemPadding
: proofRequestSpacing.cardPadding
}
>
<ScrollView
ref={scrollViewRef}
showsVerticalScrollIndicator={true}
contentContainerStyle={{ flexGrow: 1 }}
onScroll={onScroll}
scrollEventThrottle={16}
onContentSizeChange={onContentSizeChange}
onLayout={onLayout}
contentOffset={
typeof initialScrollOffset === 'number'
? { x: 0, y: initialScrollOffset }
: undefined
}
>
{children}
</ScrollView>
</View>
</View>
</View>
</View>
);
};

View File

@@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import type { ImageSourcePropType } from 'react-native';
import { Image, Text, View, YStack } from 'tamagui';
import {
advercase,
dinot,
plexMono,
} from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
export interface ProofRequestHeaderProps {
logoSource: ImageSourcePropType | null;
appName: string;
appUrl: string | null;
requestMessage: React.ReactNode;
testID?: string;
}
/**
* Black header section for proof request screens.
* Displays app logo, name, URL, and request description.
* Matches Figma design 15234:9267.
*/
export const ProofRequestHeader: React.FC<ProofRequestHeaderProps> = ({
logoSource,
appName,
appUrl,
requestMessage,
testID = 'proof-request-header',
}) => {
const hasLogo = logoSource !== null;
return (
<View
backgroundColor={proofRequestColors.black}
padding={30}
gap={20}
testID={testID}
>
{/* Logo and App Info Row */}
<View flexDirection="row" alignItems="center" gap={20}>
{logoSource && (
<View
width={50}
height={50}
borderRadius={3}
overflow="hidden"
testID={`${testID}-logo`}
>
<Image
source={logoSource}
width={50}
height={50}
objectFit="contain"
/>
</View>
)}
<YStack>
<Text
fontFamily={advercase}
fontSize={28}
color={proofRequestColors.white}
letterSpacing={1}
testID={`${testID}-app-name`}
>
{appName}
</Text>
{appUrl && (
<View marginRight={hasLogo ? 50 : 0}>
<Text
fontFamily={plexMono}
fontSize={12}
color={proofRequestColors.zinc500}
testID={`${testID}-app-url`}
numberOfLines={1}
ellipsizeMode="middle"
>
{appUrl}
</Text>
</View>
)}
</YStack>
</View>
{/* Request Description */}
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate400}
lineHeight={24}
minHeight={75}
testID={`${testID}-request-message`}
>
{requestMessage}
</Text>
</View>
);
};

View File

@@ -0,0 +1,240 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Modal, Pressable, StyleSheet } from 'react-native';
import { Text, View, XStack, YStack } from 'tamagui';
import Clipboard from '@react-native-clipboard/clipboard';
import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request/designTokens';
import { CopyIcon, WalletIcon } from '@/components/proof-request/icons';
export interface WalletAddressModalProps {
visible: boolean;
onClose: () => void;
address: string;
userIdType?: string;
testID?: string;
}
/**
* Modal that displays the full wallet address with copy functionality.
* Appears when user taps on the truncated wallet badge.
*/
export const WalletAddressModal: React.FC<WalletAddressModalProps> = ({
visible,
onClose,
address,
userIdType,
testID = 'wallet-address-modal',
}) => {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID';
// Reset copied state when modal closes
useEffect(() => {
if (!visible) {
setCopied(false);
}
}, [visible]);
// Clear timeout on unmount or when modal closes/address changes
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [visible, address, onClose]);
const handleCopy = useCallback(() => {
// Clear any existing timeout before setting a new one
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
Clipboard.setString(address);
setCopied(true);
// Reset copied state and close after a brief delay
timeoutRef.current = setTimeout(() => {
setCopied(false);
onClose();
timeoutRef.current = null;
}, 800);
}, [address, onClose]);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
testID={testID}
>
<Pressable style={styles.backdrop} onPress={onClose}>
<View style={styles.container}>
<Pressable onPress={e => e.stopPropagation()}>
<YStack
backgroundColor={proofRequestColors.white}
borderRadius={16}
paddingHorizontal={24}
paddingVertical={24}
gap={20}
minWidth={300}
maxWidth="90%"
elevation={8}
shadowColor={proofRequestColors.black}
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.3}
shadowRadius={8}
>
{/* Header */}
<YStack gap={8}>
<XStack alignItems="center" gap={8}>
<WalletIcon size={20} color={proofRequestColors.blue600} />
<Text
fontFamily={dinot}
fontSize={18}
color={proofRequestColors.slate900}
fontWeight="600"
>
{label}
</Text>
</XStack>
</YStack>
{/* Full Address */}
<View
backgroundColor={proofRequestColors.slate100}
padding={16}
borderRadius={8}
borderWidth={1}
borderColor={proofRequestColors.slate200}
>
<Text
fontFamily={plexMono}
fontSize={14}
color={proofRequestColors.slate900}
numberOfLines={undefined}
ellipsizeMode="middle"
testID={`${testID}-full-address`}
>
{address}
</Text>
</View>
{/* Action Buttons */}
<XStack gap={12}>
<Pressable
onPress={handleCopy}
disabled={copied}
style={({ pressed }) => [
copied ? styles.copiedButton : styles.copyButton,
pressed && !copied && styles.copyButtonPressed,
]}
testID={`${testID}-copy`}
>
<XStack
alignItems="center"
justifyContent="center"
gap={8}
paddingVertical={14}
>
{copied ? (
<Text
fontSize={16}
fontWeight="600"
color={proofRequestColors.white}
>
</Text>
) : (
<CopyIcon size={16} color={proofRequestColors.white} />
)}
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.white}
fontWeight="600"
>
{copied ? 'Copied!' : 'Copy'}
</Text>
</XStack>
</Pressable>
{!copied && (
<Pressable
onPress={onClose}
style={({ pressed }) => [
styles.closeButton,
pressed && styles.closeButtonPressed,
]}
testID={`${testID}-close`}
>
<View
alignItems="center"
justifyContent="center"
paddingVertical={14}
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
fontWeight="600"
>
Close
</Text>
</View>
</Pressable>
)}
</XStack>
</YStack>
</Pressable>
</View>
</Pressable>
</Modal>
);
};
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
copyButton: {
flex: 1,
backgroundColor: proofRequestColors.blue600,
borderRadius: 8,
},
copyButtonPressed: {
backgroundColor: proofRequestColors.blue700,
},
copiedButton: {
flex: 1,
backgroundColor: proofRequestColors.emerald500,
borderRadius: 8,
},
closeButton: {
flex: 1,
backgroundColor: proofRequestColors.slate100,
borderRadius: 8,
borderWidth: 1,
borderColor: proofRequestColors.slate200,
},
closeButtonPressed: {
backgroundColor: proofRequestColors.slate200,
},
});

View File

@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/**
* Design tokens for proof request components.
* Extracted from Figma design 15234:9267 and 15234:9322.
*/
export const proofRequestColors = {
// Base colors
black: '#000000',
white: '#FFFFFF',
// Slate palette
slate100: '#F8FAFC',
slate200: '#E2E8F0',
slate400: '#94A3B8',
slate500: '#71717A',
slate900: '#0F172A',
// Blue palette
blue500: '#3B82F6',
blue600: '#2563EB',
blue700: '#1D4ED8',
// Status colors
emerald500: '#10B981',
// Zinc palette
zinc500: '#71717A',
} as const;
export const proofRequestSpacing = {
cardPadding: 20,
headerPadding: 30,
itemPadding: 16,
borderRadius: 10,
borderRadiusSmall: 4,
} as const;

View File

@@ -0,0 +1,143 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import Svg, { Circle, Path, Rect } from 'react-native-svg';
export interface IconProps {
size?: number;
color?: string;
}
/**
* Chevron up/down icon (dropdown)
*/
export const ChevronUpDownIcon: React.FC<IconProps> = ({
size = 20,
color = '#94A3B8',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M8 10L12 6L16 10M16 14L12 18L8 14"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
/**
* Copy icon
*/
export const CopyIcon: React.FC<IconProps> = ({
size = 16,
color = '#FFFFFF',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x="9"
y="9"
width="13"
height="13"
rx="2"
stroke={color}
strokeWidth="2"
fill="none"
/>
<Path
d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
/**
* Document icon (lighter stroke to match SF Symbol design)
*/
export const DocumentIcon: React.FC<IconProps> = ({
size = 18,
color = '#94A3B8',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M14 2H6C5.46957 2 4.96086 2.21071 4.58579 2.58579C4.21071 2.96086 4 3.46957 4 4V20C4 20.5304 4.21071 21.0391 4.58579 21.4142C4.96086 21.7893 5.46957 22 6 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V8L14 2Z"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<Path
d="M14 2V8H20"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M16 13H8M16 17H8M10 9H8"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
/**
* Filled circle icon (checkmark/bullet point)
*/
export const FilledCircleIcon: React.FC<IconProps> = ({
size = 18,
color = '#10B981',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Circle cx="12" cy="12" r="10" fill={color} />
</Svg>
);
/**
* Info circle icon
*/
export const InfoCircleIcon: React.FC<IconProps> = ({
size = 20,
color = '#3B82F6',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Circle cx="12" cy="12" r="10" stroke={color} strokeWidth="2" fill="none" />
<Path
d="M12 16V12M12 8H12.01"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
/**
* Wallet icon (credit card style to match SF Symbol creditcard 􀟿)
*/
export const WalletIcon: React.FC<IconProps> = ({
size = 16,
color = '#FFFFFF',
}) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x="2"
y="5"
width="20"
height="14"
rx="2"
stroke={color}
strokeWidth="2"
fill="none"
/>
<Path d="M2 10H22" stroke={color} strokeWidth="2" strokeLinecap="round" />
</Svg>
);

View File

@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export type { BottomActionBarProps } from '@/components/proof-request/BottomActionBar';
export type { BottomVerifyBarProps } from '@/components/proof-request/BottomVerifyBar';
// Metadata bar
export type { ConnectedWalletBadgeProps } from '@/components/proof-request/ConnectedWalletBadge';
export type { DisclosureItemProps } from '@/components/proof-request/DisclosureItem';
export type { IconProps } from '@/components/proof-request/icons';
// Header section
export type { ProofMetadataBarProps } from '@/components/proof-request/ProofMetadataBar';
/**
* Proof Request Component Library
*
* Shared components for proof request preview and proving screens.
* These components implement the Figma designs 15234:9267 and 15234:9322.
*/
// Main card component
export type { ProofRequestCardProps } from '@/components/proof-request/ProofRequestCard';
export type { ProofRequestHeaderProps } from '@/components/proof-request/ProofRequestHeader';
export type { WalletAddressModalProps } from '@/components/proof-request/WalletAddressModal';
// Icons
export { BottomActionBar } from '@/components/proof-request/BottomActionBar';
export { BottomVerifyBar } from '@/components/proof-request/BottomVerifyBar';
// Bottom action bar
export {
ChevronUpDownIcon,
CopyIcon,
DocumentIcon,
FilledCircleIcon,
InfoCircleIcon,
WalletIcon,
} from '@/components/proof-request/icons';
export {
ConnectedWalletBadge,
truncateAddress,
} from '@/components/proof-request/ConnectedWalletBadge';
// Connected wallet badge
export { DisclosureItem } from '@/components/proof-request/DisclosureItem';
// Disclosure item
export {
ProofMetadataBar,
formatTimestamp,
} from '@/components/proof-request/ProofMetadataBar';
export { ProofRequestCard } from '@/components/proof-request/ProofRequestCard';
export { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader';
export { WalletAddressModal } from '@/components/proof-request/WalletAddressModal';
// Design tokens
export {
proofRequestColors,
proofRequestSpacing,
} from '@/components/proof-request/designTokens';

View File

@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { View, XStack } from 'tamagui';
import { black, zinc800 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import CheckmarkIcon from '@/assets/icons/checkmark_white.svg';
import OperaLogo from '@/assets/logos/opera_minipay.svg';
import SelfLogo from '@/assets/logos/self.svg';
export const StarfallLogoHeader: React.FC = () => (
<XStack gap={10} alignItems="center" marginBottom={20}>
{/* Opera MiniPay logo */}
<View width={46} height={46} borderRadius={3} overflow="hidden">
<OperaLogo width={46} height={46} />
</View>
{/* Checkmark icon */}
<View width={32} height={32}>
<CheckmarkIcon width={32} height={32} />
</View>
{/* Self logo */}
<View
width={46}
height={46}
backgroundColor={black}
borderRadius={3}
borderWidth={1}
borderColor={zinc800}
alignItems="center"
justifyContent="center"
>
<SelfLogo width={28} height={28} />
</View>
</XStack>
);

View File

@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Text, XStack, YStack } from 'tamagui';
import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
export interface StarfallPINProps {
code: string;
}
export const StarfallPIN: React.FC<StarfallPINProps> = ({ code }) => {
// Split the code into individual digits (expects 4 digits)
const digits = code.split('').slice(0, 4);
// Pad with empty strings if less than 4 digits
while (digits.length < 4) {
digits.push('');
}
return (
<XStack
gap={6}
alignItems="center"
justifyContent="center"
padding={4}
borderRadius={12}
borderWidth={1}
borderColor="#52525b"
backgroundColor="rgba(0, 0, 0, 0.4)"
width="100%"
>
{digits.map((digit, index) => (
<YStack
key={index}
flex={1}
height={80}
alignItems="center"
justifyContent="center"
borderRadius={8}
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.2)"
paddingHorizontal={12}
>
<Text
fontFamily={dinot}
fontSize={32}
fontWeight="500"
color={white}
letterSpacing={-1}
lineHeight={32}
>
{digit}
</Text>
</YStack>
))}
</XStack>
);
};

View File

@@ -11,11 +11,9 @@ import { apiPingUrl } from '@/consts/links';
import { useModal } from '@/hooks/useModal';
import { useNetInfo } from '@/hooks/useNetInfo';
import { navigationRef } from '@/navigation';
import analytics from '@/services/analytics';
import { trackEvent } from '@/services/analytics';
import { useSettingStore } from '@/stores/settingStore';
const { trackEvent } = analytics();
const connectionModalParams = {
titleText: 'Internet connection error',
bodyText: 'In order to use SELF, you must have access to the internet.',

View File

@@ -40,7 +40,7 @@ export const useEarnPointsFlow = ({
// Use setTimeout to ensure modal dismisses before navigating
setTimeout(() => {
navigation.navigate('Prove');
navigation.navigate('ProvingScreenRouter');
}, 100);
}, [selfClient, navigation]);

View File

@@ -17,7 +17,13 @@ export const useFeedbackAutoHide = () => {
// When screen goes out of focus, hide the feedback button
return () => {
hideFeedbackButton();
try {
hideFeedbackButton();
} catch (error) {
if (__DEV__) {
console.debug('Failed to hide feedback button:', error);
}
}
};
}, []),
);

View File

@@ -27,25 +27,38 @@ export const useFeedbackModal = () => {
timeoutRef.current = null;
}
switch (type) {
case 'button':
showFeedbackButton();
break;
case 'widget':
showFeedbackWidget();
break;
case 'custom':
setIsVisible(true);
break;
default:
showFeedbackButton();
try {
switch (type) {
case 'button':
showFeedbackButton();
break;
case 'widget':
showFeedbackWidget();
break;
case 'custom':
setIsVisible(true);
break;
default:
showFeedbackButton();
}
} catch (error) {
if (__DEV__) {
console.debug('Failed to show feedback button/widget:', error);
}
setIsVisible(true);
}
// we can close the feedback modals(sentry and custom modals), but can't do so for the Feedback button.
// This hides the button after 10 seconds.
if (type === 'button') {
timeoutRef.current = setTimeout(() => {
hideFeedbackButton();
try {
hideFeedbackButton();
} catch (error) {
if (__DEV__) {
console.debug('Failed to hide feedback button:', error);
}
}
timeoutRef.current = null;
}, 10000);
}
@@ -57,7 +70,13 @@ export const useFeedbackModal = () => {
timeoutRef.current = null;
}
hideFeedbackButton();
try {
hideFeedbackButton();
} catch (error) {
if (__DEV__) {
console.debug('Failed to hide feedback button:', error);
}
}
setIsVisible(false);
}, []);

View File

@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { SelfApp } from '@selfxyz/common';
import type { RootStackParamList } from '@/navigation';
/**
* Hook that checks if SelfApp data is stale (missing or empty disclosures)
* and navigates to Home screen if stale data is detected.
*
* Uses a small delay to allow store updates to propagate after navigation
* (e.g., after QR code scan sets selfApp data).
*/
export function useProofDisclosureStalenessCheck(
selfApp: SelfApp | null,
disclosureItems: Array<{ key: string; text: string }>,
navigation: NativeStackNavigationProp<RootStackParamList>,
) {
useFocusEffect(
useCallback(() => {
// Add a small delay to allow Zustand store updates to propagate
// after navigation (e.g., when selfApp is set from QR scan)
const timeoutId = setTimeout(() => {
if (!selfApp || disclosureItems.length === 0) {
navigation.navigate({ name: 'Home', params: {} });
}
}, 300);
return () => {
clearTimeout(timeoutId);
};
}, [selfApp, disclosureItems.length, navigation]),
);
}

View File

@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useMemo } from 'react';
import type { SelfApp } from '@selfxyz/common';
import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType';
import { formatEndpoint } from '@selfxyz/common/utils/scope';
import { getDisclosureItems } from '@/utils/disclosureUtils';
import { formatUserId } from '@/utils/formatUserId';
/**
* Hook that extracts and transforms SelfApp data for use in UI components.
* Returns memoized values for logo source, URL, formatted user ID, and disclosure items.
*/
export function useSelfAppData(selfApp: SelfApp | null) {
const logoSource = useMemo(() => {
if (!selfApp?.logoBase64) {
return null;
}
// Check if the logo is already a URL
if (
selfApp.logoBase64.startsWith('http://') ||
selfApp.logoBase64.startsWith('https://')
) {
return { uri: selfApp.logoBase64 };
}
// Otherwise handle as base64
const base64String = selfApp.logoBase64.startsWith('data:image')
? selfApp.logoBase64
: `data:image/png;base64,${selfApp.logoBase64}`;
return { uri: base64String };
}, [selfApp?.logoBase64]);
const url = useMemo(() => {
if (!selfApp?.endpoint) {
return null;
}
return formatEndpoint(selfApp.endpoint);
}, [selfApp?.endpoint]);
const formattedUserId = useMemo(
() => formatUserId(selfApp?.userId, selfApp?.userIdType),
[selfApp?.userId, selfApp?.userIdType],
);
const disclosureItems = useMemo(() => {
const disclosures = (selfApp?.disclosures as SelfAppDisclosureConfig) || {};
return getDisclosureItems(disclosures);
}, [selfApp?.disclosures]);
return {
logoSource,
url,
formattedUserId,
disclosureItems,
};
}

View File

@@ -27,6 +27,7 @@ interface Inputs {
usePacePolling?: boolean;
sessionId: string;
userId?: string;
skipReselect?: boolean;
}
interface DataGroupHash {
@@ -91,6 +92,7 @@ const scanAndroid = async (
canNumber: inputs.canNumber ?? '',
useCan: inputs.useCan ?? false,
sessionId: inputs.sessionId,
skipReselect: inputs.skipReselect ?? false,
});
};

View File

@@ -16,6 +16,7 @@ type ScanOptions = {
usePacePolling?: boolean;
sessionId?: string;
quality?: number;
skipReselect?: boolean;
};
export interface AndroidScanResponse {
@@ -91,6 +92,8 @@ if (Platform.OS === 'android') {
canNumber = '',
useCan = false,
quality = 1,
skipReselect = false,
sessionId,
} = options;
return androidScan({
@@ -100,6 +103,8 @@ if (Platform.OS === 'android') {
canNumber,
useCan,
quality,
skipReselect,
sessionId,
});
};
}

View File

@@ -16,6 +16,7 @@ import AccountRecoveryScreen from '@/screens/account/recovery/AccountRecoveryScr
import DocumentDataNotFoundScreen from '@/screens/account/recovery/DocumentDataNotFoundScreen';
import RecoverWithPhraseScreen from '@/screens/account/recovery/RecoverWithPhraseScreen';
import CloudBackupScreen from '@/screens/account/settings/CloudBackupScreen';
import { ProofSettingsScreen } from '@/screens/account/settings/ProofSettingsScreen';
import SettingsScreen from '@/screens/account/settings/SettingsScreen';
import ShowRecoveryPhraseScreen from '@/screens/account/settings/ShowRecoveryPhraseScreen';
import { IS_EUCLID_ENABLED } from '@/utils/devUtils';
@@ -65,6 +66,18 @@ const accountScreens = {
},
} as NativeStackNavigationOptions,
},
ProofSettings: {
screen: ProofSettingsScreen,
options: {
title: 'Proof Settings',
headerStyle: {
backgroundColor: white,
},
headerTitleStyle: {
color: black,
},
} as NativeStackNavigationOptions,
},
Settings: {
screen: SettingsScreen,
options: {

View File

@@ -9,6 +9,7 @@ import { countries } from '@selfxyz/common/constants/countries';
import type { IdDocInput } from '@selfxyz/common/utils';
import type { SelfClient } from '@selfxyz/mobile-sdk-alpha';
import type { RootStackParamList } from '@/navigation';
import { navigationRef } from '@/navigation';
import useUserStore from '@/stores/userStore';
import { IS_DEV_MODE } from '@/utils/devUtils';
@@ -108,6 +109,28 @@ export const getAndClearQueuedUrl = (): string | null => {
return url;
};
const safeNavigate = (
navigationState: ReturnType<typeof createDeeplinkNavigationState>,
): void => {
const targetScreen = navigationState.routes[1]?.name as
| keyof RootStackParamList
| undefined;
const currentRoute = navigationRef.getCurrentRoute();
const isColdLaunch = currentRoute?.name === 'Splash';
if (!isColdLaunch && targetScreen) {
// Use object syntax to satisfy TypeScript's strict typing for navigate
// The params will be undefined for screens that don't require them
navigationRef.navigate({
name: targetScreen,
params: undefined,
} as Parameters<typeof navigationRef.navigate>[0]);
} else {
navigationRef.reset(navigationState);
}
};
export const handleUrl = (selfClient: SelfClient, uri: string) => {
const validatedParams = parseAndValidateUrlParams(uri);
const {
@@ -125,8 +148,11 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => {
selfClient.getSelfAppState().setSelfApp(selfAppJson);
selfClient.getSelfAppState().startAppListener(selfAppJson.sessionId);
navigationRef.reset(
createDeeplinkNavigationState('Prove', correctParentScreen),
safeNavigate(
createDeeplinkNavigationState(
'ProvingScreenRouter',
correctParentScreen,
),
);
return;
@@ -134,7 +160,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => {
if (IS_DEV_MODE) {
console.error('Error parsing selfApp:', error);
}
navigationRef.reset(
safeNavigate(
createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen),
);
}
@@ -142,8 +168,8 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => {
selfClient.getSelfAppState().cleanSelfApp();
selfClient.getSelfAppState().startAppListener(sessionId);
navigationRef.reset(
createDeeplinkNavigationState('Prove', correctParentScreen),
safeNavigate(
createDeeplinkNavigationState('ProvingScreenRouter', correctParentScreen),
);
} else if (mock_passport) {
try {
@@ -172,25 +198,26 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => {
});
// Reset navigation stack with correct parent -> MockDataDeepLink
navigationRef.reset(
safeNavigate(
createDeeplinkNavigationState('MockDataDeepLink', correctParentScreen),
);
} catch (error) {
if (IS_DEV_MODE) {
console.error('Error parsing mock_passport data or navigating:', error);
}
navigationRef.reset(
safeNavigate(
createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen),
);
}
} else if (referrer && typeof referrer === 'string') {
useUserStore.getState().setDeepLinkReferrer(referrer);
// Navigate to HomeScreen - it will show confirmation modal and then navigate to GratificationScreen
navigationRef.reset({
index: 0,
routes: [{ name: 'Home' }],
});
const currentRoute = navigationRef.getCurrentRoute();
if (currentRoute?.name === 'Home') {
// Already on Home, no navigation needed - the modal will show automatically
} else {
safeNavigate(createDeeplinkNavigationState('Home', 'Home'));
}
} else if (Platform.OS === 'web') {
// TODO: web handle links if we need to idk if we do
// For web, we can handle the URL some other way if we dont do this loading app in web always navigates to QRCodeTrouble
@@ -208,7 +235,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => {
'No sessionId, selfApp or valid OAuth parameters found in the data',
);
}
navigationRef.reset(
safeNavigate(
createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen),
);
}

View File

@@ -27,10 +27,11 @@ import documentsScreens from '@/navigation/documents';
import homeScreens from '@/navigation/home';
import onboardingScreens from '@/navigation/onboarding';
import sharedScreens from '@/navigation/shared';
import starfallScreens from '@/navigation/starfall';
import verificationScreens from '@/navigation/verification';
import type { ModalNavigationParams } from '@/screens/app/ModalScreen';
import type { WebViewScreenParams } from '@/screens/shared/WebViewScreen';
import analytics from '@/services/analytics';
import { trackScreenView } from '@/services/analytics';
import type { ProofHistory } from '@/stores/proofTypes';
export const navigationScreens = {
@@ -41,6 +42,7 @@ export const navigationScreens = {
...verificationScreens,
...accountScreens,
...sharedScreens,
...starfallScreens,
...devScreens, // allow in production for testing
};
@@ -71,6 +73,8 @@ export type RootStackParamList = Omit<
| 'Disclaimer'
| 'DocumentNFCScan'
| 'DocumentOnboarding'
| 'DocumentSelectorForProving'
| 'ProvingScreenRouter'
| 'Gratification'
| 'Home'
| 'IDPicker'
@@ -140,13 +144,24 @@ export type RootStackParamList = Omit<
returnToScreen?: 'Points';
}
| undefined;
ProofSettings: undefined;
AccountVerifiedSuccess: undefined;
// Proof/Verification screens
ProofHistoryDetail: {
data: ProofHistory;
};
Prove: undefined;
Prove:
| {
scrollOffset?: number;
}
| undefined;
ProvingScreenRouter: undefined;
DocumentSelectorForProving:
| {
documentType?: string;
}
| undefined;
// App screens
Loading: {
@@ -158,6 +173,7 @@ export type RootStackParamList = Omit<
Gratification: {
points?: number;
};
StarfallPushCode: undefined;
// Home screens
Home: {
@@ -195,7 +211,6 @@ declare global {
}
}
const { trackScreenView } = analytics();
const Navigation = createStaticNavigation(AppNavigation);
const NavigationWithTracking = () => {

View File

@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import StarfallPushCodeScreen from '@/screens/starfall/StarfallPushCodeScreen';
const starfallScreens = {
StarfallPushCode: {
screen: StarfallPushCodeScreen,
options: {
headerShown: false,
} as NativeStackNavigationOptions,
},
};
export default starfallScreens;

View File

@@ -6,11 +6,30 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen';
import ProofRequestStatusScreen from '@/screens/verification/ProofRequestStatusScreen';
import ProveScreen from '@/screens/verification/ProveScreen';
import { ProvingScreenRouter } from '@/screens/verification/ProvingScreenRouter';
import QRCodeTroubleScreen from '@/screens/verification/QRCodeTroubleScreen';
import QRCodeViewFinderScreen from '@/screens/verification/QRCodeViewFinderScreen';
/**
* Shared header configuration for proof request screens
*/
const proofRequestHeaderOptions: NativeStackNavigationOptions = {
title: 'Proof Requested',
headerStyle: {
backgroundColor: black,
},
headerTitleStyle: {
color: white,
fontWeight: '600',
},
headerTintColor: white,
gestureEnabled: false,
animation: 'none',
};
const verificationScreens = {
ProofRequestStatus: {
screen: ProofRequestStatusScreen,
@@ -20,18 +39,17 @@ const verificationScreens = {
gestureEnabled: false,
} as NativeStackNavigationOptions,
},
ProvingScreenRouter: {
screen: ProvingScreenRouter,
options: proofRequestHeaderOptions,
},
DocumentSelectorForProving: {
screen: DocumentSelectorForProvingScreen,
options: proofRequestHeaderOptions,
},
Prove: {
screen: ProveScreen,
options: {
title: 'Request Proof',
headerStyle: {
backgroundColor: black,
},
headerTitleStyle: {
color: white,
},
gestureEnabled: false,
} as NativeStackNavigationOptions,
options: proofRequestHeaderOptions,
},
QRCodeTrouble: {
screen: QRCodeTroubleScreen,

View File

@@ -22,11 +22,14 @@ import {
createKeychainOptions,
detectSecurityCapabilities,
} from '@/integrations/keychain';
import analytics from '@/services/analytics';
import { trackEvent } from '@/services/analytics';
import { useSettingStore } from '@/stores/settingStore';
import type { Mnemonic } from '@/types/mnemonic';
const { trackEvent } = analytics();
import {
getKeychainErrorIdentity,
isKeychainCryptoError,
isUserCancellation,
} from '@/utils/keychainErrors';
const SERVICE_NAME = 'secret';
@@ -149,31 +152,73 @@ async function restoreFromMnemonic(
}
}
let keychainCryptoFailureCallback:
| ((errorType: 'user_cancelled' | 'crypto_failed') => void)
| null = null;
async function loadOrCreateMnemonic(
keychainOptions: KeychainOptions,
): Promise<string | false> {
// Get adaptive security configuration
const { setOptions, getOptions } = keychainOptions;
const storedMnemonic = await Keychain.getGenericPassword({
...getOptions,
service: SERVICE_NAME,
});
if (storedMnemonic) {
try {
JSON.parse(storedMnemonic.password);
trackEvent(AuthEvents.MNEMONIC_LOADED);
return storedMnemonic.password;
} catch (e: unknown) {
console.error(
'Error parsing stored mnemonic, old secret format was used',
e,
);
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
reason: 'unknown_error',
error: e instanceof Error ? e.message : String(e),
});
try {
const storedMnemonic = await Keychain.getGenericPassword({
...getOptions,
service: SERVICE_NAME,
});
if (storedMnemonic) {
try {
JSON.parse(storedMnemonic.password);
trackEvent(AuthEvents.MNEMONIC_LOADED);
return storedMnemonic.password;
} catch (e: unknown) {
console.error(
'Error parsing stored mnemonic, old secret format was used',
e,
);
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
reason: 'unknown_error',
error: e instanceof Error ? e.message : String(e),
});
}
}
} catch (error: unknown) {
if (isUserCancellation(error)) {
console.log('User cancelled authentication');
trackEvent(AuthEvents.BIOMETRIC_LOGIN_CANCELLED);
if (keychainCryptoFailureCallback) {
keychainCryptoFailureCallback('user_cancelled');
}
throw error;
}
if (isKeychainCryptoError(error)) {
const err = getKeychainErrorIdentity(error);
console.error('Keychain crypto error:', {
code: err?.code,
name: err?.name,
});
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
reason: 'keychain_crypto_failed',
errorCode: err?.code,
});
if (keychainCryptoFailureCallback) {
keychainCryptoFailureCallback('crypto_failed');
}
throw error;
}
console.error('Error loading mnemonic:', error);
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
reason: 'unknown_error',
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
try {
const { mnemonic } = ethers.HDNodeWallet.fromMnemonic(
@@ -426,6 +471,13 @@ export async function migrateToSecureKeychain(): Promise<boolean> {
}
}
// Global callback for keychain crypto failures
export function setKeychainCryptoFailureCallback(
callback: ((errorType: 'user_cancelled' | 'crypto_failed') => void) | null,
) {
keychainCryptoFailureCallback = callback;
}
export async function unsafe_clearSecrets() {
if (__DEV__) {
await Keychain.resetGenericPassword({ service: SERVICE_NAME });
@@ -458,10 +510,6 @@ export async function unsafe_getPointsPrivateKey(
return wallet.privateKey;
}
/**
* The only reason this is exported without being locked behind user biometrics is to allow `loadPassportDataAndSecret`
* to access both the privatekey and the passport data with the user only authenticating once
*/
export async function unsafe_getPrivateKey(keychainOptions?: KeychainOptions) {
const options =
keychainOptions ||

View File

@@ -18,11 +18,9 @@ import React, {
import { AuthEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import analytics from '@/services/analytics';
import { trackEvent } from '@/services/analytics';
import type { Mnemonic } from '@/types/mnemonic';
const { trackEvent } = analytics();
type SignedPayload<T> = { signature: string; data: T };
// Check if Android bridge is available

View File

@@ -8,9 +8,7 @@ import messaging from '@react-native-firebase/messaging';
import { NotificationEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import analytics from '@/services/analytics';
const { trackEvent } = analytics();
import { trackEvent } from '@/services/analytics';
export const NotificationTrackingProvider: React.FC<PropsWithChildren> = ({
children,

View File

@@ -67,6 +67,59 @@ import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { createKeychainOptions } from '@/integrations/keychain';
import { unsafe_getPrivateKey, useAuth } from '@/providers/authProvider';
import type { KeychainErrorType } from '@/utils/keychainErrors';
import {
getKeychainErrorIdentity,
isKeychainCryptoError,
isUserCancellation,
} from '@/utils/keychainErrors';
let keychainCryptoFailureCallback:
| ((errorType: 'user_cancelled' | 'crypto_failed') => void)
| null = null;
export function setPassportKeychainErrorCallback(
callback: ((errorType: 'user_cancelled' | 'crypto_failed') => void) | null,
) {
keychainCryptoFailureCallback = callback;
}
function notifyKeychainFailure(type: KeychainErrorType) {
if (keychainCryptoFailureCallback) {
keychainCryptoFailureCallback(type);
}
}
function handleKeychainReadError({
contextLabel,
error,
throwOnUserCancel = false,
}: {
contextLabel: string;
error: unknown;
throwOnUserCancel?: boolean;
}) {
if (isUserCancellation(error)) {
console.log(`User cancelled authentication for ${contextLabel}`);
notifyKeychainFailure('user_cancelled');
if (throwOnUserCancel) {
throw error;
}
}
if (isKeychainCryptoError(error)) {
const err = getKeychainErrorIdentity(error);
console.error(`Keychain crypto error loading ${contextLabel}:`, {
code: err?.code,
name: err?.name,
});
notifyKeychainFailure('crypto_failed');
}
console.log(`Error loading ${contextLabel}:`, error);
}
// Create safe wrapper functions to prevent undefined errors during early initialization
// These need to be declared early to avoid dependency issues
@@ -447,7 +500,10 @@ export async function loadDocumentByIdDirectlyFromKeychain(
return JSON.parse(documentCreds.password);
}
} catch (error) {
console.log(`Error loading document ${documentId}:`, error);
handleKeychainReadError({
contextLabel: `document ${documentId}`,
error,
});
}
return null;
}
@@ -491,7 +547,11 @@ export async function loadDocumentCatalogDirectlyFromKeychain(): Promise<Documen
return parsed;
}
} catch (error) {
console.log('Error loading document catalog:', error);
handleKeychainReadError({
contextLabel: 'document catalog',
error,
throwOnUserCancel: true,
});
}
// Return empty catalog if none exists

View File

@@ -3,7 +3,7 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { PropsWithChildren } from 'react';
import { useMemo } from 'react';
import React, { useMemo } from 'react';
import { Platform } from 'react-native';
import {
@@ -23,10 +23,20 @@ import {
import { logNFCEvent, logProofEvent } from '@/config/sentry';
import type { RootStackParamList } from '@/navigation';
import { navigationRef } from '@/navigation';
import { unsafe_getPrivateKey } from '@/providers/authProvider';
import { selfClientDocumentsAdapter } from '@/providers/passportDataProvider';
import analytics, { trackNfcEvent } from '@/services/analytics';
import {
setKeychainCryptoFailureCallback,
unsafe_getPrivateKey,
} from '@/providers/authProvider';
import {
selfClientDocumentsAdapter,
setPassportKeychainErrorCallback,
} from '@/providers/passportDataProvider';
import { trackEvent, trackNfcEvent } from '@/services/analytics';
import { useSettingStore } from '@/stores/settingStore';
import {
registerModalCallbacks,
unregisterModalCallbacks,
} from '@/utils/modalCallbackRegistry';
type GlobalCrypto = { crypto?: { subtle?: Crypto['subtle'] } };
/**
@@ -105,6 +115,8 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
}
}
},
enableKeychainErrorModal,
disableKeychainErrorModal,
},
crypto: {
async hash(
@@ -130,7 +142,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
},
analytics: {
trackEvent: (event: string, data?: TrackEventParams) => {
analytics().trackEvent(event, data);
trackEvent(event, data);
},
trackNfcEvent: (name: string, data?: Record<string, unknown>) => {
trackNfcEvent(name, data);
@@ -211,21 +223,21 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
if (fcmToken) {
try {
analytics().trackEvent('DEVICE_TOKEN_REG_STARTED');
trackEvent('DEVICE_TOKEN_REG_STARTED');
logProofEvent('info', 'Device token registration started', context);
const { registerDeviceToken: registerFirebaseDeviceToken } =
await import('@/services/notifications/notificationService');
await registerFirebaseDeviceToken(uuid, fcmToken, isMock);
analytics().trackEvent('DEVICE_TOKEN_REG_SUCCESS');
trackEvent('DEVICE_TOKEN_REG_SUCCESS');
logProofEvent('info', 'Device token registration success', context);
} catch (error) {
logProofEvent('warn', 'Device token registration failed', context, {
error: error instanceof Error ? error.message : String(error),
});
console.error('Error registering device token:', error);
analytics().trackEvent('DEVICE_TOKEN_REG_FAILED', {
trackEvent('DEVICE_TOKEN_REG_FAILED', {
message: error instanceof Error ? error.message : String(error),
});
}
@@ -322,4 +334,56 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
);
};
export function disableKeychainErrorModal() {
setKeychainCryptoFailureCallback(null);
setPassportKeychainErrorCallback(null);
}
// Functions to enable/disable keychain error modals
// These should be called by the provingMachine when entering/exiting proving flows
export function enableKeychainErrorModal() {
setKeychainCryptoFailureCallback(showKeychainErrorModal);
setPassportKeychainErrorCallback(showKeychainErrorModal);
}
export function showKeychainErrorModal(
errorType: 'user_cancelled' | 'crypto_failed',
) {
if (Platform.OS !== 'android') return;
if (!navigationRef.isReady()) return;
const errorContent = {
user_cancelled: {
titleText: 'Authentication Required',
bodyText:
'You need to authenticate with your fingerprint, PIN or faceID to continue the verification process. Please try again.',
buttonText: 'Try Again',
},
crypto_failed: {
titleText: 'Keychain Error',
bodyText:
'Unable to access your keychain. This may happen if your device security settings have changed or if the encrypted data was corrupted. Please contact support if the issue persists.',
buttonText: 'Go to Home',
},
};
const content = errorContent[errorType];
const callbackId = registerModalCallbacks({
onButtonPress: () => {
unregisterModalCallbacks(callbackId);
navigationRef.navigate({ name: 'Home', params: {} });
},
onModalDismiss: () => {
unregisterModalCallbacks(callbackId);
navigationRef.navigate({ name: 'Home', params: {} });
},
});
navigationRef.navigate('Modal', {
...content,
callbackId,
});
}
export default SelfClientProvider;

View File

@@ -27,9 +27,7 @@ import {
storePassportData,
updateDocumentRegistrationState,
} from '@/providers/passportDataProvider';
import analytics from '@/services/analytics';
const { trackEvent } = analytics();
import { trackEvent } from '@/services/analytics';
/**
* This function checks and updates registration states for all documents and updates the `isRegistered`.

View File

@@ -21,9 +21,7 @@ import {
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import analytics from '@/services/analytics';
const { flush: flushAnalytics } = analytics();
import { flush as flushAnalytics } from '@/services/analytics';
const DocumentDataNotFoundScreen: React.FC = () => {
const selfClient = useSelfClient();

View File

@@ -0,0 +1,130 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { ScrollView, YStack } from 'tamagui';
import {
black,
blue600,
slate200,
slate500,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSettingStore } from '@/stores/settingStore';
const ProofSettingsScreen: React.FC = () => {
const {
skipDocumentSelector,
setSkipDocumentSelector,
skipDocumentSelectorIfSingle,
setSkipDocumentSelectorIfSingle,
} = useSettingStore();
return (
<YStack flex={1} backgroundColor={white}>
<ScrollView>
<YStack padding={20} gap={20}>
<Text style={styles.sectionTitle}>Document Selection</Text>
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={styles.settingLabel}>
Always skip document selection
</Text>
<Text style={styles.settingDescription}>
Go directly to proof generation using your previously selected
or first available document
</Text>
</View>
<Switch
value={skipDocumentSelector}
onValueChange={setSkipDocumentSelector}
trackColor={{ false: slate200, true: blue600 }}
thumbColor={white}
testID="skip-document-selector-toggle"
/>
</View>
<View style={styles.divider} />
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={styles.settingLabel}>
Skip when only one document
</Text>
<Text style={styles.settingDescription}>
Automatically select your document when you only have one valid
ID available
</Text>
</View>
<Switch
value={skipDocumentSelectorIfSingle}
onValueChange={setSkipDocumentSelectorIfSingle}
trackColor={{ false: slate200, true: blue600 }}
thumbColor={white}
disabled={skipDocumentSelector}
testID="skip-document-selector-if-single-toggle"
/>
</View>
{skipDocumentSelector && (
<Text style={styles.infoText}>
Document selection is always skipped. The &quot;Skip when only one
document&quot; setting has no effect.
</Text>
)}
</YStack>
</ScrollView>
</YStack>
);
};
const styles = StyleSheet.create({
sectionTitle: {
fontSize: 14,
fontFamily: dinot,
fontWeight: '600',
color: slate500,
textTransform: 'uppercase',
letterSpacing: 1,
},
settingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
},
settingTextContainer: {
flex: 1,
gap: 4,
},
settingLabel: {
fontSize: 16,
fontFamily: dinot,
fontWeight: '500',
color: black,
},
settingDescription: {
fontSize: 14,
fontFamily: dinot,
color: slate500,
},
divider: {
height: 1,
backgroundColor: slate200,
},
infoText: {
fontSize: 13,
fontFamily: dinot,
fontStyle: 'italic',
color: slate500,
paddingHorizontal: 4,
},
});
export { ProofSettingsScreen };

View File

@@ -12,7 +12,7 @@ import type { SvgProps } from 'react-native-svg';
import { Button, ScrollView, View, XStack, YStack } from 'tamagui';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Bug, FileText } from '@tamagui/lucide-icons';
import { Bug, FileText, Settings2 } from '@tamagui/lucide-icons';
import { BodyText, pressedStyle } from '@selfxyz/mobile-sdk-alpha/components';
import {
@@ -78,6 +78,7 @@ const routes =
[Data, 'View document info', 'DocumentDataInfo'],
[Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'],
[Cloud, 'Cloud backup', 'CloudBackupSettings'],
[Settings2 as React.FC<SvgProps>, 'Proof settings', 'ProofSettings'],
[Feedback, 'Send feedback', 'email_feedback'],
[ShareIcon, 'Share Self app', 'share'],
[
@@ -88,6 +89,7 @@ const routes =
] satisfies [React.FC<SvgProps>, string, RouteOption][])
: ([
[Data, 'View document info', 'DocumentDataInfo'],
[Settings2 as React.FC<SvgProps>, 'Proof settings', 'ProofSettings'],
[Feedback, 'Send feeback', 'email_feedback'],
[
FileText as React.FC<SvgProps>,
@@ -105,10 +107,10 @@ const DEBUG_MENU: [React.FC<SvgProps>, string, RouteOption][] = [
];
const DOCUMENT_DEPENDENT_ROUTES: RouteOption[] = [
'CloudBackupSettings',
'DocumentDataInfo',
'ShowRecoveryPhrase',
];
const CLOUD_BACKUP_ROUTE: RouteOption = 'CloudBackupSettings';
const social = [
[X, xUrl],
@@ -185,16 +187,20 @@ const SettingsScreen: React.FC = () => {
const screenRoutes = useMemo(() => {
const baseRoutes = isDevMode ? [...routes, ...DEBUG_MENU] : routes;
const shouldHideCloudBackup = Platform.OS === 'android';
const hasConfirmedRealDocument = hasRealDocument === true;
// Show all routes while loading or if user has a real document
if (hasRealDocument === null || hasRealDocument === true) {
return baseRoutes;
}
return baseRoutes.filter(([, , route]) => {
if (DOCUMENT_DEPENDENT_ROUTES.includes(route)) {
return hasConfirmedRealDocument;
}
// Only filter out document-related routes if we've confirmed user has no real documents
return baseRoutes.filter(
([, , route]) => !DOCUMENT_DEPENDENT_ROUTES.includes(route),
);
if (shouldHideCloudBackup && route === CLOUD_BACKUP_ROUTE) {
return hasConfirmedRealDocument;
}
return true;
});
}, [hasRealDocument, isDevMode]);
const devModeTap = Gesture.Tap()

View File

@@ -15,6 +15,7 @@ import Mnemonic from '@/components/Mnemonic';
import useMnemonic from '@/hooks/useMnemonic';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { useSettingStore } from '@/stores/settingStore';
import { getRecoveryPhraseWarningMessage } from '@/utils/crypto/mnemonic';
import { IS_EUCLID_ENABLED } from '@/utils/devUtils';
function useCopyRecoveryPhrase(mnemonic: string[] | undefined) {
@@ -89,10 +90,7 @@ const ShowRecoveryPhraseScreen: React.FC & {
gap={20}
>
<Mnemonic words={mnemonic} onRevealWords={loadMnemonic} />
<Description>
This phrase is the only way to recover your account. Keep it secret,
keep it safe.
</Description>
<Description>{getRecoveryPhraseWarningMessage()}</Description>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
);

View File

@@ -26,8 +26,8 @@ import {
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot, dinotBold } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import LogoWhite from '@/assets/icons/logo_white.svg';
import GratificationBg from '@/assets/images/gratification_bg.svg';
import SelfLogo from '@/assets/logos/self.svg';
import type { RootStackParamList } from '@/navigation';
const GratificationScreen: React.FC = () => {
@@ -160,7 +160,7 @@ const GratificationScreen: React.FC = () => {
>
{/* Logo icon */}
<View marginBottom={12} style={styles.logoContainer}>
<LogoWhite width={37} height={37} />
<SelfLogo width={37} height={37} />
</View>
{/* Points display */}

View File

@@ -11,8 +11,8 @@ import React, {
useState,
} from 'react';
import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
import { Alert, ScrollView } from 'react-native';
import { Adapt, Button, Select, Sheet, Text, XStack, YStack } from 'tamagui';
import { Alert, ScrollView, TouchableOpacity } from 'react-native';
import { Button, Sheet, Text, XStack, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Check, ChevronDown, ChevronRight } from '@tamagui/lucide-icons';
@@ -33,7 +33,6 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import BugIcon from '@/assets/icons/bug_icon.svg';
import IdIcon from '@/assets/icons/id_icon.svg';
import WarningIcon from '@/assets/icons/warning.svg';
import type { RootStackParamList } from '@/navigation';
import { navigationScreens } from '@/navigation';
@@ -187,82 +186,207 @@ function ParameterSection({
const ScreenSelector = ({}) => {
const navigation = useNavigation();
const [open, setOpen] = useState(false);
const screenList = useMemo(
() =>
(
Object.keys(navigationScreens) as (keyof typeof navigationScreens)[]
).sort(),
[],
);
return (
<Select
open={open}
onOpenChange={setOpen}
onValueChange={(screen: keyof RootStackParamList) => {
navigation.navigate(screen as never);
}}
disablePreventBodyScroll
>
<Select.Trigger asChild>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => setOpen(true)}
<>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => setOpen(true)}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Select screen
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
</Select.Trigger>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Select screen
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Adapt when={true} platform="touch">
<Sheet native modal dismissOnSnapToBottom animation="medium">
<Sheet.Frame>
<Sheet.ScrollView>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay
backgroundColor="$shadowColor"
animation="lazy"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
</Sheet>
</Adapt>
<Sheet
modal
open={open}
onOpenChange={setOpen}
snapPoints={[85]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Select screen
</Text>
<Button
onPress={() => setOpen(false)}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
>
{screenList.map(item => (
<TouchableOpacity
key={item}
onPress={() => {
setOpen(false);
navigation.navigate(item as never);
}}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{item}
</Text>
</XStack>
</TouchableOpacity>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
};
<Select.Content zIndex={200000}>
<Select.Viewport minWidth={200}>
<Select.Group>
{useMemo(
() =>
(
Object.keys(
navigationScreens,
) as (keyof typeof navigationScreens)[]
)
.sort()
.map((item, i) => {
return (
<Select.Item index={i} key={item} value={item}>
<Select.ItemText>{item}</Select.ItemText>
<Select.ItemIndicator marginLeft="auto">
<Check size={16} />
</Select.ItemIndicator>
</Select.Item>
);
}),
[],
)}
</Select.Group>
</Select.Viewport>
</Select.Content>
</Select>
const LogLevelSelector = ({
currentLevel,
onSelect,
}: {
currentLevel: string;
onSelect: (level: 'debug' | 'info' | 'warn' | 'error') => void;
}) => {
const [open, setOpen] = useState(false);
const logLevels = ['debug', 'info', 'warn', 'error'] as const;
return (
<>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => setOpen(true)}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
{currentLevel.toUpperCase()}
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Sheet
modal
open={open}
onOpenChange={setOpen}
snapPoints={[50]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Select log level
</Text>
<Button
onPress={() => setOpen(false)}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView showsVerticalScrollIndicator={false}>
{logLevels.map(level => (
<TouchableOpacity
key={level}
onPress={() => {
setOpen(false);
onSelect(level);
}}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{level.toUpperCase()}
</Text>
{currentLevel === level && (
<Check color={slate600} size={20} />
)}
</XStack>
</TouchableOpacity>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
};
@@ -526,57 +650,6 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
paddingTop="$4"
paddingBottom={paddingBottom}
>
<ParameterSection
icon={<IdIcon />}
title="Manage ID Documents"
description="Register new IDs and generate test IDs"
>
{[
{
label: 'Manage available IDs',
onPress: () => {
navigation.navigate('ManageDocuments');
},
},
{
label: 'Generate Test ID',
onPress: () => {
navigation.navigate('CreateMock');
},
},
{
label: 'Scan new ID Document',
onPress: () => {
navigation.navigate('DocumentOnboarding');
},
},
].map(({ label, onPress }) => (
<YStack gap="$2" key={label}>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={onPress}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
{label}
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
</YStack>
))}
</ParameterSection>
<ParameterSection
icon={<BugIcon />}
title="Debug Shortcuts"
@@ -642,11 +715,11 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
>
<YStack gap="$2">
<TopicToggleButton
label="Nova"
label="Starfall"
isSubscribed={
hasNotificationPermission && subscribedTopics.includes('nova')
}
onToggle={() => handleTopicToggle(['nova'], 'Nova')}
onToggle={() => handleTopicToggle(['nova'], 'Starfall')}
/>
<TopicToggleButton
label="General"
@@ -657,7 +730,7 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
onToggle={() => handleTopicToggle(['general'], 'General')}
/>
<TopicToggleButton
label="Both (Nova + General)"
label="Both (Starfall + General)"
isSubscribed={
hasNotificationPermission &&
subscribedTopics.includes('nova') &&
@@ -675,38 +748,10 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
title="Log Level"
description="Configure logging verbosity"
>
<YStack gap="$2">
{(['debug', 'info', 'warn', 'error'] as const).map(level => (
<Button
key={level}
backgroundColor={
loggingSeverity === level ? '$green9' : slate200
}
borderRadius="$2"
height="$5"
onPress={() => {
setLoggingSeverity(level);
}}
flexDirection="row"
justifyContent="space-between"
paddingHorizontal="$4"
pressStyle={{
opacity: 0.8,
scale: 0.98,
}}
>
<Text
color={loggingSeverity === level ? white : slate600}
fontSize="$5"
fontFamily={dinot}
fontWeight="600"
>
{level.toUpperCase()}
</Text>
{loggingSeverity === level && <Check color={white} size={20} />}
</Button>
))}
</YStack>
<LogLevelSelector
currentLevel={loggingSeverity}
onSelect={setLoggingSeverity}
/>
</ParameterSection>
<ParameterSection

View File

@@ -8,7 +8,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Button, ScrollView, Spinner, Text, XStack, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Check, Eraser } from '@tamagui/lucide-icons';
import { Check, Eraser, HousePlus } from '@tamagui/lucide-icons';
import type {
DocumentCatalog,
@@ -33,6 +33,8 @@ import { usePassport } from '@/providers/passportDataProvider';
import { extraYPadding } from '@/utils/styleUtils';
const PassportDataSelector = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const selfClient = useSelfClient();
const {
loadDocumentCatalog,
@@ -73,7 +75,20 @@ const PassportDataSelector = () => {
loadPassportDataInfo();
}, [loadPassportDataInfo]);
const handleDocumentSelection = async (documentId: string) => {
const handleDocumentSelection = async (
documentId: string,
isRegistered: boolean | undefined,
) => {
if (!isRegistered) {
Alert.alert(
'Document not registered',
'This document cannot be selected as active, because it is not registered. Click the add button next to it to register it first.',
[{ text: 'OK', style: 'cancel' }],
);
return;
}
await setSelectedDocument(documentId);
// Reload to update UI without loading state for quick operations
const catalog = await loadDocumentCatalog();
@@ -90,24 +105,40 @@ const PassportDataSelector = () => {
await loadPassportDataInfo();
};
const handleDeleteButtonPress = (documentId: string) => {
Alert.alert(
'⚠️ Delete Document ⚠️',
'Are you sure you want to delete this document?\n\nThis document is already linked to your identity in Self Protocol and cannot be linked by another person.',
[
{
text: 'Cancel',
style: 'cancel',
const handleRegisterDocument = async (documentId: string) => {
try {
await setSelectedDocument(documentId);
navigation.navigate('ConfirmBelonging', {});
} catch {
Alert.alert(
'Registration Error',
'Failed to prepare document for registration. Please try again.',
[{ text: 'OK', style: 'cancel' }],
);
}
};
const handleDeleteButtonPress = (
documentId: string,
isRegistered: boolean | undefined,
) => {
const message = isRegistered
? 'Are you sure you want to delete this document?\n\nThis document is already linked to your identity in Self Protocol and cannot be linked by another person.'
: 'Are you sure you want to delete this document?';
Alert.alert('⚠️ Delete Document ⚠️', message, [
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await handleDeleteSpecific(documentId);
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await handleDeleteSpecific(documentId);
},
},
],
);
},
]);
};
const getDisplayName = (documentType: string): string => {
@@ -156,6 +187,16 @@ const PassportDataSelector = () => {
}
};
const getDocumentBackgroundColor = (
isSelected: boolean,
isRegistered: boolean | undefined,
): string => {
if (!isRegistered) {
return '#ffebee'; // Light red for unregistered documents
}
return isSelected ? '$gray2' : 'white';
};
if (loading) {
return (
<YStack gap="$3" alignItems="center" padding="$4">
@@ -196,6 +237,10 @@ const PassportDataSelector = () => {
);
}
const hasUnregisteredDocuments = documentCatalog.documents.some(
doc => !doc.isRegistered,
);
return (
<YStack gap="$3" width="100%">
<Text
@@ -206,6 +251,21 @@ const PassportDataSelector = () => {
>
Available Documents
</Text>
{hasUnregisteredDocuments && (
<YStack
padding="$3"
backgroundColor="#fff3cd"
borderRadius="$3"
borderWidth={1}
borderColor="#ffc107"
>
<Text color="#856404" fontSize="$3" textAlign="center">
We've detected some documents that are not registered. In order
to use them, you'll have to register them first by clicking the plus
icon next to them.
</Text>
</YStack>
)}
{documentCatalog.documents.map((metadata: DocumentMetadata) => (
<YStack
key={metadata.id}
@@ -217,12 +277,13 @@ const PassportDataSelector = () => {
: borderColor
}
borderRadius="$3"
backgroundColor={
documentCatalog.selectedDocumentId === metadata.id
? '$gray2'
: 'white'
backgroundColor={getDocumentBackgroundColor(
documentCatalog.selectedDocumentId === metadata.id,
metadata.isRegistered,
)}
onPress={() =>
handleDocumentSelection(metadata.id, metadata.isRegistered)
}
onPress={() => handleDocumentSelection(metadata.id)}
pressStyle={{ opacity: 0.8 }}
>
<XStack
@@ -241,7 +302,9 @@ const PassportDataSelector = () => {
}
borderColor={textBlack}
borderWidth={1}
onPress={() => handleDocumentSelection(metadata.id)}
onPress={() =>
handleDocumentSelection(metadata.id, metadata.isRegistered)
}
>
{documentCatalog.selectedDocumentId === metadata.id && (
<Check size={12} color="white" />
@@ -256,19 +319,36 @@ const PassportDataSelector = () => {
</Text>
</YStack>
</XStack>
<Button
backgroundColor="white"
justifyContent="center"
borderColor={borderColor}
borderWidth={1}
size="$3"
onPress={e => {
e.stopPropagation();
handleDeleteButtonPress(metadata.id);
}}
>
<Eraser color={textBlack} size={16} />
</Button>
<XStack gap="$3">
{metadata.isRegistered !== true && (
<Button
backgroundColor="white"
justifyContent="center"
borderColor={borderColor}
borderWidth={1}
size="$3"
onPress={e => {
e.stopPropagation();
handleRegisterDocument(metadata.id);
}}
>
<HousePlus color={textBlack} size={16} />
</Button>
)}
<Button
backgroundColor="white"
justifyContent="center"
borderColor={borderColor}
borderWidth={1}
size="$3"
onPress={e => {
e.stopPropagation();
handleDeleteButtonPress(metadata.id, metadata.isRegistered);
}}
>
<Eraser color={textBlack} size={16} />
</Button>
</XStack>
</XStack>
</YStack>
))}

View File

@@ -16,9 +16,7 @@ import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import analytics from '@/services/analytics';
const { flush: flushAnalytics } = analytics();
import { flush as flushAnalytics } from '@/services/analytics';
const tips: TipProps[] = [
{

View File

@@ -39,6 +39,13 @@ const NFC_METHODS = [
platform: ['ios'],
params: {},
},
{
key: 'skipReselect',
label: 'Skip Re-select',
description: 'Skip the re-select step after the NFC scan.',
platform: ['android'],
params: { skipReselect: true },
},
{
key: 'usePacePolling',
label: 'Use PACE Polling',

View File

@@ -81,6 +81,7 @@ const emitter =
: null;
type DocumentNFCScanRouteParams = {
skipReselect?: boolean;
usePacePolling?: boolean;
canNumber?: string;
useCan?: boolean;
@@ -326,8 +327,14 @@ const DocumentNFCScanScreen: React.FC = () => {
}, 30000);
try {
const { canNumber, useCan, skipPACE, skipCA, extendedMode } =
route.params ?? {};
const {
canNumber,
useCan,
skipPACE,
skipCA,
extendedMode,
skipReselect,
} = route.params ?? {};
await configureNfcAnalytics();
const scanResponse = await scan({
@@ -341,6 +348,7 @@ const DocumentNFCScanScreen: React.FC = () => {
extendedMode,
usePacePolling: isPacePolling,
sessionId: sessionIdRef.current,
skipReselect,
});
// Check if scan was cancelled by timeout

View File

@@ -10,7 +10,15 @@ import React, {
useState,
} from 'react';
import { Dimensions, Image, Pressable } from 'react-native';
import { Button, ScrollView, Text, View, XStack, YStack } from 'tamagui';
import {
Button,
ScrollView,
Spinner,
Text,
View,
XStack,
YStack,
} from 'tamagui';
import {
useFocusEffect,
useIsFocused,
@@ -201,7 +209,7 @@ const HomeScreen: React.FC = () => {
justifyContent="center"
alignItems="center"
>
<Text>Loading documents...</Text>
<Spinner size="large" color={black} />
</YStack>
);
}

View File

@@ -23,6 +23,7 @@ import useMnemonic from '@/hooks/useMnemonic';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { STORAGE_NAME } from '@/services/cloud-backup';
import { useSettingStore } from '@/stores/settingStore';
import { getRecoveryPhraseWarningMessage } from '@/utils/crypto/mnemonic';
const SaveRecoveryPhraseScreen: React.FC = () => {
const [userHasSeenMnemonic, setUserHasSeenMnemonic] = useState(false);
@@ -55,8 +56,7 @@ const SaveRecoveryPhraseScreen: React.FC = () => {
Save your recovery phrase
</Title>
<Description style={{ paddingBottom: 10 }}>
This phrase is the only way to recover your account. Keep it secret,
keep it safe.
{getRecoveryPhraseWarningMessage()}
</Description>
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection

View File

@@ -25,11 +25,9 @@ import useHapticNavigation from '@/hooks/useHapticNavigation';
import { notificationError } from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { SharedRoutesParamList } from '@/navigation/types';
import analytics from '@/services/analytics';
import { flush as flushAnalytics } from '@/services/analytics';
import { sendCountrySupportNotification } from '@/services/email';
const { flush: flushAnalytics } = analytics();
type ComingSoonScreenProps = NativeStackScreenProps<
SharedRoutesParamList,
'ComingSoon'

View File

@@ -0,0 +1,255 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useEffect, useRef, useState } from 'react';
import { ImageBackground, StyleSheet } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { Text, View, YStack } from 'tamagui';
import Clipboard from '@react-native-clipboard/clipboard';
import { useNavigation } from '@react-navigation/native';
import {
PrimaryButton,
SecondaryButton,
} from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
green500,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { advercase, dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import StarfallBackground from '@/assets/images/bg_starfall_push.png';
import { StarfallLogoHeader } from '@/components/starfall/StarfallLogoHeader';
import { StarfallPIN } from '@/components/starfall/StarfallPIN';
import { confirmTap } from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
import { fetchPushCode } from '@/services/starfall/pushCodeService';
const DASH_CODE = '----';
const StarfallPushCodeScreen: React.FC = () => {
const navigation = useNavigation();
const [code, setCode] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isCopied, setIsCopied] = useState(false);
const copyTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleFetchCode = async () => {
try {
setIsLoading(true);
setError(null);
confirmTap();
const walletAddress = await getOrGeneratePointsAddress();
const fetchedCode = await fetchPushCode(walletAddress);
setCode(fetchedCode);
} catch (err) {
console.error('Failed to fetch push code:', err);
setError('Failed to generate code. Please try again.');
setCode(null); // Clear stale code on error
} finally {
setIsLoading(false);
}
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);
const handleRetry = () => {
handleFetchCode();
};
const handleCopyCode = async () => {
if (!code || code === DASH_CODE) {
return;
}
try {
confirmTap();
await Clipboard.setString(code);
setIsCopied(true);
// Clear any existing timeout before creating a new one
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
// Reset after 1.65 seconds
copyTimeoutRef.current = setTimeout(() => {
setIsCopied(false);
copyTimeoutRef.current = null;
}, 1650);
} catch (copyError) {
console.error('Failed to copy to clipboard:', copyError);
}
};
const handleDismiss = () => {
confirmTap();
navigation.goBack();
};
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
<ExpandableBottomLayout.TopSection backgroundColor={black}>
{/* Colorful background image */}
<ImageBackground
source={StarfallBackground}
style={StyleSheet.absoluteFill}
resizeMode="cover"
>
{/* Fade to black overlay - stronger at bottom */}
<LinearGradient
colors={['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.6)', black]}
locations={[0.1, 0.45, 0.6]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={StyleSheet.absoluteFill}
/>
</ImageBackground>
{/* Content container */}
<YStack flex={1} justifyContent="center" alignItems="center">
{/* App logos section */}
<StarfallLogoHeader />
{/* Title and content */}
<YStack
paddingHorizontal={20}
paddingVertical={20}
gap={12}
alignItems="center"
width="100%"
>
<Text
fontFamily={advercase}
fontSize={28}
fontWeight="400"
color={white}
textAlign="center"
letterSpacing={1}
>
Your Starfall code awaits
</Text>
<YStack gap={16} width="100%" alignItems="center">
<View paddingHorizontal={40} width="100%">
<Text
fontFamily={dinot}
fontSize={14}
fontWeight="500"
color={white}
textAlign="center"
>
Open Starfall in Opera MiniPay and enter this four digit code
to continue your journey.
</Text>
</View>
<View width="100%">
<StarfallPIN
code={
code === null || isLoading || error !== null
? DASH_CODE
: code
}
/>
</View>
{/* Error message */}
{error && (
<Text
fontFamily={dinot}
fontSize={14}
fontWeight="500"
color="#ef4444"
textAlign="center"
>
{error}
</Text>
)}
</YStack>
</YStack>
</YStack>
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection
backgroundColor={black}
style={{ backgroundColor: black }}
>
{/* Bottom buttons */}
<YStack gap={10} width="100%">
{/* Debug: Fetch code button or Retry button on error */}
{error ? (
<PrimaryButton
onPress={handleRetry}
disabled={isLoading}
fontSize={16}
style={{
borderColor: '#374151',
borderWidth: 1,
borderRadius: 60,
height: 46,
paddingVertical: 0,
}}
>
Retry
</PrimaryButton>
) : (
<PrimaryButton
onPress={handleFetchCode}
disabled={isLoading}
fontSize={16}
style={{
borderColor: '#374151',
borderWidth: 1,
borderRadius: 60,
height: 46,
paddingVertical: 0,
}}
>
{isLoading ? 'Fetching...' : 'Fetch code'}
</PrimaryButton>
)}
<PrimaryButton
onPress={handleCopyCode}
disabled={isCopied || !code || code === DASH_CODE || isLoading}
fontSize={16}
style={{
backgroundColor: isCopied ? green500 : undefined,
borderColor: '#374151',
borderWidth: 1,
borderRadius: 60,
height: 46,
paddingVertical: 0,
}}
>
{isCopied ? 'Code copied!' : 'Copy code'}
</PrimaryButton>
<SecondaryButton
onPress={handleDismiss}
textColor={black}
fontSize={16}
style={{ borderRadius: 60, height: 46, paddingVertical: 0 }}
>
Dismiss
</SecondaryButton>
</YStack>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
);
};
export default StarfallPushCodeScreen;

View File

@@ -0,0 +1,491 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import { ActivityIndicator, StyleSheet } from 'react-native';
import { Text, View, YStack } from 'tamagui';
import type { RouteProp } from '@react-navigation/native';
import {
useFocusEffect,
useNavigation,
useRoute,
} from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type {
DocumentCatalog,
DocumentMetadata,
IDDocument,
} from '@selfxyz/common/utils/types';
import {
getDocumentAttributes,
isDocumentValidForProving,
useSelfClient,
} from '@selfxyz/mobile-sdk-alpha';
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import type { IDSelectorState } from '@/components/documents';
import { IDSelectorSheet, isDisabledState } from '@/components/documents';
import {
BottomActionBar,
ConnectedWalletBadge,
DisclosureItem,
ProofRequestCard,
proofRequestColors,
truncateAddress,
WalletAddressModal,
} from '@/components/proof-request';
import { useSelfAppData } from '@/hooks/useSelfAppData';
import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { getDocumentTypeName } from '@/utils/documentUtils';
function getDocumentDisplayName(
metadata: DocumentMetadata,
documentData?: IDDocument,
): string {
const category = metadata.documentCategory || '';
const isMock = metadata.mock;
// Extract country information from document data
let countryCode: string | null = null;
if (documentData) {
try {
const attributes = getDocumentAttributes(documentData);
countryCode = attributes.nationalitySlice || null;
} catch {
// If we can't extract attributes, continue without country
}
}
const mockPrefix = isMock ? 'Dev ' : '';
if (category === 'passport') {
const base = 'Passport';
return countryCode
? `${mockPrefix}${countryCode} ${base}`
: `${mockPrefix}${base}`;
} else if (category === 'id_card') {
const base = 'ID Card';
return countryCode
? `${mockPrefix}${countryCode} ${base}`
: `${mockPrefix}${base}`;
} else if (category === 'aadhaar') {
return isMock ? 'Dev Aadhaar ID' : 'Aadhaar ID';
}
return isMock ? `Dev ${metadata.documentType}` : metadata.documentType;
}
function determineDocumentState(
metadata: DocumentMetadata,
documentData: IDDocument | undefined,
): IDSelectorState {
// Use SDK to check if document is valid (not expired)
if (!isDocumentValidForProving(metadata, documentData)) {
return 'expired';
}
// UI-specific state mapping: Mock documents are selectable but marked as developer/mock
if (metadata.mock) {
return 'mock';
}
// Both registered and non-registered real documents are valid for selection
// They will be registered during the proving flow if needed
return 'verified';
}
const DocumentSelectorForProvingScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route =
useRoute<RouteProp<RootStackParamList, 'DocumentSelectorForProving'>>();
const selfClient = useSelfClient();
const { useSelfAppStore } = selfClient;
const selfApp = useSelfAppStore(state => state.selfApp);
const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } =
usePassport();
// Extract SelfApp data using hook
const { logoSource, url, formattedUserId, disclosureItems } =
useSelfAppData(selfApp);
const [documentCatalog, setDocumentCatalog] = useState<DocumentCatalog>({
documents: [],
});
const [allDocuments, setAllDocuments] = useState<
Record<string, { data: IDDocument; metadata: DocumentMetadata }>
>({});
const [selectedDocumentId, setSelectedDocumentId] = useState<
string | undefined
>();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [walletModalOpen, setWalletModalOpen] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const scrollOffsetRef = useRef(0);
const pickInitialDocument = useCallback(
(
catalog: DocumentCatalog,
docs: Record<string, { data: IDDocument; metadata: DocumentMetadata }>,
) => {
if (catalog.selectedDocumentId) {
const selectedMeta = catalog.documents.find(
doc => doc.id === catalog.selectedDocumentId,
);
const selectedData = selectedMeta
? docs[catalog.selectedDocumentId]
: undefined;
if (selectedMeta && selectedData) {
const state = determineDocumentState(selectedMeta, selectedData.data);
if (!isDisabledState(state)) {
return catalog.selectedDocumentId;
}
} else if (selectedMeta) {
return catalog.selectedDocumentId;
}
}
const firstValid = catalog.documents.find(doc => {
const docData = docs[doc.id];
const state = determineDocumentState(doc, docData?.data);
return !isDisabledState(state);
});
return firstValid?.id;
},
[],
);
const loadDocuments = useCallback(async () => {
// Cancel any in-flight request
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
setError(null);
try {
const catalog = await loadDocumentCatalog();
const docs = await getAllDocuments();
// Don't update state if this request was aborted
if (controller.signal.aborted) {
return;
}
setDocumentCatalog(catalog);
setAllDocuments(docs);
setSelectedDocumentId(pickInitialDocument(catalog, docs));
} catch (loadError) {
// Don't show error if this request was aborted
if (controller.signal.aborted) {
return;
}
console.warn('Failed to load documents:', loadError);
setError('Unable to load documents.');
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, [getAllDocuments, loadDocumentCatalog, pickInitialDocument]);
useFocusEffect(
useCallback(() => {
loadDocuments();
}, [loadDocuments]),
);
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
const documents = useMemo(() => {
return documentCatalog.documents
.map(metadata => {
const docData = allDocuments[metadata.id];
const baseState = determineDocumentState(metadata, docData?.data);
const isSelected = metadata.id === selectedDocumentId;
const itemState =
isSelected && !isDisabledState(baseState) ? 'active' : baseState;
return {
id: metadata.id,
name: getDocumentDisplayName(metadata, docData?.data),
state: itemState,
};
})
.sort((a, b) => {
// Get metadata for both documents
const metaA = documentCatalog.documents.find(d => d.id === a.id);
const metaB = documentCatalog.documents.find(d => d.id === b.id);
// Sort real documents before mock documents
if (metaA && metaB) {
if (metaA.mock !== metaB.mock) {
return metaA.mock ? 1 : -1; // Real first
}
}
// Within same type (real/mock), sort alphabetically by name
return a.name.localeCompare(b.name);
});
}, [allDocuments, documentCatalog.documents, selectedDocumentId]);
const selectedDocument = documents.find(doc => doc.id === selectedDocumentId);
const canContinue =
!!selectedDocument && !isDisabledState(selectedDocument.state);
// Get document type for the proof request message
const selectedDocumentType = useMemo(() => {
// If we have a preloaded document type from route params, use it while loading
const preloadedType = route.params?.documentType;
if (loading && preloadedType) {
return preloadedType;
}
if (!selectedDocumentId) return preloadedType || '';
const metadata = documentCatalog.documents.find(
d => d.id === selectedDocumentId,
);
return getDocumentTypeName(metadata?.documentCategory);
}, [
selectedDocumentId,
documentCatalog.documents,
loading,
route.params?.documentType,
]);
const handleSelect = useCallback((documentId: string) => {
setSelectedDocumentId(documentId);
}, []);
const handleSheetSelect = useCallback(async () => {
if (!selectedDocumentId || !canContinue || submitting) {
return;
}
setSubmitting(true);
setError(null);
try {
await setSelectedDocument(selectedDocumentId);
setSheetOpen(false); // Close the sheet first
navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current });
} catch (selectionError) {
console.error('Failed to set selected document:', selectionError);
setError('Failed to select document. Please try again.');
} finally {
setSubmitting(false);
}
}, [
selectedDocumentId,
canContinue,
submitting,
setSelectedDocument,
navigation,
]);
const handleApprove = async () => {
if (!selectedDocumentId || !canContinue || submitting) {
return;
}
setSubmitting(true);
setError(null);
try {
await setSelectedDocument(selectedDocumentId);
navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current });
} catch (selectionError) {
console.error('Failed to set selected document:', selectionError);
setError('Failed to select document. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
},
[],
);
// Loading state
if (loading) {
return (
<View
flex={1}
backgroundColor={proofRequestColors.white}
alignItems="center"
justifyContent="center"
testID="document-selector-loading-container"
>
<ActivityIndicator color={black} size="large" />
</View>
);
}
// Error state
if (error) {
return (
<View
flex={1}
backgroundColor={proofRequestColors.white}
alignItems="center"
justifyContent="center"
gap={16}
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
textAlign="center"
testID="document-selector-error"
>
{error}
</Text>
<View
paddingHorizontal={24}
paddingVertical={12}
borderRadius={8}
borderWidth={1}
borderColor={proofRequestColors.slate200}
onPress={loadDocuments}
pressStyle={{ opacity: 0.7 }}
testID="document-selector-retry"
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
>
Retry
</Text>
</View>
</View>
);
}
// Empty state
if (documents.length === 0) {
return (
<View
flex={1}
backgroundColor={proofRequestColors.white}
alignItems="center"
justifyContent="center"
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
textAlign="center"
paddingHorizontal={40}
testID="document-selector-empty"
>
No documents found. Please scan a document first.
</Text>
</View>
);
}
return (
<View style={[styles.container, { paddingTop: 0 }]}>
{/* Main Content - Proof Request Card */}
<ProofRequestCard
logoSource={logoSource}
appName={selfApp?.appName || 'Self'}
appUrl={url}
documentType={selectedDocumentType}
connectedWalletBadge={
formattedUserId ? (
<ConnectedWalletBadge
address={
selfApp?.userIdType === 'hex'
? truncateAddress(selfApp?.userId || '')
: formattedUserId
}
userIdType={selfApp?.userIdType}
onToggle={() => setWalletModalOpen(true)}
testID="document-selector-wallet-badge"
/>
) : undefined
}
onScroll={handleScroll}
testID="document-selector-card"
>
{/* Disclosure Items */}
<YStack marginTop={0}>
{disclosureItems.map((item, index) => (
<DisclosureItem
key={item.key}
text={item.text}
verified={true}
isLast={index === disclosureItems.length - 1}
testID={`document-selector-disclosure-${item.key}`}
/>
))}
</YStack>
</ProofRequestCard>
{/* Bottom Action Bar */}
<BottomActionBar
selectedDocumentName={selectedDocument?.name || 'Select ID'}
onDocumentSelectorPress={() => setSheetOpen(true)}
onApprovePress={handleApprove}
approveDisabled={!canContinue}
approving={submitting}
testID="document-selector-action-bar"
/>
{/* ID Selector Sheet */}
<IDSelectorSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
documents={documents}
selectedId={selectedDocumentId}
onSelect={handleSelect}
onDismiss={() => setSheetOpen(false)}
onApprove={handleSheetSelect}
testID="document-selector-sheet"
/>
{/* Wallet Address Modal */}
{formattedUserId && selfApp?.userId && (
<WalletAddressModal
visible={walletModalOpen}
onClose={() => setWalletModalOpen(false)}
address={selfApp.userId}
userIdType={selfApp?.userIdType}
testID="document-selector-wallet-modal"
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: white,
},
});
export { DocumentSelectorForProvingScreen };

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import LottieView from 'lottie-react-native';
import React, {
useCallback,
useEffect,
@@ -14,33 +13,33 @@ import type {
LayoutChangeEvent,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView as ScrollViewType,
} from 'react-native';
import { ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
import { Image, Text, View, XStack, YStack } from 'tamagui';
import { useIsFocused, useNavigation } from '@react-navigation/native';
import { StyleSheet, useWindowDimensions } from 'react-native';
import { View, YStack } from 'tamagui';
import type { RouteProp } from '@react-navigation/native';
import {
useIsFocused,
useNavigation,
useRoute,
} from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Eye, EyeOff } from '@tamagui/lucide-icons';
import { isMRZDocument } from '@selfxyz/common';
import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType';
import { formatEndpoint } from '@selfxyz/common/utils/scope';
import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import miscAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json';
import {
BodyText,
Caption,
HeldPrimaryButtonProveScreen,
} from '@selfxyz/mobile-sdk-alpha/components';
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import {
black,
slate300,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import Disclosures from '@/components/Disclosures';
import {
BottomVerifyBar,
ConnectedWalletBadge,
DisclosureItem,
ProofRequestCard,
proofRequestColors,
truncateAddress,
WalletAddressModal,
} from '@/components/proof-request';
import { useSelfAppData } from '@/hooks/useSelfAppData';
import { buttonTap } from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import {
setDefaultDocumentTypeIfNeeded,
@@ -56,35 +55,63 @@ import {
checkDocumentExpiration,
getDocumentAttributes,
} from '@/utils/documentAttributes';
import { formatUserId } from '@/utils/formatUserId';
import { getDocumentTypeName } from '@/utils/documentUtils';
const ProveScreen: React.FC = () => {
const selfClient = useSelfClient();
const { trackEvent } = selfClient;
const { navigate } =
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { navigate } = navigation;
const route = useRoute<RouteProp<RootStackParamList, 'Prove'>>();
const isFocused = useIsFocused();
const { useProvingStore, useSelfAppStore } = selfClient;
const selectedApp = useSelfAppStore(state => state.selfApp);
// Extract SelfApp data using hook
const { logoSource, url, formattedUserId, disclosureItems } =
useSelfAppData(selectedApp);
const selectedAppRef = useRef<typeof selectedApp>(null);
const processedSessionsRef = useRef<Set<string>>(new Set());
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0);
const [scrollViewHeight, setScrollViewHeight] = useState(0);
const [showFullAddress, setShowFullAddress] = useState(false);
const [isDocumentExpired, setIsDocumentExpired] = useState(false);
const [documentType, setDocumentType] = useState('');
const [walletModalOpen, setWalletModalOpen] = useState(false);
const isDocumentExpiredRef = useRef(false);
const scrollViewRef = useRef<ScrollView>(null);
const scrollViewRef = useRef<ScrollViewType>(null);
const hasInitializedScrollStateRef = useRef(false);
const isContentShorterThanScrollView = useMemo(
() => scrollViewContentHeight <= scrollViewHeight,
() => scrollViewContentHeight <= scrollViewHeight + 50,
[scrollViewContentHeight, scrollViewHeight],
);
const isScrollable = useMemo(
() => !isContentShorterThanScrollView,
[isContentShorterThanScrollView],
);
const provingStore = useProvingStore();
const currentState = useProvingStore(state => state.currentState);
const isReadyToProve = currentState === 'ready_to_prove';
// Use window dimensions for dynamic scroll offset padding
// This scales with viewport height rather than using hardcoded platform values
const { height: windowHeight } = useWindowDimensions();
const initialScrollOffset = useMemo(() => {
if (route.params?.scrollOffset === undefined) {
return undefined;
}
// Use ~1.5% of window height as padding to account for minor layout differences
// This scales appropriately across different device sizes
const padding = windowHeight * 0.01;
return route.params.scrollOffset + padding;
}, [route.params?.scrollOffset, windowHeight]);
const { addProofHistory } = useProofHistoryStore();
const { loadDocumentCatalog } = usePassport();
@@ -92,6 +119,7 @@ const ProveScreen: React.FC = () => {
const addHistory = async () => {
if (provingStore.uuid && selectedApp) {
const catalog = await loadDocumentCatalog();
const selectedDocumentId = catalog.selectedDocumentId;
addProofHistory({
@@ -109,21 +137,43 @@ const ProveScreen: React.FC = () => {
}
};
addHistory();
}, [addProofHistory, provingStore.uuid, selectedApp, loadDocumentCatalog]);
}, [addProofHistory, loadDocumentCatalog, provingStore.uuid, selectedApp]);
useEffect(() => {
// Wait for actual measurements before determining initial scroll state
// Both start at 0, causing false-positive on first render
const hasMeasurements = scrollViewContentHeight > 0 && scrollViewHeight > 0;
if (!hasMeasurements || hasInitializedScrollStateRef.current) {
return;
}
// Only auto-enable if content is short enough that no scrolling is needed
if (isContentShorterThanScrollView) {
setHasScrolledToBottom(true);
} else {
setHasScrolledToBottom(false);
}
}, [isContentShorterThanScrollView]);
// If content is long, leave hasScrolledToBottom as false (require scroll)
// Don't explicitly set to false to avoid resetting user's scroll progress
// Mark as initialized so we don't override user's scroll state later
hasInitializedScrollStateRef.current = true;
}, [
isContentShorterThanScrollView,
scrollViewContentHeight,
scrollViewHeight,
]);
useEffect(() => {
if (!isFocused || !selectedApp) {
return;
}
// Reset scroll state tracking for new session
if (selectedAppRef.current?.sessionId !== selectedApp.sessionId) {
hasInitializedScrollStateRef.current = false;
setHasScrolledToBottom(false);
}
setDefaultDocumentTypeIfNeeded();
const checkExpirationAndInit = async () => {
@@ -142,6 +192,9 @@ const ProveScreen: React.FC = () => {
setIsDocumentExpired(isExpired);
isDocumentExpiredRef.current = isExpired;
}
setDocumentType(
getDocumentTypeName(selectedDocument?.data?.documentCategory),
);
} catch (error) {
console.error('Error checking document expiration:', error);
setIsDocumentExpired(false);
@@ -212,43 +265,6 @@ const ProveScreen: React.FC = () => {
enhanceApp();
}, [selectedApp, selfClient]);
const disclosureOptions = useMemo(() => {
return (selectedApp?.disclosures as SelfAppDisclosureConfig) || [];
}, [selectedApp?.disclosures]);
// Format the logo source based on whether it's a URL or base64 string
const logoSource = useMemo(() => {
if (!selectedApp?.logoBase64) {
return null;
}
// Check if the logo is already a URL
if (
selectedApp.logoBase64.startsWith('http://') ||
selectedApp.logoBase64.startsWith('https://')
) {
return { uri: selectedApp.logoBase64 };
}
// Otherwise handle as base64 as before
const base64String = selectedApp.logoBase64.startsWith('data:image')
? selectedApp.logoBase64
: `data:image/png;base64,${selectedApp.logoBase64}`;
return { uri: base64String };
}, [selectedApp?.logoBase64]);
const url = useMemo(() => {
if (!selectedApp?.endpoint) {
return null;
}
return formatEndpoint(selectedApp.endpoint);
}, [selectedApp?.endpoint]);
const formattedUserId = useMemo(
() => formatUserId(selectedApp?.userId, selectedApp?.userIdType),
[selectedApp?.userId, selectedApp?.userIdType],
);
function onVerify() {
provingStore.setUserConfirmed(selfClient);
buttonTap();
@@ -270,7 +286,7 @@ const ProveScreen: React.FC = () => {
}
const { layoutMeasurement, contentOffset, contentSize } =
event.nativeEvent;
const paddingToBottom = 10;
const paddingToBottom = 50;
const isCloseToBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom;
@@ -304,213 +320,80 @@ const ProveScreen: React.FC = () => {
);
const handleScrollViewLayout = useCallback((event: LayoutChangeEvent) => {
setScrollViewHeight(event.nativeEvent.layout.height);
const layoutHeight = event.nativeEvent.layout.height;
setScrollViewHeight(layoutHeight);
}, []);
const handleAddressToggle = useCallback(() => {
if (selectedApp?.userIdType === 'hex') {
setShowFullAddress(!showFullAddress);
buttonTap();
}
}, [selectedApp?.userIdType, showFullAddress]);
return (
<ExpandableBottomLayout.Layout flex={1} backgroundColor={black}>
<ExpandableBottomLayout.TopSection backgroundColor={black}>
<YStack alignItems="center">
{!selectedApp?.sessionId ? (
<LottieView
source={miscAnimation}
autoPlay
loop
resizeMode="cover"
cacheComposition={true}
renderMode="HARDWARE"
style={styles.animation}
speed={1}
progress={0}
<View style={styles.container}>
<ProofRequestCard
logoSource={logoSource}
appName={selectedApp?.appName || 'Self'}
appUrl={url}
documentType={documentType}
connectedWalletBadge={
formattedUserId ? (
<ConnectedWalletBadge
address={
selectedApp?.userIdType === 'hex'
? truncateAddress(selectedApp?.userId || '')
: formattedUserId
}
userIdType={selectedApp?.userIdType}
onToggle={() => setWalletModalOpen(true)}
testID="prove-screen-wallet-badge"
/>
) : (
<YStack alignItems="center" justifyContent="center">
{logoSource && (
<Image
marginBottom={20}
source={logoSource}
width={64}
height={64}
objectFit="contain"
/>
)}
<BodyText
style={{ fontSize: 12, color: slate300, marginBottom: 20 }}
>
{url}
</BodyText>
<BodyText
style={{ fontSize: 24, color: slate300, textAlign: 'center' }}
>
<Text color={white}>{selectedApp.appName}</Text> is requesting
you to prove the following information:
</BodyText>
</YStack>
)}
</YStack>
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection
paddingBottom={20}
backgroundColor={white}
maxHeight={'55%'}
) : undefined
}
onScroll={handleScroll}
scrollViewRef={scrollViewRef}
onContentSizeChange={handleContentSizeChange}
onLayout={handleScrollViewLayout}
initialScrollOffset={initialScrollOffset}
testID="prove-screen-card"
>
<ScrollView
ref={scrollViewRef}
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onLayout={handleScrollViewLayout}
>
<Disclosures disclosures={disclosureOptions} />
{/* Disclosure Items */}
<YStack marginTop={0}>
{disclosureItems.map((item, index) => (
<DisclosureItem
key={item.key}
text={item.text}
verified={true}
isLast={index === disclosureItems.length - 1}
testID={`prove-screen-disclosure-${item.key}`}
/>
))}
</YStack>
</ProofRequestCard>
{/* Display connected wallet or UUID */}
{formattedUserId && (
<View marginTop={20} paddingHorizontal={20}>
<BodyText
style={{
fontSize: 16,
color: black,
fontWeight: '600',
marginBottom: 10,
}}
>
{selectedApp?.userIdType === 'hex'
? 'Connected Wallet'
: 'Connected ID'}
:
</BodyText>
<TouchableOpacity
onPress={handleAddressToggle}
activeOpacity={selectedApp?.userIdType === 'hex' ? 0.7 : 1}
style={{ minHeight: 44 }}
>
<View
backgroundColor={slate300}
padding={15}
borderRadius={8}
marginBottom={10}
>
<XStack alignItems="center" justifyContent="space-between">
<View
flex={1}
marginRight={selectedApp?.userIdType === 'hex' ? 12 : 0}
>
<BodyText
style={{
fontSize: 14,
color: black,
lineHeight: 20,
...(showFullAddress &&
selectedApp?.userIdType === 'hex'
? { fontFamily: 'monospace' }
: {}),
flexWrap: showFullAddress ? 'wrap' : 'nowrap',
}}
>
{selectedApp?.userIdType === 'hex' && showFullAddress
? selectedApp.userId
: formattedUserId}
</BodyText>
</View>
{selectedApp?.userIdType === 'hex' && (
<View alignItems="center" justifyContent="center">
{showFullAddress ? (
<EyeOff size={16} color={black} />
) : (
<Eye size={16} color={black} />
)}
</View>
)}
</XStack>
{selectedApp?.userIdType === 'hex' && (
<BodyText
style={{
fontSize: 12,
color: black,
opacity: 0.6,
marginTop: 4,
}}
>
{showFullAddress
? 'Tap to hide address'
: 'Tap to show full address'}
</BodyText>
)}
</View>
</TouchableOpacity>
</View>
)}
<BottomVerifyBar
onVerify={onVerify}
selectedAppSessionId={selectedApp?.sessionId}
hasScrolledToBottom={hasScrolledToBottom}
isScrollable={isScrollable}
isReadyToProve={isReadyToProve}
isDocumentExpired={isDocumentExpired}
testID="prove-screen-verify-bar"
/>
{/* Display userDefinedData if it exists */}
{selectedApp?.userDefinedData && (
<View marginTop={20} paddingHorizontal={20}>
<BodyText
style={{
fontSize: 16,
color: black,
fontWeight: '600',
marginBottom: 10,
}}
>
Additional Information:
</BodyText>
<View
backgroundColor={slate300}
padding={15}
borderRadius={8}
marginBottom={10}
>
<BodyText
style={{ fontSize: 14, color: black, lineHeight: 20 }}
>
{selectedApp.userDefinedData}
</BodyText>
</View>
</View>
)}
<View marginTop={20}>
<Caption
style={{
textAlign: 'center',
fontSize: 12,
marginBottom: 20,
marginTop: 10,
borderRadius: 4,
paddingBottom: 20,
}}
>
Self will confirm that these details are accurate and none of your
confidential info will be revealed to {selectedApp?.appName}
</Caption>
</View>
</ScrollView>
<HeldPrimaryButtonProveScreen
onVerify={onVerify}
selectedAppSessionId={selectedApp?.sessionId}
hasScrolledToBottom={hasScrolledToBottom}
isReadyToProve={isReadyToProve}
isDocumentExpired={isDocumentExpired}
{formattedUserId && selectedApp?.userId && (
<WalletAddressModal
visible={walletModalOpen}
onClose={() => setWalletModalOpen(false)}
address={selectedApp.userId}
userIdType={selectedApp?.userIdType}
testID="prove-screen-wallet-modal"
/>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
)}
</View>
);
};
export default ProveScreen;
const styles = StyleSheet.create({
animation: {
top: 0,
width: 200,
height: 200,
transform: [{ scale: 2 }, { translateY: -20 }],
container: {
flex: 1,
backgroundColor: proofRequestColors.white,
},
});

View File

@@ -0,0 +1,205 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator } from 'react-native';
import { Text, View } from 'tamagui';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
isDocumentValidForProving,
pickBestDocumentToSelect,
} from '@selfxyz/mobile-sdk-alpha';
import { black } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { proofRequestColors } from '@/components/proof-request';
import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
import { getDocumentTypeName } from '@/utils/documentUtils';
/**
* Router screen for the proving flow that decides whether to skip the document selector.
*
* This screen:
* 1. Loads document catalog and counts valid documents
* 2. Checks skip settings (skipDocumentSelector, skipDocumentSelectorIfSingle)
* 3. Routes to appropriate screen:
* - No valid documents -> DocumentDataNotFound
* - Skip enabled -> auto-select and go to Prove
* - Otherwise -> DocumentSelectorForProving
*/
const ProvingScreenRouter: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } =
usePassport();
const { skipDocumentSelector, skipDocumentSelectorIfSingle } =
useSettingStore();
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const hasRoutedRef = useRef(false);
const loadAndRoute = useCallback(async () => {
// Cancel any in-flight request
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
// Prevent double routing
if (hasRoutedRef.current) {
return;
}
setError(null);
try {
const catalog = await loadDocumentCatalog();
const docs = await getAllDocuments();
// Don't continue if this request was aborted
if (controller.signal.aborted) {
return;
}
// Count valid documents
const validDocuments = catalog.documents.filter(doc => {
const docData = docs[doc.id];
return isDocumentValidForProving(doc, docData?.data);
});
const validCount = validDocuments.length;
// Mark as routed to prevent re-routing
hasRoutedRef.current = true;
// Route based on document availability and skip settings
if (validCount === 0) {
// No valid documents - redirect to onboarding
navigation.replace('DocumentDataNotFound');
return;
}
// Determine document type from first valid document for display
const firstValidDoc = validDocuments[0];
const documentType = getDocumentTypeName(firstValidDoc?.documentCategory);
// Determine if we should skip the selector
const shouldSkip =
skipDocumentSelector ||
(skipDocumentSelectorIfSingle && validCount === 1);
if (shouldSkip) {
// Auto-select and navigate to Prove
const docToSelect = pickBestDocumentToSelect(catalog, docs);
if (docToSelect) {
try {
await setSelectedDocument(docToSelect);
navigation.replace('Prove');
} catch (selectError) {
console.error('Failed to auto-select document:', selectError);
// On error, fall back to showing the selector
hasRoutedRef.current = false;
navigation.replace('DocumentSelectorForProving', {
documentType,
});
}
} else {
// No valid document to select, show selector
navigation.replace('DocumentSelectorForProving', {
documentType,
});
}
} else {
// Show the document selector
navigation.replace('DocumentSelectorForProving', {
documentType,
});
}
} catch (loadError) {
// Don't show error if this request was aborted
if (controller.signal.aborted) {
return;
}
console.warn('Failed to load documents for routing:', loadError);
setError('Unable to load documents.');
// Reset routed flag to allow retry
hasRoutedRef.current = false;
}
}, [
getAllDocuments,
loadDocumentCatalog,
navigation,
setSelectedDocument,
skipDocumentSelector,
skipDocumentSelectorIfSingle,
]);
useFocusEffect(
useCallback(() => {
// Reset routing flag when screen gains focus
hasRoutedRef.current = false;
loadAndRoute();
}, [loadAndRoute]),
);
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
return (
<View
flex={1}
backgroundColor={proofRequestColors.white}
alignItems="center"
justifyContent="center"
testID="proving-router-container"
>
{error ? (
<View alignItems="center" gap={16}>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
textAlign="center"
testID="proving-router-error"
>
{error}
</Text>
<View
paddingHorizontal={24}
paddingVertical={12}
borderRadius={8}
borderWidth={1}
borderColor={proofRequestColors.slate200}
onPress={() => {
hasRoutedRef.current = false;
loadAndRoute();
}}
pressStyle={{ opacity: 0.7 }}
testID="proving-router-retry"
>
<Text
fontFamily={dinot}
fontSize={16}
color={proofRequestColors.slate500}
>
Retry
</Text>
</View>
</View>
) : (
<>
<ActivityIndicator color={black} size="large" />
</>
)}
</View>
);
};
export { ProvingScreenRouter };

View File

@@ -48,7 +48,7 @@ const QRCodeViewFinderScreen: React.FC = () => {
const isFocused = useIsFocused();
const [doneScanningQR, setDoneScanningQR] = useState(false);
const { top: safeAreaTop } = useSafeAreaInsets();
const navigateToProve = useHapticNavigation('Prove');
const navigateToDocumentSelector = useHapticNavigation('ProvingScreenRouter');
// This resets to the default state when we navigate back to this screen
useFocusEffect(
@@ -91,7 +91,7 @@ const QRCodeViewFinderScreen: React.FC = () => {
.startAppListener(selfAppJson.sessionId);
setTimeout(() => {
navigateToProve();
navigateToDocumentSelector();
}, 100);
} catch (parseError) {
trackEvent(ProofEvents.QR_SCAN_FAILED, {
@@ -115,7 +115,7 @@ const QRCodeViewFinderScreen: React.FC = () => {
selfClient.getSelfAppState().startAppListener(sessionId);
setTimeout(() => {
navigateToProve();
navigateToDocumentSelector();
}, 100);
} else {
trackEvent(ProofEvents.QR_SCAN_FAILED, {
@@ -129,7 +129,13 @@ const QRCodeViewFinderScreen: React.FC = () => {
}
}
},
[doneScanningQR, navigation, navigateToProve, trackEvent, selfClient],
[
doneScanningQR,
navigation,
navigateToDocumentSelector,
trackEvent,
selfClient,
],
);
const shouldRenderCamera = !connectionModalVisible && !doneScanningQR;

View File

@@ -13,9 +13,19 @@ import type { TrackEventParams } from '@selfxyz/mobile-sdk-alpha';
import { createSegmentClient } from '@/config/segment';
import { PassportReader } from '@/integrations/nfc/passportReader';
// ============================================================================
// Constants
// ============================================================================
const MIXPANEL_AUTO_FLUSH_THRESHOLD = 5;
const MAX_EVENT_QUEUE_SIZE = 100;
// ============================================================================
// State Management
// ============================================================================
const segmentClient = createSegmentClient();
// --- Analytics flush strategy ---
let mixpanelConfigured = false;
let eventCount = 0;
let isConnected = true;
@@ -25,6 +35,10 @@ const eventQueue: Array<{
properties?: Record<string, unknown>;
}> = [];
// ============================================================================
// Internal Helpers - JSON Coercion
// ============================================================================
function coerceToJsonValue(
value: unknown,
seen = new WeakSet(),
@@ -93,64 +107,53 @@ function validateParams(
return cleanParams(validatedProps);
}
/*
Records analytics events and screen views
In development mode, events are logged to console instead of being sent to Segment
// ============================================================================
// Internal Helpers - Event Tracking
// ============================================================================
/**
* Internal tracking function used by trackEvent and trackScreenView
* Records analytics events and screen views
* In development mode, events are logged to console instead of being sent to Segment
*
* NOTE: Screen views are tracked as 'Screen Viewed' events for Mixpanel compatibility
*/
const analytics = () => {
function _track(
type: 'event' | 'screen',
eventName: string,
properties?: Record<string, unknown>,
) {
// Validate and clean properties
const validatedProps = validateParams(properties);
function _track(
type: 'event' | 'screen',
eventName: string,
properties?: Record<string, unknown>,
) {
// Transform screen events for Mixpanel compatibility
const finalEventName = type === 'screen' ? `Viewed ${eventName}` : eventName;
if (__DEV__) {
console.log(`[DEV: Analytics ${type.toUpperCase()}]`, {
name: eventName,
properties: validatedProps,
});
return;
}
// Validate and clean properties
const validatedProps = validateParams(properties);
if (!segmentClient) {
return;
}
const trackMethod = (e: string, p?: JsonMap) =>
type === 'screen'
? segmentClient.screen(e, p)
: segmentClient.track(e, p);
if (!validatedProps) {
// you may need to remove the catch when debugging
return trackMethod(eventName).catch(console.info);
}
// you may need to remove the catch when debugging
trackMethod(eventName, validatedProps).catch(console.info);
if (__DEV__) {
console.log(`[DEV: Analytics ${type.toUpperCase()}]`, {
name: finalEventName,
properties: validatedProps,
});
return;
}
return {
// Using LiteralCheck will allow constants but not plain string literals
trackEvent: (eventName: string, properties?: TrackEventParams) => {
_track('event', eventName, properties);
},
trackScreenView: (
screenName: string,
properties?: Record<string, unknown>,
) => {
_track('screen', screenName, properties);
},
flush: () => {
if (!__DEV__ && segmentClient) {
segmentClient.flush();
}
},
};
};
if (!segmentClient) {
return;
}
export default analytics;
// Always use track() for both events and screen views (Mixpanel compatibility)
if (!validatedProps) {
// you may need to remove the catch when debugging
return segmentClient.track(finalEventName).catch(console.info);
}
// you may need to remove the catch when debugging
segmentClient.track(finalEventName, validatedProps).catch(console.info);
}
// ============================================================================
// Public API - Segment Analytics
// ============================================================================
/**
* Cleanup function to clear event queues
@@ -160,6 +163,117 @@ export const cleanupAnalytics = () => {
eventCount = 0;
};
// ============================================================================
// Public API - Mixpanel NFC Analytics
// ============================================================================
export const configureNfcAnalytics = async () => {
if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return;
const enableDebugLogs = ENABLE_DEBUG_LOGS;
// Check if PassportReader and configure method exist (Android doesn't have configure)
if (PassportReader && typeof PassportReader.configure === 'function') {
try {
// iOS configure method only accepts token and enableDebugLogs
// Android doesn't have this method at all
await Promise.resolve(
PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs),
);
} catch (error) {
console.warn('Failed to configure NFC analytics:', error);
}
}
setupFlushPolicies();
mixpanelConfigured = true;
};
/**
* Flush any pending analytics events immediately
*/
export const flush = () => {
if (!__DEV__ && segmentClient) {
segmentClient.flush();
}
};
/**
* Consolidated analytics flush function that flushes both Segment and Mixpanel events
* This should be called when you want to ensure all analytics events are sent immediately
*/
export const flushAllAnalytics = () => {
// Flush Segment analytics
flush();
// Never flush Mixpanel during active NFC scanning to prevent interference
if (!isNfcScanningActive) {
flushMixpanelEvents().catch(console.warn);
}
};
/**
* Set NFC scanning state to prevent analytics flush interference
*/
export const setNfcScanningActive = (active: boolean) => {
isNfcScanningActive = active;
if (__DEV__)
console.log(
`[NFC Analytics] Scanning state: ${active ? 'active' : 'inactive'}`,
);
// Flush queued events when scanning completes
if (!active && eventQueue.length > 0) {
flushMixpanelEvents().catch(console.warn);
}
};
/**
* Track an analytics event
* @param eventName - Name of the event to track
* @param properties - Optional properties to attach to the event
*/
export const trackEvent = (
eventName: string,
properties?: TrackEventParams,
) => {
_track('event', eventName, properties);
};
export const trackNfcEvent = async (
name: string,
properties?: Record<string, unknown>,
) => {
if (!MIXPANEL_NFC_PROJECT_TOKEN) return;
if (!mixpanelConfigured) await configureNfcAnalytics();
if (!isConnected || isNfcScanningActive) {
if (eventQueue.length >= MAX_EVENT_QUEUE_SIZE) {
if (__DEV__)
console.warn('[Mixpanel] Event queue full, dropping oldest event');
eventQueue.shift();
}
eventQueue.push({ name, properties });
return;
}
try {
if (PassportReader && PassportReader.trackEvent) {
await Promise.resolve(PassportReader.trackEvent(name, properties));
}
eventCount++;
// Prevent automatic flush during NFC scanning
if (eventCount >= MIXPANEL_AUTO_FLUSH_THRESHOLD && !isNfcScanningActive) {
flushMixpanelEvents().catch(console.warn);
}
} catch {
if (eventQueue.length >= MAX_EVENT_QUEUE_SIZE) {
if (__DEV__)
console.warn('[Mixpanel] Event queue full, dropping oldest event');
eventQueue.shift();
}
eventQueue.push({ name, properties });
}
};
const setupFlushPolicies = () => {
AppState.addEventListener('change', (state: AppStateStatus) => {
// Never flush during active NFC scanning to prevent interference
@@ -221,84 +335,14 @@ const flushMixpanelEvents = async () => {
}
};
// --- Mixpanel NFC Analytics ---
export const configureNfcAnalytics = async () => {
if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return;
const enableDebugLogs =
String(ENABLE_DEBUG_LOGS ?? '')
.trim()
.toLowerCase() === 'true';
// Check if PassportReader and configure method exist (Android doesn't have configure)
if (PassportReader && typeof PassportReader.configure === 'function') {
try {
// iOS configure method only accepts token and enableDebugLogs
// Android doesn't have this method at all
await Promise.resolve(
PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs),
);
} catch (error) {
console.warn('Failed to configure NFC analytics:', error);
}
}
setupFlushPolicies();
mixpanelConfigured = true;
};
/**
* Consolidated analytics flush function that flushes both Segment and Mixpanel events
* This should be called when you want to ensure all analytics events are sent immediately
* Track a screen view
* @param screenName - Name of the screen to track
* @param properties - Optional properties to attach to the screen view
*/
export const flushAllAnalytics = () => {
// Flush Segment analytics
const { flush: flushAnalytics } = analytics();
flushAnalytics();
// Never flush Mixpanel during active NFC scanning to prevent interference
if (!isNfcScanningActive) {
flushMixpanelEvents().catch(console.warn);
}
};
/**
* Set NFC scanning state to prevent analytics flush interference
*/
export const setNfcScanningActive = (active: boolean) => {
isNfcScanningActive = active;
if (__DEV__)
console.log(
`[NFC Analytics] Scanning state: ${active ? 'active' : 'inactive'}`,
);
// Flush queued events when scanning completes
if (!active && eventQueue.length > 0) {
flushMixpanelEvents().catch(console.warn);
}
};
export const trackNfcEvent = async (
name: string,
export const trackScreenView = (
screenName: string,
properties?: Record<string, unknown>,
) => {
if (!MIXPANEL_NFC_PROJECT_TOKEN) return;
if (!mixpanelConfigured) await configureNfcAnalytics();
if (!isConnected || isNfcScanningActive) {
eventQueue.push({ name, properties });
return;
}
try {
if (PassportReader && PassportReader.trackEvent) {
await Promise.resolve(PassportReader.trackEvent(name, properties));
}
eventCount++;
// Prevent automatic flush during NFC scanning
if (eventCount >= 5 && !isNfcScanningActive) {
flushMixpanelEvents().catch(console.warn);
}
} catch {
eventQueue.push({ name, properties });
}
_track('screen', screenName, properties);
};

View File

@@ -56,33 +56,11 @@ const DocumentLogger = Logger.extend('DOCUMENT');
//Native Modules
const NfcLogger = Logger.extend('NFC');
// Collect all extended loggers for severity updates
const extendedLoggers = [
AppLogger,
NotificationLogger,
AuthLogger,
PassportLogger,
ProofLogger,
SettingsLogger,
BackupLogger,
MockDataLogger,
DocumentLogger,
NfcLogger,
];
// Subscribe to settings store changes to update logger severity dynamically
// Extended loggers are independent instances, so we need to update each one
// Note: Dynamically created loggers (e.g., in nativeLoggerBridge for unknown categories)
// will inherit the severity at creation time but won't receive runtime updates
let previousSeverity = initialSeverity;
useSettingStore.subscribe(state => {
if (state.loggingSeverity !== previousSeverity) {
Logger.setSeverity(state.loggingSeverity);
// Update all extended loggers since they don't inherit runtime changes
// Extended loggers have setSeverity at runtime, even if not in type definition
extendedLoggers.forEach(extLogger => {
(extLogger as typeof Logger).setSeverity(state.loggingSeverity);
});
previousSeverity = state.loggingSeverity;
}
});

View File

@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { POINTS_API_BASE_URL } from '@/services/points/constants';
const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
/**
* Fetches a one-time push code for the specified wallet address.
* The code has a TTL of 30 minutes and refreshes with each call.
*
* @param walletAddress - The wallet address to generate a push code for
* @returns The 4-digit push code as a string
* @throws Error if the API request fails or times out
*/
export async function fetchPushCode(walletAddress: string): Promise<string> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, REQUEST_TIMEOUT_MS);
try {
const response = await fetch(
`${POINTS_API_BASE_URL}/push/wallet/${walletAddress}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: controller.signal,
},
);
// Clear timeout on successful response
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`Failed to fetch push code: ${response.status} ${response.statusText}`,
);
}
const code = await response.json();
// The API returns a JSON string like "5932"
if (typeof code !== 'string' || code.length !== 4) {
throw new Error('Invalid push code format received from API');
}
return code;
} catch (error) {
// Clear timeout on error
clearTimeout(timeoutId);
// Handle abort/timeout specifically
if (error instanceof Error && error.name === 'AbortError') {
console.error('Push code request timed out');
throw new Error(
'Request timed out. Please check your connection and try again.',
);
}
console.error('Error fetching push code:', error);
throw error;
}
}

View File

@@ -14,6 +14,9 @@ const STALE_PROOF_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
SQLite.enablePromise(true);
const toInsertId = (result: SQLite.ResultSet) =>
result.insertId ? result.insertId.toString() : '0';
async function openDatabase() {
return SQLite.openDatabase({
name: DB_NAME,
@@ -127,8 +130,9 @@ export const database: ProofDB = {
proof.documentId,
],
);
// Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId
return {
id: insertResult.insertId.toString(),
id: toInsertId(insertResult),
timestamp,
rowsAffected: insertResult.rowsAffected,
};
@@ -154,8 +158,9 @@ export const database: ProofDB = {
proof.documentId,
],
);
// Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId
return {
id: insertResult.insertId.toString(),
id: toInsertId(insertResult),
timestamp,
rowsAffected: insertResult.rowsAffected,
};
@@ -182,8 +187,9 @@ export const database: ProofDB = {
proof.documentId,
],
);
// Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId
return {
id: insertResult.insertId.toString(),
id: toInsertId(insertResult),
timestamp,
rowsAffected: insertResult.rowsAffected,
};

View File

@@ -34,8 +34,12 @@ interface PersistedSettingsState {
setKeychainMigrationCompleted: () => void;
setLoggingSeverity: (severity: LoggingSeverity) => void;
setPointsAddress: (address: string | null) => void;
setSkipDocumentSelector: (value: boolean) => void;
setSkipDocumentSelectorIfSingle: (value: boolean) => void;
setSubscribedTopics: (topics: string[]) => void;
setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void;
skipDocumentSelector: boolean;
skipDocumentSelectorIfSingle: boolean;
subscribedTopics: string[];
toggleCloudBackupEnabled: () => void;
turnkeyBackupEnabled: boolean;
@@ -135,6 +139,14 @@ export const useSettingStore = create<SettingsState>()(
setPointsAddress: (address: string | null) =>
set({ pointsAddress: address }),
// Document selector skip settings
skipDocumentSelector: false,
setSkipDocumentSelector: (value: boolean) =>
set({ skipDocumentSelector: value }),
skipDocumentSelectorIfSingle: true,
setSkipDocumentSelectorIfSingle: (value: boolean) =>
set({ skipDocumentSelectorIfSingle: value }),
// Non-persisted state (will not be saved to storage)
hideNetworkModal: false,
setHideNetworkModal: (hideNetworkModal: boolean) => {

View File

@@ -11,6 +11,7 @@ declare module 'react-native-passport-reader' {
useCan: boolean;
quality?: number;
sessionId?: string;
skipReselect?: boolean;
}
interface PassportReader {

View File

@@ -4,8 +4,19 @@
import { ethers } from 'ethers';
import { STORAGE_NAME } from '@/services/cloud-backup';
import type { Mnemonic } from '@/types/mnemonic';
/**
* Gets the recovery phrase warning message based on the current platform.
* The message mentions cloud backup options available for the OS.
* @returns The recovery phrase warning message
*/
export function getRecoveryPhraseWarningMessage(): string {
const cloudBackupName = STORAGE_NAME;
return `Using this phrase or ${cloudBackupName} backup are the only ways to recover your account. Keep it secret, keep it safe.`;
}
/**
* Type guard to check if an object is a valid Mnemonic
* @param obj The object to check

View File

@@ -8,4 +8,4 @@
* Use this constant instead of checking __DEV__ directly throughout the codebase.
*/
export const IS_DEV_MODE = typeof __DEV__ !== 'undefined' && __DEV__;
export const IS_EUCLID_ENABLED = false; //IS_DEV_MODE; // just in case we forgot to turn it off before pushing to prod.
export const IS_EUCLID_ENABLED = false; // Enabled for proof request UI redesign

View File

@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { Country3LetterCode } from '@selfxyz/common/constants';
import { countryCodes } from '@selfxyz/common/constants';
import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType';
function listToString(list: string[]): string {
if (list.length === 1) {
return list[0];
} else if (list.length === 2) {
return list.join(' nor ');
}
return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`;
}
function countriesToSentence(countries: Country3LetterCode[]): string {
return listToString(countries.map(country => countryCodes[country]));
}
export const ORDERED_DISCLOSURE_KEYS: Array<keyof SelfAppDisclosureConfig> = [
'issuing_state',
'name',
'passport_number',
'nationality',
'date_of_birth',
'gender',
'expiry_date',
'ofac',
'excludedCountries',
'minimumAge',
] as const;
export function getDisclosureItems(
disclosures: SelfAppDisclosureConfig,
): Array<{ key: string; text: string }> {
const items: Array<{ key: string; text: string }> = [];
for (const key of ORDERED_DISCLOSURE_KEYS) {
const isEnabled = disclosures[key];
if (!isEnabled || (Array.isArray(isEnabled) && isEnabled.length === 0)) {
continue;
}
const text = getDisclosureText(key, disclosures);
if (text) {
items.push({ key, text });
}
}
return items;
}
/**
* Generates the display text for a disclosure key.
* This is the single source of truth for disclosure text across the app.
*/
export function getDisclosureText(
key: keyof SelfAppDisclosureConfig,
disclosures: SelfAppDisclosureConfig,
): string {
switch (key) {
case 'ofac':
return 'I am not on the OFAC sanction list';
case 'excludedCountries':
return `I am not a citizen of the following countries: ${countriesToSentence(
(disclosures.excludedCountries as Country3LetterCode[]) || [],
)}`;
case 'minimumAge':
return `Age is over ${disclosures.minimumAge}`;
case 'name':
return 'Name';
case 'passport_number':
return 'Passport Number';
case 'date_of_birth':
return 'Date of Birth';
case 'gender':
return 'Gender';
case 'expiry_date':
return 'Passport Expiry Date';
case 'issuing_state':
return 'Issuing State';
case 'nationality':
return 'Nationality';
default:
return '';
}
}

View File

@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/**
* Gets the document type display name for the proof request message.
*/
export function getDocumentTypeName(category: string | undefined): string {
switch (category) {
case 'passport':
return 'Passport';
case 'id_card':
return 'ID Card';
case 'aadhaar':
return 'Aadhaar';
default:
return 'Document';
}
}

View File

@@ -39,6 +39,9 @@ export { extraYPadding, normalizeBorderWidth } from '@/utils/styleUtils';
// JSON utilities
export { formatUserId } from '@/utils/formatUserId';
// Document utilities
export { getDocumentTypeName } from '@/utils/documentUtils';
export {
getModalCallbacks,
registerModalCallbacks,

View File

@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export type KeychainErrorIdentity = {
code?: string;
name?: string;
};
type KeychainError = {
code?: string;
message?: string;
name?: string;
};
export type KeychainErrorType = 'user_cancelled' | 'crypto_failed';
export function getKeychainErrorIdentity(
error: unknown,
): KeychainErrorIdentity {
const err = error as KeychainError;
return { code: err?.code, name: err?.name };
}
export function isKeychainCryptoError(error: unknown): boolean {
const err = error as KeychainError;
return Boolean(
(err?.code === 'E_CRYPTO_FAILED' ||
err?.name === 'com.oblador.keychain.exceptions.CryptoFailedException' ||
err?.message?.includes('CryptoFailedException') ||
err?.message?.includes('Decryption failed') ||
err?.message?.includes('Authentication tag verification failed')) &&
!isUserCancellation(error),
);
}
export function isUserCancellation(error: unknown): boolean {
const err = error as KeychainError;
return Boolean(
err?.code === 'E_AUTHENTICATION_FAILED' ||
err?.code === 'USER_CANCELED' ||
err?.message?.includes('User canceled') ||
err?.message?.includes('Authentication canceled') ||
err?.message?.includes('cancelled by user'),
);
}

View File

@@ -9,10 +9,23 @@ jest.mock('@react-navigation/native', () => {
const MockNavigator = (props, _ref) => props.children;
MockNavigator.displayName = 'MockNavigator';
// `useFocusEffect` should behave like an effect: it should not synchronously run
// on every re-render, otherwise any state updates inside the callback can cause
// an infinite render loop in tests.
const focusEffectCallbacks = new WeakSet();
return {
useFocusEffect: jest.fn(callback => {
// Immediately invoke the effect for testing without requiring a container
return callback();
// Invoke only once per callback instance (per component mount), similar to
// how a real focus effect would run on focus rather than every render.
if (
typeof callback === 'function' &&
!focusEffectCallbacks.has(callback)
) {
focusEffectCallbacks.add(callback);
return callback();
}
return undefined;
}),
useNavigation: jest.fn(() => ({
navigate: jest.fn(),

View File

@@ -54,10 +54,18 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
const Text = jest.fn(({ children, ...props }) => children || null);
Text.displayName = 'MockText';
const Title = jest.fn(({ children, ...props }) => children || null);
Title.displayName = 'MockTitle';
const View = jest.fn(({ children, ...props }) => children || null);
View.displayName = 'MockView';
return {
__esModule: true,
Button,
XStack,
Title,
View,
// Provide minimal Text to satisfy potential usages
Text,
};
@@ -175,6 +183,10 @@ jest.mock('@tamagui/lucide-icons', () => {
ExternalLink: makeIcon('external-link'),
X: makeIcon('x'),
Clipboard: makeIcon('clipboard'),
Check: makeIcon('check'),
Circle: makeIcon('circle'),
ChevronDown: makeIcon('chevron-down'),
ChevronLeft: makeIcon('chevron-left'),
};
});

View File

@@ -0,0 +1,225 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { fireEvent, render } from '@testing-library/react-native';
import type { IDSelectorDocument } from '@/components/documents';
import { IDSelectorItem, IDSelectorSheet } from '@/components/documents';
describe('IDSelectorItem', () => {
const mockOnPress = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders with testID', () => {
const { getByTestId } = render(
<IDSelectorItem
documentName="EU ID"
state="active"
onPress={mockOnPress}
testID="test-item"
/>,
);
expect(getByTestId('test-item')).toBeTruthy();
});
it('calls onPress when pressed on active state', () => {
const { getByTestId } = render(
<IDSelectorItem
documentName="EU ID"
state="active"
onPress={mockOnPress}
testID="test-item"
/>,
);
fireEvent.press(getByTestId('test-item'));
expect(mockOnPress).toHaveBeenCalledTimes(1);
});
it('calls onPress when pressed on verified state', () => {
const { getByTestId } = render(
<IDSelectorItem
documentName="FRA Passport"
state="verified"
onPress={mockOnPress}
testID="test-item"
/>,
);
fireEvent.press(getByTestId('test-item'));
expect(mockOnPress).toHaveBeenCalledTimes(1);
});
it('renders different states correctly', () => {
// Render active state
const { rerender, getByTestId } = render(
<IDSelectorItem
documentName="EU ID"
state="active"
onPress={mockOnPress}
testID="test-item"
/>,
);
expect(getByTestId('test-item')).toBeTruthy();
// Rerender with verified state
rerender(
<IDSelectorItem
documentName="FRA Passport"
state="verified"
onPress={mockOnPress}
testID="test-item"
/>,
);
expect(getByTestId('test-item')).toBeTruthy();
// Rerender with expired state
rerender(
<IDSelectorItem
documentName="Aadhaar ID"
state="expired"
onPress={mockOnPress}
testID="test-item"
/>,
);
expect(getByTestId('test-item')).toBeTruthy();
// Rerender with mock state
rerender(
<IDSelectorItem
documentName="Dev USA Passport"
state="mock"
onPress={mockOnPress}
testID="test-item"
/>,
);
expect(getByTestId('test-item')).toBeTruthy();
});
});
describe('IDSelectorSheet', () => {
const mockDocuments: IDSelectorDocument[] = [
{ id: 'doc1', name: 'EU ID', state: 'verified' },
{ id: 'doc2', name: 'FRA Passport', state: 'verified' },
{ id: 'doc3', name: 'Dev USA Passport', state: 'mock' },
{ id: 'doc4', name: 'Aadhaar ID', state: 'expired' },
];
const mockOnOpenChange = jest.fn();
const mockOnSelect = jest.fn();
const mockOnDismiss = jest.fn();
const mockOnApprove = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders document items with testIDs', () => {
const { getByTestId } = render(
<IDSelectorSheet
open={true}
onOpenChange={mockOnOpenChange}
documents={mockDocuments}
selectedId="doc1"
onSelect={mockOnSelect}
onDismiss={mockOnDismiss}
onApprove={mockOnApprove}
testID="sheet"
/>,
);
// Document items use Pressable which properly passes testID
expect(getByTestId('sheet-item-doc1')).toBeTruthy();
expect(getByTestId('sheet-item-doc2')).toBeTruthy();
expect(getByTestId('sheet-item-doc3')).toBeTruthy();
expect(getByTestId('sheet-item-doc4')).toBeTruthy();
});
it('calls onSelect when a document item is pressed', () => {
const { getByTestId } = render(
<IDSelectorSheet
open={true}
onOpenChange={mockOnOpenChange}
documents={mockDocuments}
selectedId="doc1"
onSelect={mockOnSelect}
onDismiss={mockOnDismiss}
onApprove={mockOnApprove}
testID="sheet"
/>,
);
// Press doc2 item
fireEvent.press(getByTestId('sheet-item-doc2'));
expect(mockOnSelect).toHaveBeenCalledWith('doc2');
});
it('renders empty list without document items', () => {
const { queryByTestId } = render(
<IDSelectorSheet
open={true}
onOpenChange={mockOnOpenChange}
documents={[]}
selectedId={undefined}
onSelect={mockOnSelect}
onDismiss={mockOnDismiss}
onApprove={mockOnApprove}
testID="sheet"
/>,
);
expect(queryByTestId('sheet-item-doc1')).toBeNull();
expect(queryByTestId('sheet-item-doc2')).toBeNull();
});
it('shows selected document as active', () => {
const { getByTestId } = render(
<IDSelectorSheet
open={true}
onOpenChange={mockOnOpenChange}
documents={mockDocuments}
selectedId="doc1"
onSelect={mockOnSelect}
onDismiss={mockOnDismiss}
onApprove={mockOnApprove}
testID="sheet"
/>,
);
// The selected item should have the check icon (indicating active state)
expect(getByTestId('icon-check')).toBeTruthy();
});
it('calls onSelect with different document IDs', () => {
const { getByTestId } = render(
<IDSelectorSheet
open={true}
onOpenChange={mockOnOpenChange}
documents={mockDocuments}
selectedId="doc1"
onSelect={mockOnSelect}
onDismiss={mockOnDismiss}
onApprove={mockOnApprove}
testID="sheet"
/>,
);
// Press each item and verify the correct ID is passed
fireEvent.press(getByTestId('sheet-item-doc1'));
expect(mockOnSelect).toHaveBeenLastCalledWith('doc1');
fireEvent.press(getByTestId('sheet-item-doc2'));
expect(mockOnSelect).toHaveBeenLastCalledWith('doc2');
fireEvent.press(getByTestId('sheet-item-doc3'));
expect(mockOnSelect).toHaveBeenLastCalledWith('doc3');
fireEvent.press(getByTestId('sheet-item-doc4'));
expect(mockOnSelect).toHaveBeenLastCalledWith('doc4');
});
});

Some files were not shown because too many files have changed in this diff Show More