mirror of
https://github.com/selfxyz/self.git
synced 2026-02-19 02:24:25 -05:00
Merge pull request #1545 from selfxyz/staging
Release to Production - 2026-01-04
This commit is contained in:
@@ -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
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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._
|
||||
29
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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._
|
||||
2
.github/workflows/circuits.yml
vendored
2
.github/workflows/circuits.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/contracts.yml
vendored
2
.github/workflows/contracts.yml
vendored
@@ -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
|
||||
|
||||
53
.github/workflows/core-sdk-ci.yml
vendored
53
.github/workflows/core-sdk-ci.yml
vendored
@@ -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:
|
||||
|
||||
30
.github/workflows/mobile-deploy-auto.yml
vendored
30
.github/workflows/mobile-deploy-auto.yml
vendored
@@ -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
|
||||
|
||||
19
.github/workflows/mobile-deploy.yml
vendored
19
.github/workflows/mobile-deploy.yml
vendored
@@ -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
|
||||
|
||||
49
.github/workflows/qrcode-sdk-ci.yml
vendored
49
.github/workflows/qrcode-sdk-ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
143
app/README.md
143
app/README.md
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
184
app/scripts/setup-macos.sh
Executable 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"
|
||||
3
app/src/assets/icons/checkmark_white.svg
Normal file
3
app/src/assets/icons/checkmark_white.svg
Normal 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 |
BIN
app/src/assets/images/bg_starfall_push.png
Normal file
BIN
app/src/assets/images/bg_starfall_push.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
12
app/src/assets/logos/opera_minipay.svg
Normal file
12
app/src/assets/logos/opera_minipay.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
132
app/src/components/documents/IDSelectorItem.tsx
Normal file
132
app/src/components/documents/IDSelectorItem.tsx
Normal 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';
|
||||
}
|
||||
174
app/src/components/documents/IDSelectorSheet.tsx
Normal file
174
app/src/components/documents/IDSelectorSheet.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
app/src/components/documents/index.ts
Normal file
18
app/src/components/documents/index.ts
Normal 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';
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
170
app/src/components/proof-request/BottomActionBar.tsx
Normal file
170
app/src/components/proof-request/BottomActionBar.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
52
app/src/components/proof-request/BottomVerifyBar.tsx
Normal file
52
app/src/components/proof-request/BottomVerifyBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
app/src/components/proof-request/ConnectedWalletBadge.tsx
Normal file
103
app/src/components/proof-request/ConnectedWalletBadge.tsx
Normal 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)}`;
|
||||
}
|
||||
85
app/src/components/proof-request/DisclosureItem.tsx
Normal file
85
app/src/components/proof-request/DisclosureItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
89
app/src/components/proof-request/ProofMetadataBar.tsx
Normal file
89
app/src/components/proof-request/ProofMetadataBar.tsx
Normal 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}`;
|
||||
}
|
||||
161
app/src/components/proof-request/ProofRequestCard.tsx
Normal file
161
app/src/components/proof-request/ProofRequestCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
app/src/components/proof-request/ProofRequestHeader.tsx
Normal file
104
app/src/components/proof-request/ProofRequestHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
240
app/src/components/proof-request/WalletAddressModal.tsx
Normal file
240
app/src/components/proof-request/WalletAddressModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
40
app/src/components/proof-request/designTokens.ts
Normal file
40
app/src/components/proof-request/designTokens.ts
Normal 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;
|
||||
143
app/src/components/proof-request/icons.tsx
Normal file
143
app/src/components/proof-request/icons.tsx
Normal 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>
|
||||
);
|
||||
68
app/src/components/proof-request/index.ts
Normal file
68
app/src/components/proof-request/index.ts
Normal 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';
|
||||
40
app/src/components/starfall/StarfallLogoHeader.tsx
Normal file
40
app/src/components/starfall/StarfallLogoHeader.tsx
Normal 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>
|
||||
);
|
||||
62
app/src/components/starfall/StarfallPIN.tsx
Normal file
62
app/src/components/starfall/StarfallPIN.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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.',
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
40
app/src/hooks/useProofDisclosureStalenessCheck.ts
Normal file
40
app/src/hooks/useProofDisclosureStalenessCheck.ts
Normal 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]),
|
||||
);
|
||||
}
|
||||
62
app/src/hooks/useSelfAppData.ts
Normal file
62
app/src/hooks/useSelfAppData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
18
app/src/navigation/starfall.ts
Normal file
18
app/src/navigation/starfall.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
|
||||
130
app/src/screens/account/settings/ProofSettingsScreen.tsx
Normal file
130
app/src/screens/account/settings/ProofSettingsScreen.tsx
Normal 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 "Skip when only one
|
||||
document" 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 };
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
255
app/src/screens/starfall/StarfallPushCodeScreen.tsx
Normal file
255
app/src/screens/starfall/StarfallPushCodeScreen.tsx
Normal 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;
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
205
app/src/screens/verification/ProvingScreenRouter.tsx
Normal file
205
app/src/screens/verification/ProvingScreenRouter.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
67
app/src/services/starfall/pushCodeService.ts
Normal file
67
app/src/services/starfall/pushCodeService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
1
app/src/types/reactNativePassportReader.d.ts
vendored
1
app/src/types/reactNativePassportReader.d.ts
vendored
@@ -11,6 +11,7 @@ declare module 'react-native-passport-reader' {
|
||||
useCan: boolean;
|
||||
quality?: number;
|
||||
sessionId?: string;
|
||||
skipReselect?: boolean;
|
||||
}
|
||||
|
||||
interface PassportReader {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
89
app/src/utils/disclosureUtils.ts
Normal file
89
app/src/utils/disclosureUtils.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
19
app/src/utils/documentUtils.ts
Normal file
19
app/src/utils/documentUtils.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
46
app/src/utils/keychainErrors.ts
Normal file
46
app/src/utils/keychainErrors.ts
Normal 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'),
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
225
app/tests/src/components/documents/IDSelectorSheet.test.tsx
Normal file
225
app/tests/src/components/documents/IDSelectorSheet.test.tsx
Normal 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
Reference in New Issue
Block a user