Merge pull request #1418 from selfxyz/release/staging-2025-11-14

Release to Staging - 2025-11-14
This commit is contained in:
Justin Hernandez
2025-11-19 01:52:40 -03:00
committed by GitHub
211 changed files with 18068 additions and 5698 deletions

View File

@@ -1,291 +0,0 @@
---
description: Comprehensive migration strategy and testing-first approach for porting identity verification logic from app to mobile-sdk-alpha package
version: 1.0.0
status: active
owners:
- team: mobile-identity
- team: sdk-platform
lastUpdated: 2025-01-12
specId: mobile-sdk-migration
importanceScore: 90
importanceJustification: Critical framework for systematically migrating core identity verification functionality to a partner-consumable SDK while maintaining quality and testing coverage.
contextUsageNote: If this file is used to add in-context notes, include a single italicized line stating what specific information was used from this file in sentence case.
---
# Mobile SDK Migration Context
## Migration Strategy Overview
### Testing-First Approach
- **Create tests BEFORE migrating logic** to verify functionality works correctly
- **Dual testing environment**: Jest (app) + Vitest (mobile-sdk-alpha)
- **Validation commands**: `yarn test:build` in both app and mobile-sdk-alpha directories
- **Incremental migration**: One checklist item at a time with thorough validation
### Test Environment Differences
#### App (Jest)
- **Location**: `app/` directory
- **Config**: `jest.config.cjs` with React Native preset
- **Setup**: `jest.setup.js` with comprehensive mocks
- **Module mapping**: `@/` → `src/`, `@tests/` → `tests/src/`
- **Test command**: `yarn test:build` (builds deps + types + bundle analysis + tests)
#### Mobile SDK Alpha (Vitest)
- **Location**: `packages/mobile-sdk-alpha/` directory
- **Config**: `vitest.config.ts` with Node environment
- **Setup**: `tests/setup.ts` with console noise suppression
- **Test command**: `yarn test:build` (build + test + types + lint)
### Migration Validation Workflow
1. **Pre-migration**: Create comprehensive tests in mobile-sdk-alpha for target functionality
2. **Migration**: Port logic from app to mobile-sdk-alpha
3. **Validation**: Run `yarn test:build` in both directories
4. **Integration**: Update app to consume mobile-sdk-alpha
5. **Final validation**: Ensure app tests pass with new SDK consumption
## Migration Checklist Items
### 1. Processing Helpers (MRZ)
**Current Location**: `app/src/utils/` (MRZ utilities)
**Target Location**: `packages/mobile-sdk-alpha/src/processing/`
**Testing Strategy**:
- Create MRZ parsing tests with sample passport data
- Test cross-platform compatibility (React Native vs Web)
### 2. Validation Module
**Current Location**: `app/src/utils/` (document validation logic)
**Target Location**: `packages/mobile-sdk-alpha/src/validation/`
**Testing Strategy**:
- Unit tests for each validation rule
- Test with valid/invalid document data
- Test edge cases and error conditions
### 3. Proof Input Generation
**Current Location**: `app/src/utils/proving/`
**Target Location**: `packages/mobile-sdk-alpha/src/proving/`
**Testing Strategy**:
- Test register input generation with mock data
- Test disclose input generation with various scenarios
- Validate TEE input format compliance
### 4. Crypto Adapters
**Current Location**: `app/src/utils/` (crypto utilities)
**Target Location**: `packages/mobile-sdk-alpha/src/crypto/`
**Testing Strategy**:
- Test WebCrypto vs @noble/* fallback detection
- Test CSPRNG generation across platforms
- Test timing-safe comparison functions
- Parity tests between implementations
### 5. TEE Session Management
**Current Location**: `app/src/utils/` (WebSocket handling)
**Target Location**: `packages/mobile-sdk-alpha/src/tee/`
**Testing Strategy**:
- Test WebSocket wrapper with mock server
- Test abort, timeout, and progress events
- Test connection lifecycle management
### 6. Attestation Verification
**Current Location**: `app/src/utils/` (certificate validation)
**Target Location**: `packages/mobile-sdk-alpha/src/attestation/`
**Testing Strategy**:
- Test PCR0 validation with sample data
- Test public key extraction
- Test certificate chain validation
### 7. Protocol Synchronization
**Current Location**: `app/src/utils/` (protocol tree handling)
**Target Location**: `packages/mobile-sdk-alpha/src/protocol/`
**Testing Strategy**:
- Test protocol tree fetching with pagination
- Test TTL cache behavior
- Test rate limiting and exponential backoff
- Test memory bounds enforcement
### 8. Artifact Management
**Current Location**: `app/src/utils/` (manifest handling)
**Target Location**: `packages/mobile-sdk-alpha/src/artifacts/`
**Testing Strategy**:
- Test manifest schema validation
- Test CDN download with caching
- Test signature verification
- Test storage adapter integration
### 9. Sample Applications
**Target Location**: `packages/mobile-sdk-alpha/samples/`
**Testing Strategy**:
- Create React Native demo with MRZ → proof flow
- Create web demo with browser-based MRZ input
- Test iOS `OpenPassport` URL scheme
### 10. SDK Integration into App
**Migration Strategy**:
- Replace existing modules with SDK imports
- Update import paths throughout app
- Validate all existing functionality works
- Ensure no regression in app behavior
### 11. In-SDK Lightweight Demo
**Target Location**: `packages/mobile-sdk-alpha/demo/`
**Testing Strategy**:
- Embedded React Native demo using MRZ → proof flow
- Test theming hooks integration
- Validate build and run instructions
## Testing Best Practices
### Test Data Management
- **Mock data**: Create comprehensive test fixtures for each module
- **Sensitive data**: Never log PII, credentials, or private keys
- **Redaction**: Use consistent patterns for sensitive field masking
- **Environment flags**: Use `DEBUG_SECRETS_TOKEN` for debug-level secrets
### Cross-Platform Testing
- **React Native**: Test on both iOS and Android simulators
- **Web**: Test with browser adapters
- **Platform detection**: Test platform-specific code paths
- **Native modules**: Mock native dependencies appropriately
### Performance Testing
- **Bundle size**: Monitor SDK bundle size impact
- **Memory usage**: Test memory bounds for large operations
- **Network efficiency**: Test rate limiting and caching
- **Startup time**: Measure SDK initialization impact
### Integration Testing
- **End-to-end flows**: Test complete user journeys
- **Error handling**: Test graceful degradation
- **Recovery mechanisms**: Test error recovery and retry logic
- **Backward compatibility**: Ensure existing app functionality works
## Migration Validation Checklist
### Pre-Migration
- [ ] Create comprehensive test suite in mobile-sdk-alpha
- [ ] Define test fixtures and mock data
- [ ] Set up cross-platform testing environment
- [ ] Document current functionality and edge cases
### During Migration
- [ ] Port logic incrementally (one checklist item at a time)
- [ ] Run `yarn test:build` in mobile-sdk-alpha after each item
- [ ] Validate functionality matches original implementation
- [ ] Update documentation and type definitions
- [ ] Re-export new modules via `packages/mobile-sdk-alpha/src/index.ts` and document them in `packages/mobile-sdk-alpha/README.md`
### Post-Migration
- [ ] Update app to consume mobile-sdk-alpha
- [ ] Run `yarn test:build` in app directory
- [ ] Validate all existing app tests pass
- [ ] Test integration with existing app functionality
- [ ] Performance validation and bundle size analysis
### Final Validation
- [ ] End-to-end testing of complete flows
- [ ] Cross-platform compatibility verification
- [ ] Partner SDK consumption testing
- [ ] Documentation and example updates
- [ ] Release preparation and versioning
## Common Migration Patterns
### Module Structure
```typescript
// Before (app/src/utils/module.ts)
export function processData(data: InputType): OutputType {
// Implementation
}
// After (packages/mobile-sdk-alpha/src/module/index.ts)
export function processData(data: InputType): OutputType {
// Same implementation with enhanced error handling
}
// Test (packages/mobile-sdk-alpha/tests/module.test.ts)
describe('processData', () => {
it('should process valid data correctly', () => {
// Test implementation
});
});
```
### Adapter Pattern
```typescript
// Cross-platform adapter interface
export interface ScannerAdapter {
scan(): Promise<ScanResult>;
isSupported(): boolean;
}
// Platform-specific implementations
export class ReactNativeScannerAdapter implements ScannerAdapter {
// React Native implementation
}
export class WebScannerAdapter implements ScannerAdapter {
// Web implementation
}
```
### Error Handling
```typescript
// Consistent error types across SDK
export class SDKError extends Error {
constructor(
message: string,
public code: string,
public details?: Record<string, unknown>
) {
super(message);
this.name = 'SDKError';
}
}
```
## Security & Privacy Considerations
### Data Protection
- **Sensitive data**: Never log PII, credentials, or private keys in production
- **Secure storage**: Use appropriate storage mechanisms for sensitive data
- **Cleanup**: Properly clean up sensitive data after use
- **Validation**: Validate all inputs and outputs for security
### Privacy Features
- **Zero-knowledge proofs**: Ensure privacy-preserving verification
- **Selective disclosure**: Support minimal necessary attribute revelation
- **Identity commitments**: Maintain privacy of identity data
- **Audit trails**: Log access to sensitive operations without exposing data
## Performance Optimization
### Bundle Size
- **Tree shaking**: Ensure all exports support tree shaking
- **Code splitting**: Split large modules into smaller chunks
- **Dependency analysis**: Monitor and optimize dependencies
- **Bundle analysis**: Regular bundle size monitoring
### Runtime Performance
- **Lazy loading**: Load modules only when needed
- **Caching**: Implement appropriate caching strategies
- **Memory management**: Prevent memory leaks in long-running operations
- **Async operations**: Use proper async patterns for non-blocking operations
## Partner SDK Requirements
### API Design
- **Consistent interfaces**: Maintain consistent API patterns
- **Type safety**: Provide comprehensive TypeScript definitions
- **Error handling**: Clear error messages and error codes
- **Documentation**: Comprehensive API documentation
### Integration Support
- **Branding**: Support for partner branding and theming
- **Callbacks**: Async operation callbacks for integration
- **Configuration**: Flexible configuration options
- **Examples**: Comprehensive integration examples
This context provides a comprehensive framework for executing the migration checklist with a testing-first approach, ensuring quality and reliability throughout the migration process.
$END$

View File

@@ -0,0 +1,203 @@
---
description: Critical rules for avoiding out-of-memory issues in tests, specifically preventing nested require() calls that cause pipeline failures
version: 1.0.0
status: active
owners:
- team: mobile-identity
- team: platform-infrastructure
lastUpdated: 2025-01-12
specId: test-memory-optimization
importanceScore: 100
importanceJustification: Prevents catastrophic pipeline failures due to out-of-memory errors caused by nested require() calls, especially with react-native modules in test environments.
contextUsageNote: If this file is used to add in-context notes, include a single italicized line stating what specific information was used from this file in sentence case.
---
# Test Memory Optimization Rules
## Critical: Never Nest require() Calls
### The Problem
Nested `require('react-native')` calls within tests cause **out-of-memory (OOM) errors** in CI/CD pipelines. This happens because:
1. **Module Resolution Loops**: Each nested require can trigger additional module resolution and initialization
2. **Memory Accumulation**: React Native modules are large and complex; nested requires multiply memory usage
3. **Test Environment Overhead**: Jest/Vitest test runners already load modules; nested requires create duplicate module instances
4. **Hermes Parser Issues**: Nested requires can trigger WASM memory issues with hermes-parser
### The Rule
**NEVER create nested `require('react-native')` calls within test files or test setup files.**
### Examples of FORBIDDEN Patterns
#### ❌ FORBIDDEN: Nested require in test files
```typescript
// BAD - This will cause OOM issues
describe('MyComponent', () => {
beforeEach(() => {
const RN = require('react-native');
const Component = require('./MyComponent');
// Component internally does: require('react-native') again
// This creates nested requires = OOM
});
});
```
#### ❌ FORBIDDEN: require() inside module that's required in tests
```typescript
// BAD - If this module is required in tests, it creates nested requires
// app/src/utils/myUtil.ts
export function myFunction() {
const RN = require('react-native'); // Nested if called from test
return RN.Platform.OS;
}
```
#### ❌ FORBIDDEN: Dynamic requires in test hooks
```typescript
// BAD - Dynamic requires in beforeEach/afterEach create nested requires
beforeEach(() => {
jest.resetModules();
const RN = require('react-native'); // First require
const service = require('@/utils/service'); // May internally require RN again
});
```
### Examples of CORRECT Patterns
#### ✅ CORRECT: Use ES6 imports at top level
```typescript
// GOOD - Single import at top level
import { Platform } from 'react-native';
describe('MyComponent', () => {
it('should work', () => {
expect(Platform.OS).toBe('ios');
});
});
```
**Key Rule**: Use `import` statements, not `require()`. React Native is already mocked in setup files (`jest.setup.js` for Jest, `tests/setup.ts` for Vitest), so imports work correctly.
## React Native Module Handling in Tests
### Jest Setup Pattern (app/jest.setup.js)
The project uses a custom require override in `jest.setup.js` to handle React Native mocks:
```javascript
// This is OK - it's in setup file, runs once
const Module = require('module');
const originalRequire = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === 'react-native') {
const RN = originalRequire.apply(this, arguments);
// Add mocks if needed
return RN;
}
return originalRequire.apply(this, arguments);
};
```
**Key Point**: This override runs ONCE during test setup. Tests should NOT create additional require() calls that would trigger this override multiple times.
### Vitest Setup Pattern (packages/mobile-sdk-alpha/tests/setup.ts)
Vitest uses `vi.mock()` to mock React Native:
```typescript
// This is OK - runs once during setup
vi.mock('react-native', () => ({
Platform: { OS: 'web' },
// ... other mocks
}));
```
**Key Point**: Tests should use `import` statements, not `require()`, after mocks are set up.
## Best Practices
1. **Always use ES6 `import` statements** - Never use `require('react')` or `require('react-native')` in test files
2. **Put all imports at the top of the file** - No dynamic imports in hooks
3. **Avoid `jest.resetModules()`** - Only use when absolutely necessary for module initialization tests
4. **Use setup file mocks** - React Native is already mocked in `jest.setup.js` (Jest) or `tests/setup.ts` (Vitest)
## Automated Enforcement
The project has multiple layers of protection against nested require() patterns:
### 1. ESLint Rule (app/.eslintrc.cjs)
ESLint will fail on `require('react')` and `require('react-native')` in test files:
```javascript
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.name='require'][arguments.0.value='react']",
message: "Do not use require('react') in tests..."
},
{
selector: "CallExpression[callee.name='require'][arguments.0.value='react-native']",
message: "Do not use require('react-native') in tests..."
}
]
```
Run `yarn lint` to check for violations.
### 2. Validation Script (app/scripts/check-test-requires.cjs)
Automated script to detect nested require patterns:
```bash
node scripts/check-test-requires.cjs
```
This script:
- Scans all test files for `require('react')` and `require('react-native')`
- Reports exact file locations and line numbers
- Exits with error code 1 if issues found
### 3. CI Fast-Fail Check
GitHub Actions runs the validation script before tests:
```yaml
- name: Check for nested require() in tests
run: node scripts/check-test-requires.cjs
working-directory: ./app
```
This prevents wasting CI time on tests that will OOM.
## Quick Checklist
Before committing test changes:
- [ ] No `require('react')` calls in test files (use `import React from 'react'` instead)
- [ ] No `require('react-native')` calls in test files (use `import { ... } from 'react-native'` instead)
- [ ] All imports at top of file (not in hooks or jest.mock() factories)
- [ ] Run validation: `node scripts/check-test-requires.cjs`
- [ ] Run lint: `yarn lint`
## Detection
**Signs of nested require issues**: CI OOM errors, test timeouts, memory spikes, "Call stack size exceeded" errors, tests hiding actual failures
**Fix**:
1. Search for `require('react')` and `require('react-native')` in tests
2. Replace with `import` statements at the top of the file
3. Run `node scripts/check-test-requires.cjs` to verify
## Related Files
- `app/.eslintrc.cjs` - ESLint rules blocking nested requires
- `app/scripts/check-test-requires.cjs` - Validation script
- `.github/workflows/mobile-ci.yml` - CI enforcement
- `app/jest.setup.js` - Jest setup with React Native mocks
- `packages/mobile-sdk-alpha/tests/setup.ts` - Vitest setup with React Native mocks
- `app/jest.config.cjs` - Jest configuration
- `packages/mobile-sdk-alpha/vitest.config.ts` - Vitest configuration
$END$

View File

@@ -2,7 +2,10 @@
# This file configures which files and secrets to ignore during scanning
# Ignore specific file patterns
paths-ignore:
paths_ignore:
# Gitleaks configuration file (contains example secrets/patterns for detection)
- ".gitleaks.toml"
# Mock certificates for testing (these are intentionally committed test data)
- "**/mock_certificates/**/*.key"
- "**/mock_certificates/**/*.crt"
@@ -46,7 +49,7 @@ paths-ignore:
- "**/packages/mobile-sdk-alpha/ios/Frameworks/**"
- "**/packages/mobile-sdk-alpha/ios/SelfSDK/**"
# Ignore specific secret types for mock files
secrets-ignore:
secrets_ignore:
- "Generic Private Key" # For mock certificate keys
- "Generic Certificate" # For mock certificates
- "RSA Private Key" # For mock RSA keys
@@ -57,6 +60,7 @@ secret:
- match: 2036b4e50ad3042969b290e354d9864465107a14de6f5a36d49f81ea8290def8
name: prebuilt-ios-arm64-apple-ios.private.swiftinterface
ignored_paths:
- ".gitleaks.toml"
- "**/*.swiftinterface"
- "**/*.xcframework/**"
- "**/packages/mobile-sdk-alpha/ios/Frameworks/**"

11
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,11 @@
### Description
_A brief description of the changes, what and how is being changed._
### Tested
_Explain how the change has been tested (for example by manual testing, unit tests etc) or why it's not necessary (for example version bump)._
### How to QA
_How can the change be tested in a repeatable manner?_

View File

@@ -79,6 +79,8 @@ jobs:
run: yarn workspace @selfxyz/common build
- name: Build @selfxyz/mobile-sdk-alpha
run: yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Build @selfxyz/qrcode
run: yarn workspace @selfxyz/qrcode build:deps
- name: Yarn types
run: yarn types

View File

@@ -10,7 +10,7 @@ env:
WORKSPACE: ${{ github.workspace }}
APP_PATH: ${{ github.workspace }}/app
# Cache versions
GH_CACHE_VERSION: v1 # Global cache version
GH_CACHE_VERSION: v2 # Global cache version - bumped to invalidate caches
GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version
# Performance optimizations
GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=4 -Dorg.gradle.parallel=true -Dorg.gradle.configureondemand=true -Dorg.gradle.caching=true
@@ -147,10 +147,11 @@ jobs:
ls -la common/dist/ || echo "❌ common dist not found"
ls -la common/dist/cjs/ || echo "❌ common dist/cjs not found"
ls -la common/dist/cjs/index.cjs || echo "❌ common dist/cjs/index.cjs not found"
- name: Build dependencies (cache miss)
if: steps.built-deps.outputs.cache-hit != 'true'
- name: Build dependencies (always - debugging CI)
# Temporarily always build to debug CI issues
# TODO: Re-enable cache after fixing: if: steps.built-deps.outputs.cache-hit != 'true'
run: |
echo "Cache miss for built dependencies. Building now..."
echo "Building dependencies (cache temporarily disabled for debugging)..."
yarn workspace @selfxyz/mobile-app run build:deps
# Verify build completed successfully
if [ ! -f "packages/mobile-sdk-alpha/dist/cjs/index.cjs" ] || [ ! -f "common/dist/cjs/index.cjs" ]; then
@@ -182,7 +183,13 @@ jobs:
else
echo "✅ All required dependency files exist"
fi
- name: Check for nested require() in tests
run: node scripts/check-test-requires.cjs
working-directory: ./app
- name: App Tests
env:
# Increase Node.js memory to prevent hermes-parser WASM memory errors
NODE_OPTIONS: --max-old-space-size=4096
run: |
# Final verification from app directory perspective
echo "Final verification before running tests (from app directory)..."
@@ -463,11 +470,13 @@ jobs:
run: |
echo "Cache miss for built dependencies. Building now..."
yarn workspace @selfxyz/mobile-app run build:deps
- name: Clone android-passport-nfc-reader
uses: ./.github/actions/clone-android-passport-nfc-reader
with:
working_directory: ${{ env.APP_PATH }}
selfxyz_internal_pat: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
- name: Setup Android private modules
run: |
cd ${{ env.APP_PATH }}
PLATFORM=android node scripts/setup-private-modules.cjs
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
CI: true
- name: Build Android (with AAPT2 symlink fix)
run: yarn android:ci
working-directory: ./app

View File

@@ -681,6 +681,9 @@ jobs:
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
TURNKEY_AUTH_PROXY_CONFIG_ID: ${{ secrets.TURNKEY_AUTH_PROXY_CONFIG_ID }}
TURNKEY_GOOGLE_CLIENT_ID: ${{ secrets.TURNKEY_GOOGLE_CLIENT_ID }}
TURNKEY_ORGANIZATION_ID: ${{ secrets.TURNKEY_ORGANIZATION_ID }}
timeout-minutes: 90
run: |
cd ${{ env.APP_PATH }}
@@ -1086,12 +1089,14 @@ jobs:
python -m pip install --upgrade pip
pip install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client
- name: Clone android-passport-nfc-reader
- name: Setup Android private modules
if: inputs.platform != 'ios'
uses: ./.github/actions/clone-android-passport-nfc-reader
with:
working_directory: ${{ env.APP_PATH }}
selfxyz_internal_pat: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
run: |
cd ${{ env.APP_PATH }}
PLATFORM=android node scripts/setup-private-modules.cjs
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
CI: true
- name: Build Dependencies (Android)
if: inputs.platform != 'ios'
@@ -1121,6 +1126,9 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=6144"
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
TURNKEY_AUTH_PROXY_CONFIG_ID: ${{ secrets.TURNKEY_AUTH_PROXY_CONFIG_ID }}
TURNKEY_GOOGLE_CLIENT_ID: ${{ secrets.TURNKEY_GOOGLE_CLIENT_ID }}
TURNKEY_ORGANIZATION_ID: ${{ secrets.TURNKEY_ORGANIZATION_ID }}
run: |
cd ${{ env.APP_PATH }}

View File

@@ -7,7 +7,7 @@ env:
ANDROID_NDK_VERSION: 27.0.12077973
XCODE_VERSION: 16.4
# Cache versions
GH_CACHE_VERSION: v1 # Global cache version
GH_CACHE_VERSION: v2 # Global cache version - bumped to invalidate caches
GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version
# Performance optimizations
GRADLE_OPTS: -Dorg.gradle.workers.max=4 -Dorg.gradle.parallel=true -Dorg.gradle.caching=true
@@ -130,11 +130,13 @@ jobs:
corepack prepare yarn@4.6.0 --activate
yarn workspace @selfxyz/mobile-app run build:deps || { echo "❌ Dependency build failed"; exit 1; }
echo "✅ Dependencies built successfully"
- name: Clone android-passport-nfc-reader
uses: ./.github/actions/clone-android-passport-nfc-reader
with:
working_directory: app
selfxyz_internal_pat: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
- name: Setup Android private modules
run: |
cd app
PLATFORM=android node scripts/setup-private-modules.cjs
env:
SELFXYZ_INTERNAL_REPO_PAT: ${{ secrets.SELFXYZ_INTERNAL_REPO_PAT }}
CI: true
- name: Build Android APK
run: |
echo "Building Android APK..."
@@ -154,16 +156,29 @@ jobs:
APK_SIZE=$(stat -f%z "$APK_PATH" 2>/dev/null || stat -c%s "$APK_PATH" 2>/dev/null || echo "unknown")
echo "📱 APK size: $APK_SIZE bytes"
# Verify android-passport-nfc-reader was properly integrated (skip for forks)
# Verify private modules were properly integrated (skip for forks)
if [ -z "${SELFXYZ_INTERNAL_REPO_PAT:-}" ]; then
echo "🔕 No PAT available — skipping private module verification"
elif [ -d "app/android/android-passport-nfc-reader" ]; then
echo "✅ android-passport-nfc-reader directory exists"
echo "📁 android-passport-nfc-reader contents:"
ls -la app/android/android-passport-nfc-reader/ | head -10
else
echo "❌ android-passport-nfc-reader directory not found"
exit 1
# Verify android-passport-nfc-reader
if [ -d "app/android/android-passport-nfc-reader" ]; then
echo "✅ android-passport-nfc-reader directory exists"
echo "📁 android-passport-nfc-reader contents:"
ls -la app/android/android-passport-nfc-reader/ | head -10
else
echo "❌ android-passport-nfc-reader directory not found"
exit 1
fi
# Verify react-native-passport-reader
if [ -d "app/android/react-native-passport-reader" ]; then
echo "✅ react-native-passport-reader directory exists"
echo "📁 react-native-passport-reader contents:"
ls -la app/android/react-native-passport-reader/ | head -10
else
echo "❌ react-native-passport-reader directory not found"
exit 1
fi
fi
echo "🎉 Build verification completed successfully!"
@@ -296,12 +311,13 @@ jobs:
xcodebuild -version
echo "Xcode path:"
xcode-select -p
- name: Setup ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: ${{ github.job }}-${{ runner.os }}
- name: Add ccache to PATH
run: echo "/usr/local/opt/ccache/libexec" >> $GITHUB_PATH
# Temporarily disabled ccache to debug CI issues
# - name: Setup ccache
# uses: hendrikmuhs/ccache-action@v1.2
# with:
# key: ${{ github.job }}-${{ runner.os }}
# - name: Add ccache to PATH
# run: echo "/usr/local/opt/ccache/libexec" >> $GITHUB_PATH
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:

View File

@@ -13,6 +13,10 @@ on:
- "contracts/package.json"
workflow_dispatch:
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
detect-changes:
runs-on: ubuntu-latest
@@ -85,7 +89,6 @@ jobs:
- name: Publish to npm
working-directory: sdk/core
run: |
yarn config set npmScopes.selfxyz.npmAuthToken ${{ secrets.NPM_TOKEN }}
yarn config set npmPublishAccess public
yarn npm publish --access public
env:
@@ -114,7 +117,6 @@ jobs:
- name: Publish to npm
working-directory: sdk/qrcode
run: |
yarn config set npmScopes.selfxyz.npmAuthToken ${{ secrets.NPM_TOKEN }}
yarn config set npmPublishAccess public
yarn npm publish --access public
env:
@@ -143,7 +145,6 @@ jobs:
- name: Publish to npm
working-directory: common
run: |
yarn config set npmScopes.selfxyz.npmAuthToken ${{ secrets.NPM_TOKEN }}
yarn config set npmPublishAccess public
yarn npm publish --access public
env:
@@ -169,7 +170,6 @@ jobs:
- name: Publish to npm
working-directory: contracts
run: |
yarn config set npmScopes.selfxyz.npmAuthToken ${{ secrets.NPM_TOKEN }}
yarn config set npmPublishAccess public
yarn npm publish --access public
env:
@@ -197,7 +197,6 @@ jobs:
- name: Publish to npm
working-directory: sdk/qrcode-angular
run: |
yarn config set npmScopes.selfxyz.npmAuthToken ${{ secrets.NPM_TOKEN }}
yarn config set npmPublishAccess public
yarn npm publish --access public
env:
@@ -227,7 +226,6 @@ jobs:
- name: Publish to npm
working-directory: packages/mobile-sdk-alpha
run: |
yarn config set npmScopes.selfxyz.npmAuthToken ${{ secrets.NPM_TOKEN }}
yarn config set npmPublishAccess restricted
yarn npm publish --access restricted --tag alpha
env:

File diff suppressed because it is too large Load Diff

View File

@@ -3,3 +3,10 @@
1b461a626e0a4a93d4e1c727e7aed8c955aa728c:common/src/utils/passports/validate.test.ts:generic-api-key:73
1b461a626e0a4a93d4e1c727e7aed8c955aa728c:common/src/utils/passports/validate.test.ts:generic-api-key:74
8bc1e85075f73906767652ab35d5563efce2a931:packages/mobile-sdk-alpha/src/animations/passport_verify.json:aws-access-token:6
f506113a22e5b147132834e4659f5af308448389:app/tests/utils/deeplinks.test.ts:generic-api-key:183
5a67b5cc50f291401d1da4e51706d0cfcf1c2316:app/tests/utils/deeplinks.test.ts:generic-api-key:182
0e4555eee6589aa9cca68f451227b149277d8c90:app/tests/src/utils/points/api.test.ts:generic-api-key:34
feb433e3553f8a7fa6c724b2de5a3e32ef079880:app/ios/Podfile.lock:generic-api-key:2594
3d0e1b4589680df2451031913d067b1b91dafa60:app/ios/Podfile.lock:generic-api-key:2594
3d0e1b4589680df2451031913d067b1b91dafa60:app/tests/utils/deeplinks.test.ts:generic-api-key:208
circuits/circuits/gcp_jwt_verifier/example_jwt.txt:jwt:1

View File

@@ -1,5 +1,11 @@
nodeLinker: node-modules
nmHoistingLimits: workspaces
checksumBehavior: update
enableGlobalCache: true
enableScripts: true
checksumBehavior: "update"
nmHoistingLimits: workspaces
nodeLinker: node-modules
npmPublishAccess: public

View File

@@ -130,6 +130,20 @@ yarn types # Verify type checking
- For Noir circuits, run `nargo test -p <crate>` in each `noir/crates/*` directory.
- Tests for `@selfxyz/contracts` are currently disabled in CI and may be skipped.
- E2E tests (mobile app) - **Run automatically in CI/CD, not required locally**:
- E2E tests execute automatically in GitHub Actions on PRs and main branch
- Local E2E testing is optional (see `app/AGENTS.md` for local setup if needed)
- Commands available: `yarn workspace @selfxyz/mobile-app test:e2e:ios` / `test:e2e:android`
#### Test Memory Optimization
**CRITICAL**: Never create nested `require('react-native')` calls in tests. This causes out-of-memory (OOM) errors in CI/CD pipelines.
- Use ES6 `import` statements instead of `require()` when possible
- Avoid dynamic `require()` calls in `beforeEach`/`afterEach` hooks
- Prefer top-level imports over nested requires
- See `.cursor/rules/test-memory-optimization.mdc` for detailed guidelines
### CI Caching
Use the shared composite actions in `.github/actions` when caching dependencies in GitHub workflows. They provide consistent cache paths and keys:
@@ -151,6 +165,43 @@ Each action accepts an optional `cache-version` input (often combined with `GH_C
- Write short, imperative commit messages (e.g. `Fix address validation`).
- The pull request body should summarize the changes and mention test results.
## Workspace-Specific Instructions
Some workspaces have additional instructions in their own `AGENTS.md` files:
- `app/AGENTS.md` - Mobile app development, E2E testing, deployment
- `packages/mobile-sdk-alpha/AGENTS.md` - SDK development, testing guidelines, package validation
- `noir/AGENTS.md` - Noir circuit development
These workspace-specific files override or extend the root instructions for their respective areas.
## Troubleshooting
### Common Issues
#### Yarn Install Fails
- Ensure Node.js 22.x is installed: `nvm use`
- Clear Yarn cache: `yarn cache clean`
- Remove `node_modules` and reinstall: `rm -rf node_modules && yarn install`
#### Build Failures
- Run `yarn build:deps` in affected workspace first
- Check workspace-specific `AGENTS.md` for platform requirements
- For mobile app: ensure iOS/Android prerequisites are met (see `app/AGENTS.md`)
#### Test Failures
- Check workspace-specific test setup requirements
- For mobile app tests: ensure native modules are properly mocked
- See `.cursor/rules/test-memory-optimization.mdc` for test memory issues
#### Type Errors
- Run `yarn types` to see all type errors across workspaces
- Some packages may need to be built first: `yarn build:deps`
## Scope
These instructions apply to the entire repository unless overridden by a nested `AGENTS.md`.

View File

@@ -213,12 +213,54 @@ module.exports = {
rules: {
// Allow console logging and relaxed typing in tests
'no-console': 'off',
// Allow require() imports in tests for mocking
// Allow require() imports in tests for mocking, but block react/react-native
'@typescript-eslint/no-require-imports': 'off',
// Block require('react') and require('react-native') to prevent OOM issues
'no-restricted-syntax': [
'error',
{
selector:
"CallExpression[callee.name='require'][arguments.0.value='react']",
message:
"Do not use require('react') in tests. Use 'import React from \"react\"' at the top of the file to avoid out-of-memory issues in CI.",
},
{
selector:
"CallExpression[callee.name='require'][arguments.0.value='react-native']",
message:
"Do not use require('react-native') in tests. Use 'import { ... } from \"react-native\"' at the top of the file to avoid out-of-memory issues in CI.",
},
],
// Allow any types in tests for mocking
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
files: ['tests/**/*.js'],
env: {
jest: true,
},
rules: {
// Allow console logging in test JS files
'no-console': 'off',
// Block require('react') and require('react-native') to prevent OOM issues
'no-restricted-syntax': [
'error',
{
selector:
"CallExpression[callee.name='require'][arguments.0.value='react']",
message:
"Do not use require('react') in tests. Use 'import React from \"react\"' at the top of the file to avoid out-of-memory issues in CI.",
},
{
selector:
"CallExpression[callee.name='require'][arguments.0.value='react-native']",
message:
"Do not use require('react-native') in tests. Use 'import { ... } from \"react-native\"' at the top of the file to avoid out-of-memory issues in CI.",
},
],
},
},
{
// Allow console logging in scripts
files: ['scripts/**/*.cjs', 'scripts/*.cjs'],

View File

@@ -18,6 +18,7 @@ Before creating a PR for the mobile app:
- [ ] `yarn nice` passes (fixes linting and formatting)
- [ ] `yarn types` passes (TypeScript validation)
- [ ] `yarn test` passes (unit tests)
- [ ] No nested `require('react-native')` calls in tests (causes OOM in CI) - check with `grep -r "require('react-native')" app/tests/` and verify no nested patterns
- [ ] App builds successfully on target platforms
### Mobile-Specific Validation
@@ -25,12 +26,14 @@ Before creating a PR for the mobile app:
- [ ] Android build succeeds: `yarn android` (emulator/device)
- [ ] Web build succeeds: `yarn web`
- [ ] No sensitive data in logs (PII, credentials, tokens)
- [ ] Environment variables properly configured (check `.env` setup)
- [ ] E2E tests run in CI (not required locally - CI will run E2E tests automatically)
### AI Review Preparation
- [ ] Complex native module changes documented
- [ ] Platform-specific code paths explained
- [ ] Security-sensitive operations flagged
- [ ] Performance implications noted
- [ ] Performance implications noted (including test memory patterns if tests were modified)
## Post-PR Validation
@@ -45,8 +48,11 @@ After PR creation:
### Mobile-Specific Checks
- [ ] App launches without crashes
- [ ] Core functionality works on target platforms
- [ ] No memory leaks introduced
- [ ] No memory leaks introduced (including test memory patterns - see Test Memory Optimization section)
- [ ] Bundle size within acceptable limits
- [ ] No nested `require('react-native')` calls in tests (causes OOM in CI)
- [ ] Native modules work correctly (if native code was modified)
- [ ] Platform-specific code paths tested (iOS/Android/Web)
### Review Integration
- [ ] Address CodeRabbitAI feedback
@@ -92,4 +98,143 @@ yarn types # Verify type checking
## Running the App
- `yarn ios`
- `yarn ios` - Run on iOS simulator (builds dependencies automatically)
- `yarn android` - Run on Android emulator/device (builds dependencies automatically)
- `yarn web` - Run web version
### Development Tips
- Use `yarn build:deps` to build all workspace dependencies before running the app
- For iOS: Ensure Xcode scheme is set to "OpenPassport" (see memory)
- For Android: Ensure emulator is running or device is connected before `yarn android`
- Metro bundler starts automatically; use `yarn start` to run it separately
## E2E Testing
The app uses Maestro for end-to-end testing. **E2E tests run automatically in CI/CD pipelines - they are not required to run locally.**
### CI/CD E2E Testing
- E2E tests run automatically in GitHub Actions workflows
- iOS and Android E2E tests run on PRs and main branch
- No local setup required - CI handles all E2E test execution
### Local E2E Testing (Optional)
If you need to run E2E tests locally for debugging:
**Prerequisites:**
- Maestro CLI installed: `curl -Ls "https://get.maestro.mobile.dev" | bash`
- iOS: Simulator running or device connected
- Android: Emulator running or device connected
- App built and installed on target device/simulator
**Running Locally:**
```bash
# iOS E2E tests
yarn test:e2e:ios
# Android E2E tests
yarn test:e2e:android
# Or use the local test script (handles setup automatically)
./scripts/test-e2e-local.sh ios
./scripts/test-e2e-local.sh android
```
**E2E Test Files:**
- iOS: `tests/e2e/launch.ios.flow.yaml`
- Android: `tests/e2e/launch.android.flow.yaml`
## Environment Variables
The app uses `react-native-dotenv` for environment configuration.
### Setup
- Create `.env` file in `app/` directory (see `.env.example` if available)
- Environment variables are loaded via `@env` import
- For secrets: Use `.env.secrets` (gitignored) for local development
- In CI: Environment variables are set in workflow files
### Common Environment Variables
- `GOOGLE_SIGNIN_ANDROID_CLIENT_ID` - Google Sign-In configuration
- Various API endpoints and keys (check `app/env.ts` for full list)
### Testing with Environment Variables
- Tests use mocked environment variables (see `jest.setup.js`)
- E2E tests use actual environment configuration
- Never commit `.env.secrets` or sensitive values
## Deployment
### Mobile Deployment
The app uses Fastlane for iOS and Android deployment.
### Deployment Commands
```bash
# Deploy both platforms (requires confirmation)
yarn mobile-deploy
# Deploy iOS only
yarn mobile-deploy:ios
# Deploy Android only
yarn mobile-deploy:android
# Force local deployment (for testing deployment scripts)
yarn mobile-local-deploy
```
### Deployment Prerequisites
- See `app/docs/MOBILE_DEPLOYMENT.md` for detailed deployment guide
- Required secrets configured in CI/CD or `.env.secrets` for local
- iOS: App Store Connect API keys, certificates, provisioning profiles
- Android: Play Store service account, keystore
### Deployment Checklist
- [ ] Version bumped in `package.json` and `app.json`
- [ ] Changelog updated
- [ ] All unit tests pass (`yarn test`)
- [ ] Build succeeds for target platform
- [ ] Required secrets/environment variables configured
- [ ] Fastlane configuration verified
- [ ] CI E2E tests pass (automatically run in CI, no local action needed)
## Test Memory Optimization
**CRITICAL**: Never create nested `require('react')` or `require('react-native')` calls in tests. This causes out-of-memory (OOM) errors in CI/CD pipelines that hide actual test failures.
### Automated Enforcement
The project has multiple layers of protection:
1. **ESLint Rule**: Blocks `require('react')` and `require('react-native')` in test files
2. **Pre-commit Script**: Run `node scripts/check-test-requires.cjs` to validate
3. **CI Fast-Fail**: GitHub Actions checks for nested requires before running tests
### Quick Check
Before committing, verify no nested requires:
```bash
# Automated check (recommended)
node scripts/check-test-requires.cjs
# Manual check
grep -r "require('react')" app/tests/
grep -r "require('react-native')" app/tests/
```
### Best Practices
- **Always use ES6 `import` statements** - Never use `require('react')` or `require('react-native')` in test files
- Put all imports at the top of the file - No dynamic imports in hooks
- Avoid `require()` calls in `beforeEach`/`afterEach` hooks
- React and React Native are already mocked in `jest.setup.js` - use imports in test files
### Detailed Guidelines
See `.cursor/rules/test-memory-optimization.mdc` for comprehensive guidelines, examples, and anti-patterns.

View File

@@ -5,8 +5,19 @@
// CI/CD Pipeline Test - July 31, 2025 - With Permissions Fix
import { Buffer } from 'buffer';
import React from 'react';
import { Platform } from 'react-native';
import { YStack } from 'tamagui';
import type {
TurnkeyCallbacks,
TurnkeyProviderConfig,
} from '@turnkey/react-native-wallet-kit';
import { TurnkeyProvider } from '@turnkey/react-native-wallet-kit';
import {
TURNKEY_AUTH_PROXY_CONFIG_ID,
TURNKEY_GOOGLE_CLIENT_ID,
TURNKEY_ORGANIZATION_ID,
} from './env';
import ErrorBoundary from './src/components/ErrorBoundary';
import AppNavigation from './src/navigation';
import { AuthProvider } from './src/providers/authProvider';
@@ -18,11 +29,67 @@ import { PassportProvider } from './src/providers/passportDataProvider';
import { RemoteConfigProvider } from './src/providers/remoteConfigProvider';
import { SelfClientProvider } from './src/providers/selfClientProvider';
import { initSentry, wrapWithSentry } from './src/Sentry';
import {
TURNKEY_OAUTH_REDIRECT_URI_ANDROID,
TURNKEY_OAUTH_REDIRECT_URI_IOS,
} from './src/utils/constants';
import 'react-native-get-random-values';
import 'react-native-url-polyfill/auto';
import '@walletconnect/react-native-compat';
import '@noble/curves/p256';
import 'sha256-uint8array';
import '@turnkey/encoding';
import '@turnkey/api-key-stamper';
initSentry();
global.Buffer = Buffer;
export const TURNKEY_CALLBACKS: TurnkeyCallbacks = {
beforeSessionExpiry: ({ sessionKey: _sessionKey }) => {
console.log('[Turnkey] Session nearing expiry');
},
onSessionExpired: ({ sessionKey: _sessionKey }) => {
console.log('[Turnkey] Session expired');
},
onAuthenticationSuccess: ({
action: _action,
method: _method,
identifier: _identifier,
}) => {
// console.log('[Turnkey] Auth success:', { action, method, identifier });
},
onError: error => {
console.error('[Turnkey] Error:', error);
},
};
export const TURNKEY_CONFIG: TurnkeyProviderConfig = {
organizationId: TURNKEY_ORGANIZATION_ID!,
authProxyConfigId: TURNKEY_AUTH_PROXY_CONFIG_ID!,
autoRefreshManagedState: false,
auth: {
passkey: false,
oauth: {
// Should use custom scheme, NOT 'https' for IOS
appScheme:
Platform.OS === 'ios' ? 'com.warroom.proofofpassport' : 'https',
redirectUri:
Platform.OS === 'ios'
? TURNKEY_OAUTH_REDIRECT_URI_IOS
: TURNKEY_OAUTH_REDIRECT_URI_ANDROID,
google: {
clientId: TURNKEY_GOOGLE_CLIENT_ID!,
redirectUri:
Platform.OS === 'ios'
? TURNKEY_OAUTH_REDIRECT_URI_IOS
: TURNKEY_OAUTH_REDIRECT_URI_ANDROID,
},
},
},
};
function App(): React.JSX.Element {
return (
<ErrorBoundary>
@@ -35,7 +102,12 @@ function App(): React.JSX.Element {
<DatabaseProvider>
<NotificationTrackingProvider>
<FeedbackProvider>
<AppNavigation />
<TurnkeyProvider
config={TURNKEY_CONFIG}
callbacks={TURNKEY_CALLBACKS}
>
<AppNavigation />
</TurnkeyProvider>
</FeedbackProvider>
</NotificationTrackingProvider>
</DatabaseProvider>

View File

@@ -1,10 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
CFPropertyList (3.0.8)
activesupport (7.2.3)
base64
benchmark (>= 0.3)
@@ -25,8 +22,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1181.0)
aws-sdk-core (3.236.0)
aws-partitions (1.1183.0)
aws-sdk-core (3.237.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -34,10 +31,10 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.116.0)
aws-sdk-kms (1.117.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.203.0)
aws-sdk-s3 (1.203.1)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -114,9 +111,9 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
@@ -225,14 +222,14 @@ GEM
i18n (1.14.7)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.15.2)
json (2.16.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.0)
minitest (5.26.2)
molinillo (0.8.0)
multi_json (1.17.0)
multipart-post (2.4.1)
@@ -241,7 +238,6 @@ GEM
nap (1.1.0)
naturally (2.3.0)
netrc (0.11.0)
nkf (0.2.0)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
@@ -315,4 +311,4 @@ RUBY VERSION
ruby 3.2.7p253
BUNDLED WITH
2.6.9
2.4.19

View File

@@ -1 +1,8 @@
build
build
# Private modules cloned dynamically by setup-private-modules.cjs
android-passport-nfc-reader/
react-native-passport-reader/
# Temporary credential helper scripts created during CI setup
.git-credential-helper-*.sh

View File

@@ -125,12 +125,17 @@ android {
preDexLibraries false
}
buildFeatures {
buildConfig = true
viewBinding = true
}
defaultConfig {
applicationId "com.proofofpassportapp"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 113
versionName "2.7.3"
versionCode 121
versionName "2.9.1"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {
cmake {

View File

@@ -3,6 +3,13 @@
xmlns:tools="http://schemas.android.com/tools"
>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="whatsapp" />
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_SURFACE_FLINGER" />

Binary file not shown.

View File

@@ -4,11 +4,11 @@ buildscript {
ext {
buildToolsVersion = "35.0.0"
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
compileSdkVersion = 36
targetSdkVersion = 36
// Updated NDK to support 16k page size devices
ndkVersion = "27.0.12077973"
kotlinVersion = "1.9.24"
kotlinVersion = "2.0.0"
firebaseMessagingVersion = "23.4.0"
firebaseBomVersion = "32.7.3"
}

View File

@@ -1,2 +0,0 @@
node_modules/
android/src/main/jniLibs/arm64-v8a/libark_circom_passport.so

View File

@@ -1,15 +0,0 @@
## License
Apache License, Version 2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,61 +0,0 @@
# react-native-passport-reader
Adapted from [passport-reader](https://github.com/tananaev/passport-reader). Individual modifications are too many to enumerate, but essentially: the workflow code was adapted to the needs of a React Native module, and the scanning code was largely left as is.
## Getting started
```sh
$ npm install react-native-passport-reader --save
$ react-native link react-native-passport-reader
```
In your `android/app/build.gradle` add `packagingOptions`:
```
android {
...
packagingOptions {
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
}
}
```
In `AndroidManifest.xml` add:
```xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
```
If your app will not function without nfc capabilities, set `android:required` above to `true`
## Usage
```js
import PassportReader from 'react-native-passport-reader'
// { scan, cancel, isSupported }
async function scan () {
// 1. start a scan
// 2. press the back of your android phone against the passport
// 3. wait for the scan(...) Promise to get resolved/rejected
const {
firstName,
lastName,
gender,
issuer,
nationality,
photo
} = await PassportReader.scan({
// yes, you need to know a bunch of data up front
// this is data you can get from reading the MRZ zone of the passport
documentNumber: 'ofDocumentBeingScanned',
dateOfBirth: 'yyMMdd',
dateOfExpiry: 'yyMMdd'
})
const { base64, width, height } = photo
}
```

View File

@@ -1,48 +0,0 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
namespace "io.tradle.nfc"
// Use NDK that supports 16k page size
ndkVersion = "27.0.12077973"
compileSdkVersion 35
defaultConfig {
minSdkVersion 21
targetSdkVersion 35
versionCode 1
versionName "1.0"
multiDexEnabled = true
}
packagingOptions {
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt')
}
}
lintOptions {
warning 'InvalidPackage'
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation 'com.google.code.gson:gson:2.8.9' // Check for the latest version
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'com.wdullaer:materialdatetimepicker:3.5.2'
implementation 'org.jmrtd:jmrtd:0.8.1'
implementation 'net.sf.scuba:scuba-sc-android:0.0.18'
implementation 'com.gemalto.jp2:jp2-android:1.0.3'
implementation 'com.github.mhshams:jnbis:1.1.0'
implementation 'commons-io:commons-io:2.8.0'
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.facebook.react:react-native:+'
implementation "io.sentry:sentry-android:8.20.0"
}

View File

@@ -1,5 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
</manifest>

View File

@@ -1,114 +0,0 @@
package io.tradle.nfc
import net.sf.scuba.smartcards.APDUEvent
import net.sf.scuba.smartcards.APDUListener
import net.sf.scuba.smartcards.CommandAPDU
import net.sf.scuba.smartcards.ResponseAPDU
import org.jmrtd.WrappedAPDUEvent
import android.util.Log
class APDULogger : APDUListener {
private var moduleReference: RNPassportReaderModule? = null
private val sessionContext = mutableMapOf<String, Any>()
fun setModuleReference(module: RNPassportReaderModule) {
moduleReference = module
}
fun setContext(key: String, value: Any) {
sessionContext[key] = value
}
fun clearContext() {
sessionContext.clear()
}
override fun exchangedAPDU(event: APDUEvent) {
try {
val entry = createLogEntry(event)
logToAnalytics(entry)
} catch (e: Exception) {
Log.e("APDULogger", "Error exchanging APDU", e)
}
}
private fun createLogEntry(event: APDUEvent): APDULogEntry {
val command = event.commandAPDU
val response = event.responseAPDU
val timestamp = System.currentTimeMillis()
val entry = APDULogEntry(
timestamp = timestamp,
commandHex = command.bytes.toHexString(),
responseHex = response.bytes.toHexString(),
statusWord = response.sw,
statusWordHex = "0x${response.sw.toString(16).uppercase().padStart(4, '0')}",
commandLength = command.bytes.size,
responseLength = response.bytes.size,
dataLength = response.data.size,
isWrapped = event is WrappedAPDUEvent,
plainCommandHex = if (event is WrappedAPDUEvent) event.plainTextCommandAPDU.bytes.toHexString() else null,
plainResponseHex = if (event is WrappedAPDUEvent) event.plainTextResponseAPDU.bytes.toHexString() else null,
plainCommandLength = if (event is WrappedAPDUEvent) event.plainTextCommandAPDU.bytes.size else null,
plainResponseLength = if (event is WrappedAPDUEvent) event.plainTextResponseAPDU.bytes.size else null,
plainDataLength = if (event is WrappedAPDUEvent) event.plainTextResponseAPDU.data.size else null,
context = sessionContext.toMap()
)
return entry
}
private fun ByteArray.toHexString(): String {
return joinToString("") { "%02X".format(it) }
}
private fun logToAnalytics(entry: APDULogEntry) {
try {
val params = mutableMapOf<String, Any>().apply {
put("timestamp", entry.timestamp)
put("command_hex", entry.commandHex)
put("response_hex", entry.responseHex)
put("status_word", entry.statusWord)
put("status_word_hex", entry.statusWordHex)
put("command_length", entry.commandLength)
put("response_length", entry.responseLength)
put("data_length", entry.dataLength)
put("is_wrapped", entry.isWrapped)
put("context", entry.context)
entry.plainCommandHex?.let { put("plain_command_hex", it) }
entry.plainResponseHex?.let { put("plain_response_hex", it) }
entry.plainCommandLength?.let { put("plain_command_length", it) }
entry.plainResponseLength?.let { put("plain_response_length", it) }
entry.plainDataLength?.let { put("plain_data_length", it) }
}
moduleReference?.logAnalyticsEvent("nfc_apdu_exchange", params)
} catch (e: Exception) {
Log.e("APDULogger", "Error logging to analytics", e)
}
}
}
data class APDULogEntry(
val timestamp: Long,
val commandHex: String,
val responseHex: String,
val statusWord: Int,
val statusWordHex: String,
val commandLength: Int,
val responseLength: Int,
val dataLength: Int,
val isWrapped: Boolean,
val plainCommandHex: String?,
val plainResponseHex: String?,
val plainCommandLength: Int?,
val plainResponseLength: Int?,
val plainDataLength: Int?,
val context: Map<String, Any>
)

View File

@@ -1,57 +0,0 @@
/*
* Copyright 2016 - 2022 Anton Tananaev (anton.tananaev@gmail.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.tradle.nfc
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.gemalto.jp2.JP2Decoder
import org.jnbis.WsqDecoder
import java.io.InputStream
object ImageUtil {
fun decodeImage(context: Context?, mimeType: String, inputStream: InputStream?): Bitmap {
return if (mimeType.equals("image/jp2", ignoreCase = true) || mimeType.equals(
"image/jpeg2000",
ignoreCase = true
)
) {
JP2Decoder(inputStream).decode()
} else if (mimeType.equals("image/x-wsq", ignoreCase = true)) {
val wsqDecoder = WsqDecoder()
val bitmap = wsqDecoder.decode(inputStream)
val byteData = bitmap.pixels
val intData = IntArray(byteData.size)
for (j in byteData.indices) {
intData[j] = 0xFF000000.toInt() or
(byteData[j].toInt() and 0xFF shl 16) or
(byteData[j].toInt() and 0xFF shl 8) or
(byteData[j].toInt() and 0xFF)
}
Bitmap.createBitmap(
intData,
0,
bitmap.width,
bitmap.width,
bitmap.height,
Bitmap.Config.ARGB_8888
)
} else {
BitmapFactory.decodeStream(inputStream)
}
}
}

View File

@@ -1,35 +0,0 @@
/*
* Copyright 2016 Anton Tananaev (anton.tananaev@gmail.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.tradle.nfc
import io.tradle.nfc.RNPassportReaderModule
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class RNPassportReaderPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(RNPassportReaderModule(reactContext))
}
// No need to override createJSModules method as it's removed in newer versions of React Native
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View File

@@ -1,30 +0,0 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { NativeModules } from 'react-native'
const { RNPassportReader } = NativeModules
const DATE_REGEX = /^\d{6}$/
module.exports = {
...RNPassportReader,
scan,
reset: RNPassportReader.reset
}
function scan({ documentNumber, dateOfBirth, dateOfExpiry, canNumber, useCan, quality=1 }) {
assert(typeof documentNumber === 'string', 'expected string "documentNumber"')
assert(isDate(dateOfBirth), 'expected string "dateOfBirth" in format "yyMMdd"')
assert(isDate(dateOfExpiry), 'expected string "dateOfExpiry" in format "yyMMdd"')
return RNPassportReader.scan({ documentNumber, dateOfBirth, dateOfExpiry, quality, useCan, canNumber })
}
function assert (statement, err) {
if (!statement) {
throw new Error(err || 'Assertion failed')
}
}
function isDate (str) {
return typeof str === 'string' && DATE_REGEX.test(str)
}

View File

@@ -1,15 +0,0 @@
{
"name": "react-native-passport-reader",
"version": "1.0.3",
"description": "read the NFC chip in a passport",
"keywords": [
"react-native",
"react-component",
"nfc",
"android",
"scanner"
],
"license": "APLv2",
"author": "Mark Vayngrib <mark@tradle.io> (http://github.com/mvayngrib)",
"main": "index.android.js"
}

View File

@@ -13,6 +13,7 @@ module.exports = {
},
],
['@babel/plugin-transform-private-methods', { loose: true }],
'@babel/plugin-transform-export-namespace-from',
[
'module:react-native-dotenv',
{

View File

@@ -28,3 +28,9 @@ export const IS_TEST_BUILD = process.env.IS_TEST_BUILD === 'true';
export const MIXPANEL_NFC_PROJECT_TOKEN = undefined;
export const SEGMENT_KEY = process.env.SEGMENT_KEY;
export const SENTRY_DSN = process.env.SENTRY_DSN;
export const TURNKEY_AUTH_PROXY_CONFIG_ID =
process.env.TURNKEY_AUTH_PROXY_CONFIG_ID;
export const TURNKEY_GOOGLE_CLIENT_ID = process.env.TURNKEY_GOOGLE_CLIENT_ID;
export const TURNKEY_ORGANIZATION_ID = process.env.TURNKEY_ORGANIZATION_ID;

View File

@@ -21,7 +21,7 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
[bundle exec] fastlane ios sync_version
```
Sync ios version
Sync ios version (DEPRECATED)
### ios internal_test
@@ -50,7 +50,7 @@ Deploy iOS app with automatic version management
[bundle exec] fastlane android sync_version
```
Sync android version
Sync android version (DEPRECATED)
### android internal_test

View File

@@ -19,23 +19,10 @@
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
center.delegate = self;
// Request permission for notifications
[center requestAuthorizationWithOptions:(UNAuthorizationOptionSound | UNAuthorizationOptionAlert | UNAuthorizationOptionBadge)
completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (error) {
NSLog(@"Failed to request notification authorization: %@", error.localizedDescription);
return;
}
if (granted) {
NSLog(@"Notification authorization granted");
dispatch_async(dispatch_get_main_queue(), ^{
[[UIApplication sharedApplication] registerForRemoteNotifications];
});
} else {
NSLog(@"Notification authorization denied by user");
}
}];
// NOTE: Notification permission request removed from app launch
// Permission is now requested only when user explicitly enables notifications
// (e.g., in Points screen or settings)
// The auto-request was causing unwanted permission prompts on first app launch
}
self.moduleName = @"OpenPassport";

View File

@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.7.3</string>
<string>2.9.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -30,6 +30,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>proofofpassport</string>
<string>com.warroom.proofofpassport</string>
</array>
</dict>
</array>
@@ -39,6 +40,11 @@
<false/>
<key>LSApplicationCategoryType</key>
<string></string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>whatsapp</string>
<string>sms</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NFCReaderUsageDescription</key>
@@ -53,7 +59,7 @@
<key>NSCameraUsageDescription</key>
<string>Needed to scan the passport MRZ.</string>
<key>NSFaceIDUsageDescription</key>
<string>Needed to secure the secret</string>
<string>Personal information is only stored in the secure element of your device.</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>NSLocationWhenInUseUsageDescription</key>
@@ -63,6 +69,7 @@
<key>UIAppFonts</key>
<array>
<string>Advercase-Regular.otf</string>
<string>DINOT-Bold.otf</string>
<string>DINOT-Medium.otf</string>
<string>IBMPlexMono-Regular.otf</string>
</array>

View File

@@ -15,6 +15,7 @@
<string>appclips:appclip.openpassport.app</string>
<string>applinks:proofofpassport-merkle-tree.xyz</string>
<string>applinks:redirect.self.xyz</string>
<string>webcredentials:redirect.self.xyz</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>

View File

@@ -15,6 +15,9 @@
<string>appclips:appclip.openpassport.app</string>
<string>applinks:proofofpassport-merkle-tree.xyz</string>
<string>applinks:redirect.self.xyz</string>
<string>webcredentials:redirect.self.xyz</string>
<string>applinks:oauth-redirect.turnkey.com</string>
<string>webcredentials:oauth-redirect.turnkey.com</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>

View File

@@ -1512,12 +1512,54 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-compat (2.23.0):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.10.14.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-get-random-values (1.11.0):
- React-Core
- react-native-netinfo (11.4.1):
- React-Core
- react-native-nfc-manager (3.16.3):
- React-Core
- react-native-passkey (3.3.1):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.10.14.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-safe-area-context (5.6.1):
- DoubleConversion
- glog
@@ -1956,6 +1998,8 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNInAppBrowser (3.7.0):
- React-Core
- RNKeychain (10.0.0):
- DoubleConversion
- glog
@@ -2087,7 +2131,7 @@ PODS:
- ReactCommon/turbomodule/core
- Sentry/HybridSDK (= 8.53.2)
- Yoga
- RNSVG (15.12.1):
- RNSVG (15.14.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2107,9 +2151,9 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNSVG/common (= 15.12.1)
- RNSVG/common (= 15.14.0)
- Yoga
- RNSVG/common (15.12.1):
- RNSVG/common (15.14.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2194,9 +2238,11 @@ DEPENDENCIES:
- react-native-biometrics (from `../node_modules/react-native-biometrics`)
- "react-native-blur (from `../node_modules/@react-native-community/blur`)"
- react-native-cloud-storage (from `../node_modules/react-native-cloud-storage`)
- "react-native-compat (from `../node_modules/@walletconnect/react-native-compat`)"
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-nfc-manager (from `../node_modules/react-native-nfc-manager`)
- react-native-passkey (from `../node_modules/react-native-passkey`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-sqlite-storage (from `../node_modules/react-native-sqlite-storage`)
- react-native-webview (from `../node_modules/react-native-webview`)
@@ -2234,6 +2280,7 @@ DEPENDENCIES:
- "RNFBMessaging (from `../node_modules/@react-native-firebase/messaging`)"
- "RNFBRemoteConfig (from `../node_modules/@react-native-firebase/remote-config`)"
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
- RNKeychain (from `../node_modules/react-native-keychain`)
- RNLocalize (from `../node_modules/react-native-localize`)
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
@@ -2360,12 +2407,16 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/blur"
react-native-cloud-storage:
:path: "../node_modules/react-native-cloud-storage"
react-native-compat:
:path: "../node_modules/@walletconnect/react-native-compat"
react-native-get-random-values:
:path: "../node_modules/react-native-get-random-values"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-nfc-manager:
:path: "../node_modules/react-native-nfc-manager"
react-native-passkey:
:path: "../node_modules/react-native-passkey"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-sqlite-storage:
@@ -2440,6 +2491,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-firebase/remote-config"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNInAppBrowser:
:path: "../node_modules/react-native-inappbrowser-reborn"
RNKeychain:
:path: "../node_modules/react-native-keychain"
RNLocalize:
@@ -2534,9 +2587,11 @@ SPEC CHECKSUMS:
react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d
react-native-blur: 6334d934a9b5e67718b8f5725c44cc0a12946009
react-native-cloud-storage: 8d89f2bc574cf11068dfd90933905974087fb9e9
react-native-compat: 44e82a19b6130e3965d6c8ff37dbc1546d477f0f
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-nfc-manager: 66a00e5ddab9704efebe19d605b1b8afb0bb1bd7
react-native-passkey: 8853c3c635164864da68a6dbbcec7148506c3bcf
react-native-safe-area-context: 90a89cb349c7f8168a707e6452288c2f665b9fd1
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
react-native-webview: 3f45e19f0ffc3701168768a6c37695e0f252410e
@@ -2574,12 +2629,13 @@ SPEC CHECKSUMS:
RNFBMessaging: 92325b0d5619ac90ef023a23cfd16fd3b91d0a88
RNFBRemoteConfig: a569bacaa410acfcaba769370e53a787f80fd13b
RNGestureHandler: a63b531307e5b2e6ea21d053a1a7ad4cf9695c57
RNInAppBrowser: 6d3eb68d471b9834335c664704719b8be1bfdb20
RNKeychain: 471ceef8c13f15a5534c3cd2674dbbd9d0680e52
RNLocalize: 7683e450496a5aea9a2dab3745bfefa7341d3f5e
RNReactNativeHapticFeedback: e526ac4a7ca9fb23c7843ea4fd7d823166054c73
RNScreens: 806e1449a8ec63c2a4e4cf8a63cc80203ccda9b8
RNSentry: 6ad982be2c8e32dab912afb4132b6a0d88484ea0
RNSVG: 0c1fc3e7b147949dc15644845e9124947ac8c9bb
RNSVG: e1cf5a9a5aa12c69f2ec47031defbd87ae7fb697
segment-analytics-react-native: a0c29c75ede1989118b50cac96b9495ea5c91a1d
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748

View File

@@ -41,6 +41,7 @@
E9F9A99C2D57FE2900E1362E /* PassportOCRViewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = E9F9A99A2D57FE2900E1362E /* PassportOCRViewManager.m */; };
E9F9A99D2D57FE2900E1362E /* PassportOCRViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F9A99B2D57FE2900E1362E /* PassportOCRViewManager.swift */; };
EBECCA4983EC6929A7722578 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E56E082698598B41447667BB /* PrivacyInfo.xcprivacy */; };
F3A8B2C9D4E5F6A7B8C9D0E1 /* DINOT-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* DINOT-Bold.otf */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -72,6 +73,7 @@
905B70062A72774000AFA232 /* PassportReader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PassportReader.m; sourceTree = "<group>"; };
905B70082A729CD400AFA232 /* OpenPassport.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = OpenPassport.entitlements; path = OpenPassport/OpenPassport.entitlements; sourceTree = "<group>"; };
9BF744D9A73A4BAC96EC569A /* DINOT-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "DINOT-Medium.otf"; path = "../src/assets/fonts/DINOT-Medium.otf"; sourceTree = "<group>"; };
A1B2C3D4E5F6A7B8C9D0E1F2 /* DINOT-Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "DINOT-Bold.otf"; path = "../src/assets/fonts/DINOT-Bold.otf"; sourceTree = "<group>"; };
AE6147EB2DC95A8D00445C0F /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
B49D2B102E28AA7900946F64 /* IBMPlexMono-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "IBMPlexMono-Regular.otf"; path = "../src/assets/fonts/IBMPlexMono-Regular.otf"; sourceTree = SOURCE_ROOT; };
BF1044802DD53540009B3688 /* LiveMRZScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMRZScannerView.swift; sourceTree = "<group>"; };
@@ -155,8 +157,9 @@
isa = PBXGroup;
children = (
7E5C3CEF7EDA4871B3D0EBE1 /* Advercase-Regular.otf */,
B49D2B102E28AA7900946F64 /* IBMPlexMono-Regular.otf */,
A1B2C3D4E5F6A7B8C9D0E1F2 /* DINOT-Bold.otf */,
9BF744D9A73A4BAC96EC569A /* DINOT-Medium.otf */,
B49D2B102E28AA7900946F64 /* IBMPlexMono-Regular.otf */,
);
name = Resources;
sourceTree = "<group>";
@@ -270,8 +273,9 @@
AE6147EC2DC95A8D00445C0F /* GoogleService-Info.plist in Resources */,
EBECCA4983EC6929A7722578 /* PrivacyInfo.xcprivacy in Resources */,
DAC618BCA5874DD8AD74FFFC /* Advercase-Regular.otf in Resources */,
B49D2B112E28AA7900946F64 /* IBMPlexMono-Regular.otf in Resources */,
F3A8B2C9D4E5F6A7B8C9D0E1 /* DINOT-Bold.otf in Resources */,
D427791AA5714251A5EAF8AD /* DINOT-Medium.otf in Resources */,
B49D2B112E28AA7900946F64 /* IBMPlexMono-Regular.otf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -427,7 +431,7 @@
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassportDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 178;
CURRENT_PROJECT_VERSION = 189;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 5B29R5LYHQ;
ENABLE_BITCODE = NO;
@@ -542,7 +546,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.7.3;
MARKETING_VERSION = 2.9.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -568,7 +572,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassport.entitlements;
CURRENT_PROJECT_VERSION = 178;
CURRENT_PROJECT_VERSION = 189;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 5B29R5LYHQ;
FRAMEWORK_SEARCH_PATHS = (
@@ -682,7 +686,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.7.3;
MARKETING_VERSION = 2.9.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View File

@@ -49,6 +49,9 @@ module.exports = {
'^@anon-aadhaar/core$':
'<rootDir>/../common/node_modules/@anon-aadhaar/core/dist/index.js',
},
transform: {
'\\.[jt]sx?$': 'babel-jest',
},
globals: {
'ts-jest': {
tsconfig: 'tsconfig.test.json',

View File

@@ -84,6 +84,16 @@ jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => ({
}),
};
}
if (name === 'RNDeviceInfo') {
return {
getConstants: () => ({
Dimensions: {
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
},
}),
};
}
return {
getConstants: () => ({}),
};
@@ -134,6 +144,27 @@ jest.mock(
{ virtual: true },
);
// Mock @turnkey/react-native-wallet-kit to prevent loading of problematic dependencies
jest.mock(
'@turnkey/react-native-wallet-kit',
() => ({
AuthState: {
Authenticated: 'Authenticated',
Unauthenticated: 'Unauthenticated',
},
useTurnkey: jest.fn(() => ({
handleGoogleOauth: jest.fn(),
fetchWallets: jest.fn().mockResolvedValue([]),
exportWallet: jest.fn(),
importWallet: jest.fn(),
authState: 'Unauthenticated',
logout: jest.fn(),
})),
TurnkeyProvider: ({ children }) => children,
}),
{ virtual: true },
);
// Mock the mobile-sdk-alpha's TurboModuleRegistry to prevent native module errors
jest.mock(
'../packages/mobile-sdk-alpha/node_modules/react-native/Libraries/TurboModule/TurboModuleRegistry',
@@ -239,47 +270,125 @@ jest.mock('react-native/src/private/specs/modules/NativeDeviceInfo', () => ({
})),
}));
// Mock NativeStatusBarManagerIOS for react-native-edge-to-edge SystemBars
jest.mock(
'react-native/src/private/specs/modules/NativeStatusBarManagerIOS',
() => ({
setStyle: jest.fn(),
setHidden: jest.fn(),
setNetworkActivityIndicatorVisible: jest.fn(),
}),
);
// Mock react-native-gesture-handler to prevent getConstants errors
jest.mock('react-native-gesture-handler', () => {
const RN = jest.requireActual('react-native');
const React = require('react');
// Mock the components directly without requiring react-native
// to avoid triggering hermes-parser WASM errors
const MockScrollView = props =>
React.createElement('ScrollView', props, props.children);
const MockTouchableOpacity = props =>
React.createElement('TouchableOpacity', props, props.children);
const MockTouchableHighlight = props =>
React.createElement('TouchableHighlight', props, props.children);
const MockFlatList = props => React.createElement('FlatList', props);
return {
...jest.requireActual('react-native-gesture-handler/jestSetup'),
GestureHandlerRootView: ({ children }) => children,
ScrollView: RN.ScrollView,
TouchableOpacity: RN.TouchableOpacity,
TouchableHighlight: RN.TouchableHighlight,
FlatList: RN.FlatList,
ScrollView: MockScrollView,
TouchableOpacity: MockTouchableOpacity,
TouchableHighlight: MockTouchableHighlight,
FlatList: MockFlatList,
};
});
// Mock react-native-safe-area-context
jest.mock('react-native-safe-area-context', () => {
const React = require('react');
const { View } = require('react-native');
// Use React.createElement directly instead of requiring react-native to avoid memory issues
return {
__esModule: true,
SafeAreaProvider: ({ children }) =>
React.createElement(View, null, children),
SafeAreaView: ({ children }) => React.createElement(View, null, children),
React.createElement('View', null, children),
SafeAreaView: ({ children }) => React.createElement('View', null, children),
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
};
});
// Mock NativeEventEmitter to prevent null argument errors
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => {
return class MockNativeEventEmitter {
constructor(nativeModule) {
// Accept any nativeModule argument (including null/undefined)
this.nativeModule = nativeModule;
}
function MockNativeEventEmitter(nativeModule) {
// Accept any nativeModule argument (including null/undefined)
this.nativeModule = nativeModule;
this.addListener = jest.fn();
this.removeListener = jest.fn();
this.removeAllListeners = jest.fn();
this.emit = jest.fn();
}
addListener = jest.fn();
removeListener = jest.fn();
removeAllListeners = jest.fn();
emit = jest.fn();
};
// The mock needs to be the constructor itself, not wrapped
MockNativeEventEmitter.default = MockNativeEventEmitter;
return MockNativeEventEmitter;
});
// Mock react-native-device-info to prevent NativeEventEmitter errors
jest.mock('react-native-device-info', () => ({
getUniqueId: jest.fn().mockResolvedValue('mock-device-id'),
getReadableVersion: jest.fn().mockReturnValue('1.0.0'),
getVersion: jest.fn().mockReturnValue('1.0.0'),
getBuildNumber: jest.fn().mockReturnValue('1'),
getModel: jest.fn().mockReturnValue('mock-model'),
getBrand: jest.fn().mockReturnValue('mock-brand'),
isTablet: jest.fn().mockReturnValue(false),
isLandscape: jest.fn().mockResolvedValue(false),
getSystemVersion: jest.fn().mockReturnValue('14.0'),
getSystemName: jest.fn().mockReturnValue('iOS'),
default: {
getUniqueId: jest.fn().mockResolvedValue('mock-device-id'),
getReadableVersion: jest.fn().mockReturnValue('1.0.0'),
getVersion: jest.fn().mockReturnValue('1.0.0'),
getBuildNumber: jest.fn().mockReturnValue('1'),
getModel: jest.fn().mockReturnValue('mock-model'),
getBrand: jest.fn().mockReturnValue('mock-brand'),
isTablet: jest.fn().mockReturnValue(false),
isLandscape: jest.fn().mockResolvedValue(false),
getSystemVersion: jest.fn().mockReturnValue('14.0'),
getSystemName: jest.fn().mockReturnValue('iOS'),
},
}));
// Mock react-native-device-info nested in @turnkey/react-native-wallet-kit
jest.mock(
'node_modules/@turnkey/react-native-wallet-kit/node_modules/react-native-device-info',
() => ({
getUniqueId: jest.fn().mockResolvedValue('mock-device-id'),
getReadableVersion: jest.fn().mockReturnValue('1.0.0'),
getVersion: jest.fn().mockReturnValue('1.0.0'),
getBuildNumber: jest.fn().mockReturnValue('1'),
getModel: jest.fn().mockReturnValue('mock-model'),
getBrand: jest.fn().mockReturnValue('mock-brand'),
isTablet: jest.fn().mockReturnValue(false),
isLandscape: jest.fn().mockResolvedValue(false),
getSystemVersion: jest.fn().mockReturnValue('14.0'),
getSystemName: jest.fn().mockReturnValue('iOS'),
default: {
getUniqueId: jest.fn().mockResolvedValue('mock-device-id'),
getReadableVersion: jest.fn().mockReturnValue('1.0.0'),
getVersion: jest.fn().mockReturnValue('1.0.0'),
getBuildNumber: jest.fn().mockReturnValue('1'),
getModel: jest.fn().mockReturnValue('mock-model'),
getBrand: jest.fn().mockReturnValue('mock-brand'),
isTablet: jest.fn().mockReturnValue(false),
isLandscape: jest.fn().mockResolvedValue(false),
getSystemVersion: jest.fn().mockReturnValue('14.0'),
getSystemName: jest.fn().mockReturnValue('iOS'),
},
}),
{ virtual: true },
);
// Mock problematic mobile-sdk-alpha components that use React Native StyleSheet
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
NFCScannerScreen: jest.fn(() => null),
@@ -382,6 +491,21 @@ jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
PROVING_FAILED: 'PROVING_FAILED',
// Add other events as needed
},
// Mock haptic functions
buttonTap: jest.fn(),
cancelTap: jest.fn(),
confirmTap: jest.fn(),
feedbackProgress: jest.fn(),
feedbackSuccess: jest.fn(),
feedbackUnsuccessful: jest.fn(),
impactLight: jest.fn(),
impactMedium: jest.fn(),
loadingScreenProgress: jest.fn(),
notificationError: jest.fn(),
notificationSuccess: jest.fn(),
notificationWarning: jest.fn(),
selectionChange: jest.fn(),
triggerFeedback: jest.fn(),
// Add other components and hooks as needed
}));
@@ -627,16 +751,21 @@ jest.mock('react-native-passport-reader', () => {
};
});
const { NativeModules } = require('react-native');
NativeModules.PassportReader = {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
// Mock NativeModules without requiring react-native to avoid memory issues
// Create a minimal NativeModules mock for PassportReader
const NativeModules = {
PassportReader: {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
};
// Make it available globally for any code that expects it
global.NativeModules = NativeModules;
// Mock @/utils/passportReader to properly expose the interface expected by tests
jest.mock('./src/utils/passportReader', () => {
const mockScanPassport = jest.fn();
@@ -871,9 +1000,9 @@ jest.mock('@react-navigation/core', () => {
// Mock react-native-webview globally to avoid ESM parsing and native behaviors
jest.mock('react-native-webview', () => {
const React = require('react');
const { View } = require('react-native');
// Use React.createElement directly instead of requiring react-native to avoid memory issues
const MockWebView = React.forwardRef((props, ref) => {
return React.createElement(View, { ref, testID: 'webview', ...props });
return React.createElement('View', { ref, testID: 'webview', ...props });
});
MockWebView.displayName = 'MockWebView';
return {
@@ -886,14 +1015,14 @@ jest.mock('react-native-webview', () => {
// Mock ExpandableBottomLayout to simple containers to avoid SDK internals in tests
jest.mock('@/layouts/ExpandableBottomLayout', () => {
const React = require('react');
const { View } = require('react-native');
const Layout = ({ children }) => React.createElement(View, null, children);
// Use React.createElement directly instead of requiring react-native to avoid memory issues
const Layout = ({ children }) => React.createElement('View', null, children);
const TopSection = ({ children }) =>
React.createElement(View, null, children);
React.createElement('View', null, children);
const BottomSection = ({ children }) =>
React.createElement(View, null, children);
React.createElement('View', null, children);
const FullSection = ({ children }) =>
React.createElement(View, null, children);
React.createElement('View', null, children);
return {
__esModule: true,
ExpandableBottomLayout: { Layout, TopSection, BottomSection, FullSection },
@@ -903,18 +1032,20 @@ jest.mock('@/layouts/ExpandableBottomLayout', () => {
// Mock mobile-sdk-alpha components used by NavBar (Button, XStack)
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
const React = require('react');
const { View, Text, TouchableOpacity } = require('react-native');
// Use React.createElement directly instead of requiring react-native to avoid memory issues
const Button = ({ children, onPress, icon, ...props }) =>
React.createElement(
TouchableOpacity,
'TouchableOpacity',
{ onPress, ...props, testID: 'msdk-button' },
icon
? React.createElement(View, { testID: 'msdk-button-icon' }, icon)
? React.createElement('View', { testID: 'msdk-button-icon' }, icon)
: null,
children,
);
const XStack = ({ children, ...props }) =>
React.createElement(View, { ...props, testID: 'msdk-xstack' }, children);
React.createElement('View', { ...props, testID: 'msdk-xstack' }, children);
const Text = ({ children, ...props }) =>
React.createElement('Text', { ...props }, children);
return {
__esModule: true,
Button,
@@ -927,10 +1058,10 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
// Mock Tamagui lucide icons to simple components to avoid theme context
jest.mock('@tamagui/lucide-icons', () => {
const React = require('react');
const { View } = require('react-native');
// Use React.createElement directly instead of requiring react-native to avoid memory issues
const makeIcon = name => {
const Icon = ({ size, color, opacity }) =>
React.createElement(View, {
React.createElement('View', {
testID: `icon-${name}`,
size,
color,
@@ -949,8 +1080,8 @@ jest.mock('@tamagui/lucide-icons', () => {
// Mock WebViewFooter to avoid SDK rendering complexity
jest.mock('@/components/WebViewFooter', () => {
const React = require('react');
const { View } = require('react-native');
// Use React.createElement directly instead of requiring react-native to avoid memory issues
const WebViewFooter = () =>
React.createElement(View, { testID: 'webview-footer' });
React.createElement('View', { testID: 'webview-footer' });
return { __esModule: true, WebViewFooter };
});

View File

@@ -192,6 +192,73 @@ const config = {
// Handle problematic package exports and Node.js modules
// Fix @turnkey/encoding to use CommonJS instead of ESM
if (moduleName === '@turnkey/encoding') {
const filePath = path.resolve(
projectRoot,
'node_modules/@turnkey/encoding/dist/index.js',
);
return {
type: 'sourceFile',
filePath,
};
}
// Fix @turnkey/encoding submodules to use CommonJS
if (moduleName.startsWith('@turnkey/encoding/')) {
const subpath = moduleName.replace('@turnkey/encoding/', '');
const filePath = path.resolve(
projectRoot,
`node_modules/@turnkey/encoding/dist/${subpath}.js`,
);
return {
type: 'sourceFile',
filePath,
};
}
// Fix @turnkey/api-key-stamper to use CommonJS instead of ESM
if (moduleName === '@turnkey/api-key-stamper') {
const filePath = path.resolve(
projectRoot,
'node_modules/@turnkey/api-key-stamper/dist/index.js',
);
return {
type: 'sourceFile',
filePath,
};
}
// Fix @turnkey/api-key-stamper dynamic imports by resolving submodules statically
if (moduleName.startsWith('@turnkey/api-key-stamper/')) {
const subpath = moduleName.replace('@turnkey/api-key-stamper/', '');
const filePath = path.resolve(
projectRoot,
`node_modules/@turnkey/api-key-stamper/dist/${subpath}`,
);
return {
type: 'sourceFile',
filePath,
};
}
// Fix viem dynamic import resolution
if (moduleName === 'viem') {
try {
// Viem uses package exports, so we need to resolve to the actual file path
const viemPath = path.resolve(
projectRoot,
'node_modules/viem/_cjs/index.js',
);
return {
type: 'sourceFile',
filePath: viemPath,
};
} catch (error) {
console.warn('Failed to resolve viem:', error);
}
}
// Fix @tamagui/config v2-native export resolution
if (moduleName === '@tamagui/config/v2-native') {
try {

View File

@@ -1,6 +1,6 @@
{
"name": "@selfxyz/mobile-app",
"version": "2.7.4",
"version": "2.9.1",
"private": true,
"type": "module",
"scripts": {
@@ -43,6 +43,7 @@
"mobile-local-deploy:android": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs android",
"mobile-local-deploy:ios": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs ios",
"nice": "sh -c 'if [ -z \"$SKIP_BUILD_DEPS\" ]; then yarn build:deps; fi; yarn imports:fix && yarn lint:fix && yarn fmt:fix'",
"postinstall": "npx patch-package --patch-dir ../patches || true",
"reinstall": "yarn --top-level run reinstall-app",
"release": "./scripts/release.sh",
"release:major": "./scripts/release.sh major",
@@ -107,11 +108,19 @@
"@tamagui/config": "1.126.14",
"@tamagui/lucide-icons": "1.126.14",
"@tamagui/toast": "1.126.14",
"@turnkey/api-key-stamper": "^0.5.0",
"@turnkey/core": "1.7.0",
"@turnkey/encoding": "^0.6.0",
"@turnkey/react-native-wallet-kit": "1.1.5",
"@walletconnect/react-native-compat": "^2.23.0",
"@xstate/react": "^5.0.3",
"asn1js": "^3.0.6",
"axios": "^1.13.2",
"buffer": "^6.0.3",
"country-emoji": "^1.5.6",
"elliptic": "^6.6.1",
"ethers": "^6.11.0",
"expo-application": "^7.0.7",
"expo-modules-core": "^2.2.1",
"hash.js": "^1.1.7",
"js-sha1": "^0.7.0",
@@ -135,16 +144,19 @@
"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-localize": "^3.5.2",
"react-native-logs": "^5.3.0",
"react-native-nfc-manager": "3.16.3",
"react-native-passkey": "^3.3.1",
"react-native-passport-reader": "1.0.3",
"react-native-safe-area-context": "5.6.1",
"react-native-safe-area-context": "^5.6.1",
"react-native-screens": "4.15.3",
"react-native-sqlite-storage": "^6.0.1",
"react-native-svg": "15.12.1",
"react-native-svg": "^15.14.0",
"react-native-svg-web": "^1.0.9",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "^0.19.0",
"react-native-webview": "^13.16.0",
"react-qr-barcode-scanner": "^2.1.8",
@@ -156,6 +168,7 @@
},
"devDependencies": {
"@babel/core": "^7.28.3",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-private-methods": "^7.27.1",
"@babel/preset-env": "^7.28.3",
"@react-native-community/cli": "^16.0.3",
@@ -171,7 +184,7 @@
"@types/bn.js": "^5.2.0",
"@types/dompurify": "^3.2.0",
"@types/elliptic": "^6.4.18",
"@types/jest": "^29.5.14",
"@types/jest": "^30.0.0",
"@types/node": "^22.18.3",
"@types/node-forge": "^1.3.14",
"@types/path-browserify": "^1",
@@ -185,7 +198,6 @@
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^3.10.2",
"babel-plugin-module-resolver": "^5.0.2",
"buffer": "^6.0.3",
"constants-browserify": "^1.0.0",
"dompurify": "^3.2.6",
"eslint": "^8.57.0",
@@ -194,12 +206,12 @@
"eslint-plugin-ft-flow": "^3.0.11",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^28.11.1",
"eslint-plugin-jest": "^29.1.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sort-exports": "^0.9.1",
"hermes-eslint": "^0.19.1",
"jest": "^29.6.3",
"jest": "^30.2.0",
"path-browserify": "^1.0.1",
"prettier": "^3.5.3",
"react-native-svg-transformer": "^1.5.1",

View File

@@ -17,8 +17,8 @@ if (!platform || !['android', 'ios'].includes(platform)) {
// Bundle size thresholds in MB - easy to update!
const BUNDLE_THRESHOLDS_MB = {
// TODO: fix temporary bundle bump
ios: 42,
android: 42,
ios: 44,
android: 44,
};
function formatBytes(bytes) {

View File

@@ -0,0 +1,145 @@
// 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.
/**
* Check for nested require('react') and require('react-native') in test files
* These patterns cause out-of-memory errors in CI/CD pipelines
*
* Usage: node scripts/check-test-requires.cjs
* Exit code: 0 if no issues found, 1 if issues found
*/
const fs = require('fs');
const path = require('path');
const TESTS_DIR = path.join(__dirname, '..', 'tests');
const FORBIDDEN_PATTERNS = [
{
pattern: /require\(['"]react['"]\)/g,
name: "require('react')",
fix: 'Use \'import React from "react"\' at the top of the file instead',
},
{
pattern: /require\(['"]react-native['"]\)/g,
name: "require('react-native')",
fix: 'Use \'import { ... } from "react-native"\' at the top of the file instead',
},
];
/**
* Recursively find all test files in directory
*/
function findTestFiles(dir, files = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip node_modules and other common directories
if (
!['node_modules', '.git', 'coverage', 'dist', 'build'].includes(
entry.name,
)
) {
findTestFiles(fullPath, files);
}
} else if (
entry.isFile() &&
(entry.name.endsWith('.ts') ||
entry.name.endsWith('.tsx') ||
entry.name.endsWith('.js') ||
entry.name.endsWith('.jsx'))
) {
files.push(fullPath);
}
}
return files;
}
/**
* Check a file for forbidden require patterns
*/
function checkFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const issues = [];
for (const { pattern, name, fix } of FORBIDDEN_PATTERNS) {
const matches = content.matchAll(pattern);
for (const match of matches) {
const lines = content.substring(0, match.index).split('\n');
const lineNumber = lines.length;
const columnNumber = lines[lines.length - 1].length + 1;
issues.push({
file: path.relative(process.cwd(), filePath),
line: lineNumber,
column: columnNumber,
pattern: name,
fix: fix,
});
}
}
return issues;
}
/**
* Main execution
*/
function main() {
console.log('🔍 Checking for nested require() in test files...\n');
if (!fs.existsSync(TESTS_DIR)) {
console.error(`❌ Tests directory not found: ${TESTS_DIR}`);
process.exit(1);
}
const testFiles = findTestFiles(TESTS_DIR);
console.log(`Found ${testFiles.length} test files to check\n`);
let totalIssues = 0;
const issuesByFile = new Map();
for (const file of testFiles) {
const issues = checkFile(file);
if (issues.length > 0) {
issuesByFile.set(file, issues);
totalIssues += issues.length;
}
}
if (totalIssues === 0) {
console.log('✅ No nested require() patterns found in test files!');
process.exit(0);
}
// Report issues
console.error(
`❌ Found ${totalIssues} nested require() pattern(s) that cause OOM in CI:\n`,
);
for (const [file, issues] of issuesByFile.entries()) {
console.error(`\n${path.relative(process.cwd(), file)}:`);
for (const issue of issues) {
console.error(` Line ${issue.line}:${issue.column} - ${issue.pattern}`);
console.error(` Fix: ${issue.fix}`);
}
}
console.error(
'\n⚠ These patterns cause out-of-memory errors in CI/CD pipelines.',
);
console.error(
' Use ES6 imports at the top of files instead of require() calls.',
);
console.error(
' See .cursor/rules/test-memory-optimization.mdc for details.\n',
);
process.exit(1);
}
main();

View File

@@ -87,28 +87,43 @@ cd "$PROJECT_ROOT"
log "Working directory: $(pwd)"
# Clone android-passport-nfc-reader if it doesn't exist (for local development)
# Clone private Android modules if they don't exist (for local development)
# Note: In CI, this is usually handled by GitHub action, but we keep this as fallback
if [[ ! -d "app/android/android-passport-nfc-reader" ]]; then
log "Cloning android-passport-nfc-reader for build..."
clone_private_module() {
local repo_name=$1
local target_dir=$2
if [[ -d "$target_dir" ]]; then
if is_ci; then
log "📁 $repo_name exists (likely cloned by GitHub action)"
else
log "📁 $repo_name already exists - preserving existing directory"
fi
return 0
fi
log "Cloning $repo_name for build..."
cd app/android
# Extract just the directory name from the full path for git clone
local dir_name=$(basename "$target_dir")
# Use different clone methods based on environment
if is_ci && [[ -n "${SELFXYZ_INTERNAL_REPO_PAT:-}" ]]; then
# CI environment with PAT (fallback if action didn't run)
git clone "https://${SELFXYZ_INTERNAL_REPO_PAT}@github.com/selfxyz/android-passport-nfc-reader.git" || {
log "ERROR: Failed to clone android-passport-nfc-reader with PAT"
git clone "https://${SELFXYZ_INTERNAL_REPO_PAT}@github.com/selfxyz/${repo_name}.git" "$dir_name" || {
log "ERROR: Failed to clone $repo_name with PAT"
exit 1
}
elif [[ -n "${SSH_AUTH_SOCK:-}" ]] || [[ -f "${HOME}/.ssh/id_rsa" ]] || [[ -f "${HOME}/.ssh/id_ed25519" ]]; then
# Local development with SSH
git clone "git@github.com:selfxyz/android-passport-nfc-reader.git" || {
log "ERROR: Failed to clone android-passport-nfc-reader with SSH"
git clone "git@github.com:selfxyz/${repo_name}.git" "$dir_name" || {
log "ERROR: Failed to clone $repo_name with SSH"
log "Please ensure you have SSH access to the repository or set SELFXYZ_INTERNAL_REPO_PAT"
exit 1
}
else
log "ERROR: No authentication method available for cloning android-passport-nfc-reader"
log "ERROR: No authentication method available for cloning $repo_name"
log "Please either:"
log " - Set up SSH access (for local development)"
log " - Set SELFXYZ_INTERNAL_REPO_PAT environment variable (for CI)"
@@ -116,12 +131,12 @@ if [[ ! -d "app/android/android-passport-nfc-reader" ]]; then
fi
cd ../../
log "✅ android-passport-nfc-reader cloned successfully"
elif is_ci; then
log "📁 android-passport-nfc-reader exists (likely cloned by GitHub action)"
else
log "📁 android-passport-nfc-reader already exists - preserving existing directory"
fi
log "$repo_name cloned successfully"
}
# Clone all required private modules
clone_private_module "android-passport-nfc-reader" "app/android/android-passport-nfc-reader"
clone_private_module "react-native-passport-reader" "app/android/react-native-passport-reader"
# Build and package the SDK with timeout (including dependencies)
log "Building SDK and dependencies..."

View File

@@ -10,17 +10,26 @@ const path = require('path');
const SCRIPT_DIR = __dirname;
const APP_DIR = path.dirname(SCRIPT_DIR);
const ANDROID_DIR = path.join(APP_DIR, 'android');
const PRIVATE_MODULE_PATH = path.join(
ANDROID_DIR,
'android-passport-nfc-reader',
);
const GITHUB_ORG = 'selfxyz';
const REPO_NAME = 'android-passport-nfc-reader';
const BRANCH = 'main';
const PRIVATE_MODULES = [
{
repoName: 'android-passport-nfc-reader',
localPath: path.join(ANDROID_DIR, 'android-passport-nfc-reader'),
validationFiles: ['app/build.gradle', 'app/src/main/AndroidManifest.xml'],
},
{
repoName: 'react-native-passport-reader',
localPath: path.join(ANDROID_DIR, 'react-native-passport-reader'),
validationFiles: ['android/build.gradle'],
},
];
// Environment detection
const isCI = process.env.CI === 'true';
// CI is set by GitHub Actions, CircleCI, etc. Check for truthy value
const isCI = !!process.env.CI || process.env.GITHUB_ACTIONS === 'true';
const repoToken = process.env.SELFXYZ_INTERNAL_REPO_PAT;
const isDryRun = process.env.DRY_RUN === 'true';
@@ -102,13 +111,13 @@ function sanitizeCommandForLogging(command) {
);
}
function removeExistingModule() {
if (fs.existsSync(PRIVATE_MODULE_PATH)) {
log(`Removing existing ${REPO_NAME}...`, 'cleanup');
function removeExistingModule(modulePath, repoName) {
if (fs.existsSync(modulePath)) {
log(`Removing existing ${repoName}...`, 'cleanup');
if (!isDryRun) {
// Force remove even if it's a git repo
fs.rmSync(PRIVATE_MODULE_PATH, {
fs.rmSync(modulePath, {
recursive: true,
force: true,
maxRetries: 3,
@@ -116,7 +125,7 @@ function removeExistingModule() {
});
}
log(`Removed existing ${REPO_NAME}`, 'success');
log(`Removed existing ${repoName}`, 'success');
}
}
// some of us connect to github via SSH, others via HTTPS with gh auth
@@ -136,15 +145,15 @@ function usingHTTPSGitAuth() {
}
}
function clonePrivateRepo() {
log(`Setting up ${REPO_NAME}...`, 'info');
function clonePrivateRepo(repoName, localPath) {
log(`Setting up ${repoName}...`, 'info');
let cloneUrl;
if (isCI && repoToken) {
// CI environment with Personal Access Token
log('CI detected: Using SELFXYZ_INTERNAL_REPO_PAT for clone', 'info');
cloneUrl = `https://${repoToken}@github.com/${GITHUB_ORG}/${REPO_NAME}.git`;
cloneUrl = `https://${repoToken}@github.com/${GITHUB_ORG}/${repoName}.git`;
} else if (isCI) {
log(
'CI environment detected but SELFXYZ_INTERNAL_REPO_PAT not available - skipping private module setup',
@@ -156,17 +165,18 @@ function clonePrivateRepo() {
);
return false; // Return false to indicate clone was skipped
} else if (usingHTTPSGitAuth()) {
cloneUrl = `https://github.com/${GITHUB_ORG}/${REPO_NAME}.git`;
cloneUrl = `https://github.com/${GITHUB_ORG}/${repoName}.git`;
} else {
// Local development with SSH
log('Local development: Using SSH for clone', 'info');
cloneUrl = `git@github.com:${GITHUB_ORG}/${REPO_NAME}.git`;
cloneUrl = `git@github.com:${GITHUB_ORG}/${repoName}.git`;
}
// Security: Use quiet mode for credentialed URLs to prevent token exposure
const isCredentialedUrl = isCI && repoToken;
const quietFlag = isCredentialedUrl ? '--quiet' : '';
const cloneCommand = `git clone --branch ${BRANCH} --single-branch --depth 1 ${quietFlag} "${cloneUrl}" android-passport-nfc-reader`;
const targetDir = path.basename(localPath);
const cloneCommand = `git clone --branch ${BRANCH} --single-branch --depth 1 ${quietFlag} "${cloneUrl}" "${targetDir}"`;
try {
if (isCredentialedUrl) {
@@ -175,7 +185,7 @@ function clonePrivateRepo() {
} else {
runCommand(cloneCommand);
}
log(`Successfully cloned ${REPO_NAME}`, 'success');
log(`Successfully cloned ${repoName}`, 'success');
return true; // Return true to indicate successful clone
} catch (error) {
if (isCI) {
@@ -193,65 +203,94 @@ function clonePrivateRepo() {
}
}
function validateSetup() {
const expectedFiles = [
'app/build.gradle',
'app/src/main/AndroidManifest.xml',
];
for (const file of expectedFiles) {
const filePath = path.join(PRIVATE_MODULE_PATH, file);
function validateSetup(modulePath, validationFiles, repoName) {
for (const file of validationFiles) {
const filePath = path.join(modulePath, file);
if (!fs.existsSync(filePath)) {
throw new Error(`Expected file not found: ${file}`);
throw new Error(`Expected file not found in ${repoName}: ${file}`);
}
}
log('Private module validation passed', 'success');
log(`${repoName} validation passed`, 'success');
}
function setupPrivateModule(module) {
const { repoName, localPath, validationFiles } = module;
log(`Starting setup of ${repoName}...`, 'info');
// Remove existing module
removeExistingModule(localPath, repoName);
// Clone the private repository
const cloneSuccessful = clonePrivateRepo(repoName, localPath);
// If clone was skipped (e.g., in forked PRs), exit gracefully
if (cloneSuccessful === false) {
log(`${repoName} setup skipped - private module not available`, 'warning');
return false;
}
// Security: Remove credential-embedded remote URL after clone
if (isCI && repoToken && !isDryRun) {
scrubGitRemoteUrl(localPath, repoName);
}
// Validate the setup
if (!isDryRun) {
validateSetup(localPath, validationFiles, repoName);
}
log(`${repoName} setup complete!`, 'success');
return true;
}
function setupAndroidPassportReader() {
log(`Starting setup of ${REPO_NAME}...`, 'info');
// Ensure android directory exists
if (!fs.existsSync(ANDROID_DIR)) {
throw new Error(`Android directory not found: ${ANDROID_DIR}`);
}
// Remove existing module
removeExistingModule();
log(
`Starting setup of ${PRIVATE_MODULES.length} private module(s)...`,
'info',
);
// Clone the private repository
const cloneSuccessful = clonePrivateRepo();
// If clone was skipped (e.g., in forked PRs), exit gracefully
if (cloneSuccessful === false) {
log(`${REPO_NAME} setup skipped - private module not available`, 'warning');
return;
let successCount = 0;
for (const module of PRIVATE_MODULES) {
try {
const success = setupPrivateModule(module);
if (success) {
successCount++;
}
} catch (error) {
log(`Failed to setup ${module.repoName}: ${error.message}`, 'error');
throw error;
}
}
// Security: Remove credential-embedded remote URL after clone
if (isCI && repoToken && !isDryRun) {
scrubGitRemoteUrl();
if (successCount === PRIVATE_MODULES.length) {
log('All private modules setup complete!', 'success');
} else if (successCount > 0) {
log(
`Setup complete: ${successCount}/${PRIVATE_MODULES.length} modules cloned`,
'warning',
);
}
// Validate the setup
if (!isDryRun) {
validateSetup();
}
log(`${REPO_NAME} setup complete!`, 'success');
}
function scrubGitRemoteUrl() {
function scrubGitRemoteUrl(modulePath, repoName) {
try {
const cleanUrl = `https://github.com/${GITHUB_ORG}/${REPO_NAME}.git`;
const scrubCommand = `cd "${PRIVATE_MODULE_PATH}" && git remote set-url origin "${cleanUrl}"`;
const cleanUrl = `https://github.com/${GITHUB_ORG}/${repoName}.git`;
const scrubCommand = `cd "${modulePath}" && git remote set-url origin "${cleanUrl}"`;
log('Scrubbing credential from git remote URL...', 'info');
log(`Scrubbing credential from git remote URL for ${repoName}...`, 'info');
runCommand(scrubCommand, { stdio: 'pipe' });
log('Git remote URL cleaned', 'success');
log(`Git remote URL cleaned for ${repoName}`, 'success');
} catch (error) {
log(`Warning: Failed to scrub git remote URL: ${error.message}`, 'warning');
log(
`Warning: Failed to scrub git remote URL for ${repoName}: ${error.message}`,
'warning',
);
// Non-fatal error - continue execution
}
}
@@ -274,5 +313,5 @@ if (require.main === module) {
module.exports = {
setupAndroidPassportReader,
removeExistingModule,
PRIVATE_MODULE_PATH,
PRIVATE_MODULES,
};

Binary file not shown.

View File

@@ -13,9 +13,9 @@ import type { SelfApp } from '@selfxyz/common/utils/appType';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { NavBar } from '@/components/NavBar/BaseNavBar';
import ActivityIcon from '@/images/icons/activity.svg';
import CogHollowIcon from '@/images/icons/cog_hollow.svg';
import PlusCircleIcon from '@/images/icons/plus_circle.svg';
import ScanIcon from '@/images/icons/qr_scan.svg';
import SettingsIcon from '@/images/icons/settings.svg';
import { black, charcoal, slate50 } from '@/utils/colors';
import { extraYPadding } from '@/utils/constants';
import { buttonTap } from '@/utils/haptic';
@@ -38,9 +38,7 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => {
const response = await fetch(
`https://api.self.xyz/consume-deferred-linking-token?token=${content}`,
);
console.log('Consume token response:', response);
const result = await response.json();
console.log('Consume token result:', result);
if (result.status !== 'success') {
throw new Error(
`Failed to consume token: ${result.message || 'Unknown error'}`,
@@ -110,18 +108,18 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => {
size={'$3'}
unstyled
icon={
<ActivityIcon width={'24'} height={'100%'} color={charcoal} />
<PlusCircleIcon width={'24'} height={'100%'} color={charcoal} />
}
onPress={() => {
buttonTap();
props.navigation.navigate('ProofHistory');
props.navigation.navigate('CountryPicker');
}}
/>
<Button
size={'$3'}
unstyled
icon={
<SettingsIcon width={'24'} height={'100%'} color={charcoal} />
<CogHollowIcon width={'24'} height={'100%'} color={charcoal} />
}
onPress={() => {
buttonTap();

View File

@@ -0,0 +1,600 @@
// 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, useState } from 'react';
import { Pressable, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Button, Image, Text, View, XStack, YStack, ZStack } from 'tamagui';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { HelpCircle } from '@tamagui/lucide-icons';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { PointEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { PointHistoryList } from '@/components/PointHistoryList';
import { useIncomingPoints, usePoints } from '@/hooks/usePoints';
import { usePointsGuardrail } from '@/hooks/usePointsGuardrail';
import BellWhiteIcon from '@/images/icons/bell_white.svg';
import ClockIcon from '@/images/icons/clock.svg';
import LockWhiteIcon from '@/images/icons/lock_white.svg';
import StarBlackIcon from '@/images/icons/star_black.svg';
import LogoInversed from '@/images/logo_inversed.svg';
import MajongImage from '@/images/majong.png';
import type { RootStackParamList } from '@/navigation';
import { usePointEventStore } from '@/stores/pointEventStore';
import { useSettingStore } from '@/stores/settingStore';
import analytics from '@/utils/analytics';
import {
black,
blue600,
slate50,
slate200,
slate500,
white,
} from '@/utils/colors';
import { dinot } from '@/utils/fonts';
import { registerModalCallbacks } from '@/utils/modalCallbackRegistry';
import {
isTopicSubscribed,
requestNotificationPermission,
subscribeToTopics,
} from '@/utils/notifications/notificationService';
import {
formatTimeUntilDate,
recordBackupPointEvent,
recordNotificationPointEvent,
} from '@/utils/points';
import { POINT_VALUES } from '@/utils/points/types';
const Points: React.FC = () => {
const selfClient = useSelfClient();
const { bottom } = useSafeAreaInsets();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [isGeneralSubscribed, setIsGeneralSubscribed] = useState(false);
const [isEnabling, setIsEnabling] = useState(false);
const incomingPoints = useIncomingPoints();
const { amount: points } = usePoints();
const loadEvents = usePointEventStore(state => state.loadEvents);
const { hasCompletedBackupForPoints, setBackupForPointsCompleted } =
useSettingStore();
const [isBackingUp, setIsBackingUp] = useState(false);
// Guard: Validate that user has registered a document and completed points disclosure
usePointsGuardrail();
// Track NavBar view analytics
useFocusEffect(
React.useCallback(() => {
const { trackScreenView } = analytics();
trackScreenView('Points NavBar', {
screenName: 'Points NavBar',
});
}, []),
);
const onHelpButtonPress = () => {
navigation.navigate('PointsInfo');
};
//TODO - uncomment after merging - https://github.com/selfxyz/self/pull/1363/
// useEffect(() => {
// const backupEvent = usePointEventStore
// .getState()
// .events.find(
// event => event.type === 'backup' && event.status === 'completed',
// );
// if (backupEvent && !hasCompletedBackupForPoints) {
// setBackupForPointsCompleted();
// }
// }, [setBackupForPointsCompleted, hasCompletedBackupForPoints]);
// Track if we should check for backup completion on next focus
const shouldCheckBackupRef = React.useRef(false);
// Detect when returning from backup screen and record points if backup was completed
useFocusEffect(
React.useCallback(() => {
const { cloudBackupEnabled, turnkeyBackupEnabled } =
useSettingStore.getState();
const currentHasCompletedBackup =
useSettingStore.getState().hasCompletedBackupForPoints;
// Only check if we explicitly set the flag (when navigating to backup settings)
// This prevents false triggers when returning from other flows (like notification permissions)
if (
shouldCheckBackupRef.current &&
(cloudBackupEnabled || turnkeyBackupEnabled) &&
!currentHasCompletedBackup
) {
const recordPoints = async () => {
try {
const response = await recordBackupPointEvent();
if (response.success) {
useSettingStore.getState().setBackupForPointsCompleted();
selfClient.trackEvent(PointEvents.EARN_BACKUP_SUCCESS);
const callbackId = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: () => {},
});
navigation.navigate('Modal', {
titleText: 'Success!',
bodyText: `Account backed up successfully! You earned ${POINT_VALUES.backup} points.\n\nPoints will be distributed to your wallet on the next Sunday at noon UTC.`,
buttonText: 'OK',
callbackId,
});
} else {
console.error(
'Error recording backup points after return:',
response.error,
);
selfClient.trackEvent(PointEvents.EARN_BACKUP_FAILED);
}
} catch (error) {
selfClient.trackEvent(PointEvents.EARN_BACKUP_FAILED);
console.error('Error recording backup points after return:', error);
}
};
recordPoints();
}
// Reset the flag after checking
shouldCheckBackupRef.current = false;
}, [navigation, selfClient]),
);
// Mock function to check if user has backed up their account
const hasUserBackedUpAccount = (): boolean => {
return hasCompletedBackupForPoints;
};
useEffect(() => {
loadEvents();
}, [loadEvents]);
useEffect(() => {
const checkSubscription = async () => {
const subscribed = await isTopicSubscribed('general');
setIsGeneralSubscribed(subscribed);
};
checkSubscription();
}, []);
const handleEnableNotifications = async () => {
if (isEnabling) {
return;
}
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION);
setIsEnabling(true);
try {
const granted = await requestNotificationPermission();
if (granted) {
const result = await subscribeToTopics(['general']);
if (result.successes.length > 0) {
const response = await recordNotificationPointEvent();
if (response.success) {
setIsGeneralSubscribed(true);
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_SUCCESS);
navigation.navigate('Gratification', {
points: POINT_VALUES.notification,
});
} else {
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_FAILED, {
reason: 'Failed to record points',
});
const callbackId = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: () => {},
});
navigation.navigate('Modal', {
titleText: 'Verification Failed',
bodyText:
response.error ||
'Failed to register points. Please try again.',
buttonText: 'OK',
callbackId,
});
}
} else {
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_FAILED, {
reason: 'Subscription failed',
});
const callbackId = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: () => {},
});
navigation.navigate('Modal', {
titleText: 'Error',
bodyText: `Failed to enable: ${result.failures.map(f => f.error).join(', ')}`,
buttonText: 'OK',
callbackId,
});
}
} else {
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_FAILED, {
reason: 'Permission denied',
});
const callbackId = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: () => {},
});
navigation.navigate('Modal', {
titleText: 'Permission Required',
bodyText:
'Could not enable notifications. Please enable them in your device Settings.',
buttonText: 'OK',
callbackId,
});
}
} catch (error) {
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_FAILED, {
reason: 'Exception occurred',
});
const callbackId = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: () => {},
});
navigation.navigate('Modal', {
titleText: 'Error',
bodyText:
error instanceof Error
? error.message
: 'Failed to enable notifications',
buttonText: 'OK',
callbackId,
});
} finally {
setIsEnabling(false);
}
};
const handleBackupSecret = async () => {
if (isBackingUp) {
return;
}
selfClient.trackEvent(PointEvents.EARN_BACKUP);
const { cloudBackupEnabled, turnkeyBackupEnabled } =
useSettingStore.getState();
// If either backup method is already enabled, just record points
if (cloudBackupEnabled || turnkeyBackupEnabled) {
setIsBackingUp(true);
try {
// this will add event to store and the new event will then trigger useIncomingPoints hook to refetch incoming points
const response = await recordBackupPointEvent();
if (response.success) {
setBackupForPointsCompleted();
selfClient.trackEvent(PointEvents.EARN_BACKUP_SUCCESS);
navigation.navigate('Gratification', {
points: POINT_VALUES.backup,
});
} else {
selfClient.trackEvent(PointEvents.EARN_BACKUP_FAILED);
const callbackId = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: () => {},
});
navigation.navigate('Modal', {
titleText: 'Verification Failed',
bodyText:
response.error || 'Failed to register points. Please try again.',
buttonText: 'OK',
callbackId,
});
}
} catch (error) {
selfClient.trackEvent(PointEvents.EARN_BACKUP_FAILED);
const callbackId = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: () => {},
});
navigation.navigate('Modal', {
titleText: 'Error',
bodyText:
error instanceof Error ? error.message : 'Failed to backup account',
buttonText: 'OK',
callbackId,
});
} finally {
setIsBackingUp(false);
}
} else {
// Navigate to backup screen and return to Points after backup completes
// Set flag to check for backup completion when we return
shouldCheckBackupRef.current = true;
navigation.navigate('CloudBackupSettings', { returnToScreen: 'Points' });
}
};
const ListHeader = (
<YStack paddingHorizontal={5} gap={20} paddingTop={20}>
<YStack style={styles.pointsCard}>
<Pressable style={styles.helpButton} onPress={onHelpButtonPress}>
<HelpCircle size={32} color={blue600} />
</Pressable>
<YStack style={styles.pointsCardContent}>
<View style={styles.logoContainer}>
<LogoInversed width={33} height={33} />
</View>
<YStack gap={12} alignItems="center">
<XStack gap={4} alignItems="center">
<Text style={styles.pointsTitle}>{`${points} Self points`}</Text>
</XStack>
<Text style={styles.pointsDescription}>
Earn points by referring friends, disclosing proof requests, and
more.
</Text>
</YStack>
</YStack>
{incomingPoints && (
<XStack style={styles.incomingPointsBar}>
<ClockIcon width={16} height={16} />
<Text style={styles.incomingPointsAmount}>
{`${incomingPoints.amount} incoming points`}
</Text>
<Text style={styles.incomingPointsTime}>
{`Expected in ${formatTimeUntilDate(incomingPoints.expectedDate)}`}
</Text>
</XStack>
)}
</YStack>
{!isGeneralSubscribed && (
<Pressable onPress={handleEnableNotifications} disabled={isEnabling}>
<XStack
style={[styles.actionCard, { opacity: isEnabling ? 0.5 : 1 }]}
>
<View style={styles.actionIconContainer}>
<BellWhiteIcon width={30} height={26} />
</View>
<YStack gap={4} justifyContent="center">
<Text style={styles.actionTitle}>
{isEnabling
? 'Enabling notifications...'
: 'Turn on push notifications'}
</Text>
<Text style={styles.actionSubtitle}>
Earn {POINT_VALUES.notification} points
</Text>
</YStack>
</XStack>
</Pressable>
)}
{!hasUserBackedUpAccount() && (
<Pressable onPress={handleBackupSecret} disabled={isBackingUp}>
<XStack
style={[styles.actionCard, { opacity: isBackingUp ? 0.5 : 1 }]}
>
<View style={styles.actionIconContainer}>
<LockWhiteIcon width={30} height={26} />
</View>
<YStack gap={4} justifyContent="center">
<Text style={styles.actionTitle}>
{isBackingUp ? 'Processing backup...' : 'Backup your account'}
</Text>
<Text style={styles.actionSubtitle}>
Earn {POINT_VALUES.backup} points
</Text>
</YStack>
</XStack>
</Pressable>
)}
<Pressable
onPress={() => {
selfClient.trackEvent(PointEvents.EARN_REFERRAL);
navigation.navigate('Referral');
}}
>
<YStack style={styles.referralCard}>
<ZStack style={styles.referralImageContainer}>
<Image source={MajongImage} style={styles.referralImage} />
<StarBlackIcon
width={24}
height={24}
style={styles.referralStarIcon}
/>
</ZStack>
<YStack padding={16} paddingBottom={32} gap={10}>
<Text style={styles.referralTitle}>
Refer friends and earn rewards
</Text>
<Text style={styles.referralLink}>Refer now</Text>
</YStack>
</YStack>
</Pressable>
</YStack>
);
return (
<YStack flex={1} backgroundColor={slate50}>
<ZStack flex={1}>
<PointHistoryList ListHeaderComponent={ListHeader} />
<YStack
style={[styles.exploreButtonContainer, { bottom: bottom + 20 }]}
>
<Button
style={styles.exploreButton}
onPress={() => {
selfClient.trackEvent(PointEvents.EXPLORE_APPS);
navigation.navigate('WebView', {
url: 'https://apps.self.xyz',
title: 'Explore Apps',
});
}}
>
<Text style={styles.exploreButtonText}>Explore apps</Text>
</Button>
</YStack>
</ZStack>
</YStack>
);
};
const styles = StyleSheet.create({
pointsCard: {
backgroundColor: white,
borderRadius: 10,
borderWidth: 1,
borderColor: slate200,
overflow: 'hidden',
},
pointsCardContent: {
paddingVertical: 30,
paddingHorizontal: 40,
alignItems: 'center',
gap: 20,
},
logoContainer: {
width: 68,
height: 68,
borderRadius: 12,
borderWidth: 1,
borderColor: slate200,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: white,
},
pointsTitle: {
color: black,
textAlign: 'center',
fontFamily: dinot,
fontWeight: '500',
fontSize: 32,
lineHeight: 32,
letterSpacing: -1,
},
pointsDescription: {
color: black,
fontFamily: dinot,
fontSize: 18,
fontWeight: '500',
textAlign: 'center',
paddingHorizontal: 20,
},
incomingPointsBar: {
backgroundColor: slate50,
borderTopWidth: 1,
borderTopColor: slate200,
paddingVertical: 10,
paddingHorizontal: 10,
alignItems: 'center',
gap: 4,
},
incomingPointsAmount: {
flex: 1,
fontFamily: dinot,
fontWeight: '500',
fontSize: 14,
color: black,
},
incomingPointsTime: {
fontFamily: dinot,
fontWeight: '500',
fontSize: 14,
color: blue600,
},
actionCard: {
gap: 22,
backgroundColor: white,
padding: 16,
borderRadius: 17,
borderWidth: 1,
borderColor: slate200,
},
actionIconContainer: {
width: 60,
height: 60,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: black,
},
actionTitle: {
color: black,
fontFamily: dinot,
fontWeight: '500',
fontSize: 16,
},
actionSubtitle: {
color: slate500,
fontFamily: dinot,
fontSize: 14,
},
referralCard: {
height: 270,
backgroundColor: white,
borderRadius: 16,
borderWidth: 1,
borderColor: slate200,
},
referralImageContainer: {
borderBottomWidth: 1,
borderBottomColor: slate200,
height: 170,
},
referralImage: {
width: '80%',
height: '100%',
position: 'absolute',
right: 0,
top: 0,
},
referralStarIcon: {
marginLeft: 16,
marginTop: 16,
},
referralTitle: {
fontFamily: dinot,
fontSize: 16,
color: black,
},
referralLink: {
fontFamily: dinot,
fontSize: 16,
color: blue600,
},
blurView: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 100,
},
exploreButtonContainer: {
position: 'absolute',
left: 20,
right: 20,
},
exploreButton: {
backgroundColor: black,
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 5,
height: 52,
},
exploreButtonText: {
fontFamily: dinot,
fontSize: 16,
color: white,
textAlign: 'center',
},
helpButton: {
position: 'absolute',
top: 0,
right: 0,
padding: 12,
},
});
export default Points;

View File

@@ -0,0 +1,60 @@
// 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 type { NativeStackHeaderProps } from '@react-navigation/native-stack';
import { Text, View } from '@selfxyz/mobile-sdk-alpha/components';
import { NavBar } from '@/components/NavBar/BaseNavBar';
import { black, slate50 } from '@/utils/colors';
import { extraYPadding } from '@/utils/constants';
import { buttonTap } from '@/utils/haptic';
export const PointsNavBar = (props: NativeStackHeaderProps) => {
const insets = useSafeAreaInsets();
const closeButtonWidth = 50;
return (
<NavBar.Container
backgroundColor={slate50}
barStyle={'dark'}
justifyContent="space-between"
paddingTop={Math.max(insets.top, 15) + extraYPadding}
paddingBottom={10}
paddingHorizontal={20}
>
<NavBar.LeftAction
component="close"
color={black}
onPress={() => {
buttonTap();
props.navigation.navigate('Home');
}}
/>
<View flex={1} alignItems="center" justifyContent="center">
<Text
color={black}
fontSize={15}
fontWeight="500"
fontFamily="DIN OT"
textAlign="center"
style={{
letterSpacing: 0.6,
textTransform: 'uppercase',
}}
>
Self Points
</Text>
</View>
<NavBar.RightAction
component={
// Spacer to balance the close button and center the title
<View style={{ width: closeButtonWidth }} />
}
/>
</NavBar.Container>
);
};

View File

@@ -39,6 +39,7 @@ export const WebViewNavBar: React.FC<WebViewNavBarProps> = ({
>
{/* Left: Close Button */}
<Button
testID="WebViewNavBar.closeButton"
unstyled
hitSlop={{ top: 20, bottom: 20, left: 20, right: 10 }}
icon={<X size={24} color={black} />}

View File

@@ -0,0 +1,346 @@
// 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, useMemo, useState } from 'react';
import {
ActivityIndicator,
RefreshControl,
SectionList,
StyleSheet,
} from 'react-native';
import { Card, Text, View, XStack, YStack } from 'tamagui';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { PointEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import HeartIcon from '@/images/icons/heart.svg';
import StarBlackIcon from '@/images/icons/star_black.svg';
import { usePointEventStore } from '@/stores/pointEventStore';
import {
black,
blue600,
slate50,
slate200,
slate300,
slate400,
slate500,
white,
} from '@/utils/colors';
import { dinot, plexMono } from '@/utils/fonts';
import type { PointEvent } from '@/utils/points';
type Section = {
title: string;
data: PointEvent[];
};
export type PointHistoryListProps = {
ListHeaderComponent?:
| React.ComponentType<Record<string, unknown>>
| React.ReactElement
| null;
onLayout?: () => void;
};
const TIME_PERIODS = {
TODAY: 'TODAY',
THIS_WEEK: 'THIS WEEK',
THIS_MONTH: 'THIS MONTH',
MONTH_NAME: (date: Date): string => {
return date.toLocaleString('default', { month: 'long' }).toUpperCase();
},
OLDER: 'OLDER',
};
const getIconForEventType = (type: PointEvent['type']) => {
switch (type) {
case 'disclosure':
return <StarBlackIcon width={20} height={20} />;
default:
return <HeartIcon width={20} height={20} />;
}
};
export const PointHistoryList: React.FC<PointHistoryListProps> = ({
ListHeaderComponent,
onLayout,
}) => {
const selfClient = useSelfClient();
const [refreshing, setRefreshing] = useState(false);
const pointEvents = usePointEventStore(state => state.getAllPointEvents());
const isLoading = usePointEventStore(state => state.isLoading);
const refreshPoints = usePointEventStore(state => state.refreshPoints);
const refreshIncomingPoints = usePointEventStore(
state => state.refreshIncomingPoints,
);
const loadDisclosureEvents = usePointEventStore(
state => state.loadDisclosureEvents,
);
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
const formatDateFull = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString([], {
month: 'short',
day: 'numeric',
});
};
const getTimePeriod = useCallback((timestamp: number): string => {
const now = new Date();
const eventDate = new Date(timestamp);
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
const startOfThisWeek = new Date(startOfToday);
startOfThisWeek.setDate(startOfToday.getDate() - startOfToday.getDay());
const startOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
if (eventDate >= startOfToday) {
return TIME_PERIODS.TODAY;
} else if (eventDate >= startOfThisWeek) {
return TIME_PERIODS.THIS_WEEK;
} else if (eventDate >= startOfThisMonth) {
return TIME_PERIODS.THIS_MONTH;
} else if (eventDate >= startOfLastMonth) {
return TIME_PERIODS.MONTH_NAME(eventDate);
} else {
return TIME_PERIODS.OLDER;
}
}, []);
const groupedEvents = useMemo(() => {
const groups: Record<string, PointEvent[]> = {};
[
TIME_PERIODS.TODAY,
TIME_PERIODS.THIS_WEEK,
TIME_PERIODS.THIS_MONTH,
TIME_PERIODS.OLDER,
].forEach(period => {
groups[period] = [];
});
const monthGroups = new Set<string>();
pointEvents.forEach(event => {
const period = getTimePeriod(event.timestamp);
if (
period !== TIME_PERIODS.TODAY &&
period !== TIME_PERIODS.THIS_WEEK &&
period !== TIME_PERIODS.THIS_MONTH &&
period !== TIME_PERIODS.OLDER
) {
monthGroups.add(period);
if (!groups[period]) {
groups[period] = [];
}
}
groups[period].push(event);
});
const sections: Section[] = [];
[
TIME_PERIODS.TODAY,
TIME_PERIODS.THIS_WEEK,
TIME_PERIODS.THIS_MONTH,
].forEach(period => {
if (groups[period] && groups[period].length > 0) {
sections.push({ title: period, data: groups[period] });
}
});
Array.from(monthGroups)
.sort(
(a, b) =>
new Date(groups[b][0].timestamp).getMonth() -
new Date(groups[a][0].timestamp).getMonth(),
)
.forEach(month => {
sections.push({ title: month, data: groups[month] });
});
if (groups[TIME_PERIODS.OLDER] && groups[TIME_PERIODS.OLDER].length > 0) {
sections.push({
title: TIME_PERIODS.OLDER,
data: groups[TIME_PERIODS.OLDER],
});
}
return sections;
}, [pointEvents, getTimePeriod]);
const renderItem = useCallback(
({
item,
index,
section,
}: {
item: PointEvent;
index: number;
section: Section;
}) => {
const borderRadiusSize = 16;
const isFirstItem = index === 0;
const isLastItem = index === section.data.length - 1;
return (
<View paddingHorizontal={5}>
<YStack gap={8}>
<Card
borderTopLeftRadius={isFirstItem ? borderRadiusSize : 0}
borderTopRightRadius={isFirstItem ? borderRadiusSize : 0}
borderBottomLeftRadius={isLastItem ? borderRadiusSize : 0}
borderBottomRightRadius={isLastItem ? borderRadiusSize : 0}
borderBottomWidth={1}
borderColor={slate200}
padded
backgroundColor={white}
>
<XStack alignItems="center" gap={12}>
<View height={46} alignItems="center" justifyContent="center">
{getIconForEventType(item.type)}
</View>
<YStack flex={1}>
<Text
fontSize={16}
color={black}
fontWeight="500"
fontFamily={dinot}
>
{item.title}
</Text>
<Text
fontFamily={plexMono}
color={slate400}
fontSize={14}
marginTop={2}
>
{formatDateFull(item.timestamp)} {' '}
{formatDate(item.timestamp)}
</Text>
</YStack>
<Text
fontSize={18}
color={blue600}
fontWeight="600"
fontFamily={dinot}
>
+{item.points}
</Text>
</XStack>
</Card>
</YStack>
</View>
);
},
[],
);
const renderSectionHeader = useCallback(
({ section }: { section: Section }) => {
return (
<View
paddingHorizontal={20}
backgroundColor={slate50}
marginTop={20}
marginBottom={12}
gap={12}
>
<Text
color={slate500}
fontSize={15}
fontWeight="500"
letterSpacing={0.6}
fontFamily={dinot}
>
{section.title.toUpperCase()}
</Text>
</View>
);
},
[],
);
const onRefresh = useCallback(() => {
selfClient.trackEvent(PointEvents.REFRESH_HISTORY);
setRefreshing(true);
Promise.all([
refreshPoints(),
refreshIncomingPoints(),
loadDisclosureEvents(),
]).finally(() => setRefreshing(false));
}, [selfClient, refreshPoints, refreshIncomingPoints, loadDisclosureEvents]);
const keyExtractor = useCallback((item: PointEvent) => item.id, []);
const renderEmptyComponent = useCallback(() => {
if (isLoading) {
return (
<View style={styles.emptyContainer}>
<ActivityIndicator size="large" color={slate300} />
<Text color={slate300} marginTop={16}>
Loading point history...
</Text>
</View>
);
}
return (
<View style={styles.emptyContainer}>
<Text color={slate300}>No point history available yet.</Text>
<Text color={slate500} fontSize={14} marginTop={8} textAlign="center">
Start earning points by completing actions!
</Text>
</View>
);
}, [isLoading]);
return (
<SectionList
sections={groupedEvents}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
keyExtractor={keyExtractor}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
contentContainerStyle={[
styles.listContent,
groupedEvents.length === 0 && styles.emptyList,
]}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
ListEmptyComponent={renderEmptyComponent}
ListHeaderComponent={ListHeaderComponent}
style={{ marginHorizontal: 15, marginBottom: 25 }}
onLayout={onLayout}
/>
);
};
const styles = StyleSheet.create({
listContent: {
paddingBottom: 100,
},
emptyList: {
flexGrow: 1,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 5,
},
});
export default PointHistoryList;

View File

@@ -0,0 +1,72 @@
// 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, { useState } from 'react';
import { Button, Text, XStack } from 'tamagui';
import Clipboard from '@react-native-clipboard/clipboard';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { PointEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import CopyToClipboard from '@/images/icons/copy_to_clipboard.svg';
import { black, green500, white } from '@/utils/colors';
import { dinot } from '@/utils/fonts';
export interface CopyReferralButtonProps {
referralLink: string;
onCopy?: () => void;
}
export const CopyReferralButton: React.FC<CopyReferralButtonProps> = ({
referralLink,
onCopy,
}) => {
const [isCopied, setIsCopied] = useState(false);
const selfClient = useSelfClient();
const handleCopyLink = async () => {
try {
selfClient.trackEvent(PointEvents.EARN_REFERRAL_COPY_LINK);
await Clipboard.setString(referralLink);
setIsCopied(true);
// Reset after 1.65 seconds
setTimeout(() => {
setIsCopied(false);
}, 1650);
onCopy?.();
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
};
return (
<Button
backgroundColor={isCopied ? green500 : black}
paddingHorizontal={32}
paddingVertical={18}
borderRadius={40}
height={60}
onPress={handleCopyLink}
pressStyle={{ opacity: 0.8 }}
disabled={isCopied}
>
<XStack gap={10} alignItems="center" flex={1}>
<Text
fontFamily={dinot}
fontSize={16}
fontWeight="500"
color={white}
flex={1}
>
{isCopied
? 'Referral link copied to clipboard'
: 'Copy referral link'}
</Text>
<CopyToClipboard width={24} height={24} />
</XStack>
</Button>
);
};

View File

@@ -0,0 +1,60 @@
// 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, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, View } from 'tamagui';
import ArrowLeft from '@/images/icons/arrow_left.svg';
import { black, white } from '@/utils/colors';
export interface ReferralHeaderProps {
imageSource: ImageSourcePropType;
onBackPress: () => void;
}
export const ReferralHeader: React.FC<ReferralHeaderProps> = ({
imageSource,
onBackPress,
}) => {
const { top } = useSafeAreaInsets();
return (
<View height={430} position="relative" overflow="hidden">
<Image
source={imageSource}
style={{
width: '100%',
height: '100%',
resizeMode: 'cover',
}}
/>
{/* Back button */}
<View position="absolute" top={top + 16} left={20} zIndex={10}>
<Pressable onPress={onBackPress}>
<View
backgroundColor={white}
width={46}
height={46}
borderRadius={60}
alignItems="center"
justifyContent="center"
>
<Text
fontFamily="SF Pro"
fontSize={24}
lineHeight={29}
color={black}
>
<ArrowLeft width={24} height={24} />
</Text>
</View>
</Pressable>
</View>
</View>
);
};

View File

@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Pressable } from 'react-native';
import { Text, YStack } from 'tamagui';
import { black, blue600, slate500 } from '@/utils/colors';
import { dinot } from '@/utils/fonts';
export interface ReferralInfoProps {
title: string;
description: string;
learnMoreText?: string;
onLearnMorePress?: () => void;
}
export const ReferralInfo: React.FC<ReferralInfoProps> = ({
title,
description,
learnMoreText,
onLearnMorePress,
}) => {
return (
<YStack gap={12} alignItems="center">
<Text
fontFamily={dinot}
fontSize={24}
fontWeight="500"
color={black}
textAlign="center"
>
{title}
</Text>
<YStack gap={0}>
<Text
fontFamily={dinot}
fontSize={16}
fontWeight="500"
color={slate500}
textAlign="center"
>
{description}
</Text>
{learnMoreText && (
<Pressable onPress={onLearnMorePress}>
<Text
fontFamily={dinot}
fontSize={16}
fontWeight="500"
color={blue600}
textAlign="center"
>
{learnMoreText}
</Text>
</Pressable>
)}
</YStack>
</YStack>
);
};

View File

@@ -0,0 +1,49 @@
// 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, YStack } from 'tamagui';
import { slate800 } from '@/utils/colors';
import { dinot } from '@/utils/fonts';
export interface ShareButtonProps {
icon: React.ReactNode;
label: string;
backgroundColor: string;
onPress: () => void;
}
export const ShareButton: React.FC<ShareButtonProps> = ({
icon,
label,
backgroundColor,
onPress,
}) => {
return (
<Pressable onPress={onPress}>
<YStack gap={8} alignItems="center">
<View
backgroundColor={backgroundColor}
width={64}
height={64}
borderRadius={60}
alignItems="center"
justifyContent="center"
>
{icon}
</View>
<Text
fontFamily={dinot}
fontSize={14}
fontWeight="500"
color={slate800}
>
{label}
</Text>
</YStack>
</Pressable>
);
};

View File

@@ -0,0 +1,194 @@
// 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 { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
import type { RootStackParamList } from '@/navigation';
import useUserStore from '@/stores/userStore';
import { registerModalCallbacks } from '@/utils/modalCallbackRegistry';
import {
hasUserAnIdentityDocumentRegistered,
hasUserDoneThePointsDisclosure,
POINT_VALUES,
pointsSelfApp,
} from '@/utils/points';
type UseEarnPointsFlowParams = {
hasReferrer: boolean;
isReferralConfirmed: boolean | undefined;
};
export const useEarnPointsFlow = ({
hasReferrer,
isReferralConfirmed,
}: UseEarnPointsFlowParams) => {
const selfClient = useSelfClient();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { registerReferral } = useRegisterReferral();
const referrer = useUserStore(state => state.deepLinkReferrer);
const navigateToPointsProof = useCallback(async () => {
const selfApp = await pointsSelfApp();
selfClient.getSelfAppState().setSelfApp(selfApp);
// Use setTimeout to ensure modal dismisses before navigating
setTimeout(() => {
navigation.navigate('Prove');
}, 100);
}, [selfClient, navigation]);
const showIdentityVerificationModal = useCallback(() => {
const callbackId = registerModalCallbacks({
onButtonPress: () => {
// Use setTimeout to ensure modal dismisses before navigating
setTimeout(() => {
navigation.navigate('CountryPicker');
}, 100);
},
onModalDismiss: () => {
if (hasReferrer) {
useUserStore.getState().clearDeepLinkReferrer();
}
},
});
navigation.navigate('Modal', {
titleText: 'Identity Verification Required',
bodyText:
'To access Self Points, you need to register an identity document with Self first. This helps us verify your identity and keep your points secure.',
buttonText: 'Verify Identity',
secondaryButtonText: 'Not Now',
callbackId,
});
}, [hasReferrer, navigation]);
const showPointsDisclosureModal = useCallback(() => {
const callbackId = registerModalCallbacks({
onButtonPress: () => {
navigateToPointsProof();
},
onModalDismiss: () => {
if (hasReferrer) {
useUserStore.getState().clearDeepLinkReferrer();
}
},
});
navigation.navigate('Modal', {
titleText: 'Points Disclosure Required',
bodyText:
'To access Self Points, you need to complete the points disclosure first. This helps us verify your identity and keep your points secure.',
buttonText: 'Complete Points Disclosure',
secondaryButtonText: 'Not Now',
callbackId,
});
}, [hasReferrer, navigation, navigateToPointsProof]);
const showPointsInfoScreen = useCallback(() => {
navigation.navigate('PointsInfo', {
showNextButton: true,
onNextButtonPress: () => {
showPointsDisclosureModal();
},
});
}, [navigation, showPointsDisclosureModal]);
const handleReferralFlow = useCallback(async () => {
if (!referrer) {
return;
}
const showReferralErrorModal = (errorMessage: string) => {
const callbackId = registerModalCallbacks({
onButtonPress: async () => {
await handleReferralFlow();
},
onModalDismiss: () => {
// Clear referrer when user dismisses to prevent retry loop
useUserStore.getState().clearDeepLinkReferrer();
},
});
navigation.navigate('Modal', {
titleText: 'Referral Registration Failed',
bodyText: `We couldn't register your referral at this time. ${errorMessage}. You can try again or dismiss this message.`,
buttonText: 'Try Again',
secondaryButtonText: 'Dismiss',
callbackId,
});
};
const store = useUserStore.getState();
// Check if already registered to avoid duplicate calls
if (!store.isReferrerRegistered(referrer)) {
const result = await registerReferral(referrer);
if (result.success) {
store.markReferrerAsRegistered(referrer);
// Only navigate to GratificationScreen on success
store.clearDeepLinkReferrer();
navigation.navigate('Gratification', {
points: POINT_VALUES.referee,
});
} else {
// Registration failed - show error and preserve referrer
const errorMessage = result.error || 'Unknown error occurred';
console.error('Referral registration failed:', errorMessage);
// Show error modal with retry option, don't clear referrer
showReferralErrorModal(errorMessage);
}
} else {
// Already registered, navigate to gratification
store.clearDeepLinkReferrer();
navigation.navigate('Gratification', {
points: POINT_VALUES.referee,
});
}
}, [referrer, registerReferral, navigation]);
const onEarnPointsPress = useCallback(
async (skipReferralFlow = true) => {
const hasUserAnIdentityDocumentRegistered_result =
await hasUserAnIdentityDocumentRegistered();
if (!hasUserAnIdentityDocumentRegistered_result) {
showIdentityVerificationModal();
return;
}
const hasUserDoneThePointsDisclosure_result =
await hasUserDoneThePointsDisclosure();
if (!hasUserDoneThePointsDisclosure_result) {
showPointsInfoScreen();
return;
}
// User has completed both checks
if (!skipReferralFlow && hasReferrer && isReferralConfirmed === true) {
await handleReferralFlow();
} else {
// Just go to points upon pressing "Earn Points" button
if (!hasReferrer) {
navigation.navigate('Points');
}
}
},
[
hasReferrer,
isReferralConfirmed,
navigation,
showIdentityVerificationModal,
showPointsInfoScreen,
handleReferralFlow,
],
);
return { onEarnPointsPress };
};

View File

@@ -0,0 +1,53 @@
// 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 { useEffect } from 'react';
import { usePointEventStore } from '@/stores/pointEventStore';
import { getNextSundayNoonUTC, type IncomingPoints } from '@/utils/points';
/*
* Hook to get incoming points for the user. It shows the optimistic incoming points.
* Refreshes incoming points once on mount.
*/
export const useIncomingPoints = (): IncomingPoints => {
const incomingPoints = usePointEventStore(state => state.incomingPoints);
const totalOptimisticIncomingPoints = usePointEventStore(state =>
state.totalOptimisticIncomingPoints(),
);
const refreshIncomingPoints = usePointEventStore(
state => state.refreshIncomingPoints,
);
useEffect(() => {
// Only refresh once on mount - the store handles promise caching for concurrent calls
refreshIncomingPoints();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty deps: only run once on mount
return {
amount: totalOptimisticIncomingPoints,
expectedDate: incomingPoints.expectedDate,
};
};
/*
* Hook to fetch total points for the user. It refetches the total points when the next points update time is reached (each Sunday noon UTC).
*/
export const usePoints = () => {
const points = usePointEventStore(state => state.points);
const nextPointsUpdate = getNextSundayNoonUTC().getTime();
const refreshPoints = usePointEventStore(state => state.refreshPoints);
useEffect(() => {
refreshPoints();
// refresh when points update time changes as its the only time points can change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nextPointsUpdate]);
return {
amount: points,
refetch: refreshPoints,
};
};

View File

@@ -0,0 +1,51 @@
// 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, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation';
import {
hasUserAnIdentityDocumentRegistered,
hasUserDoneThePointsDisclosure,
} from '@/utils/points';
/**
* Guard hook that validates points screen access requirements.
* Redirects to Home if user hasn't:
* 1. Registered an identity document
* 2. Completed the points disclosure
*
* This prevents users from accessing the Points screen through:
* - GratificationScreen's "Explore rewards" button
* - CloudBackupSettings return paths
* - Any other navigation bypass
*/
export const usePointsGuardrail = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
useFocusEffect(
useCallback(() => {
let isActive = true;
const checkRequirements = async () => {
const hasDocument = await hasUserAnIdentityDocumentRegistered();
const hasDisclosed = await hasUserDoneThePointsDisclosure();
// Only navigate if the screen is still focused
if (isActive && (!hasDocument || !hasDisclosed)) {
// User hasn't met requirements, redirect to Home
navigation.navigate('Home', {});
}
};
checkRequirements();
return () => {
isActive = false;
};
}, [navigation]),
);
};

View File

@@ -0,0 +1,109 @@
// 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 { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation';
import useUserStore from '@/stores/userStore';
import { registerModalCallbacks } from '@/utils/modalCallbackRegistry';
type UseReferralConfirmationParams = {
hasReferrer: boolean;
onConfirmed: () => void;
};
export const useReferralConfirmation = ({
hasReferrer,
onConfirmed,
}: UseReferralConfirmationParams) => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const referrer = useUserStore(state => state.deepLinkReferrer);
const isReferrerRegistered = useUserStore(
state => state.isReferrerRegistered,
);
// State machine: undefined (not shown) → true (confirmed) / false (dismissed)
const [isReferralConfirmed, setIsReferralConfirmed] = useState<
boolean | undefined
>(undefined);
// Guard to ensure callback executes exactly once per referral
const hasTriggeredFlowRef = useRef(false);
const showReferralConfirmationModal = useCallback(() => {
const callbackId = registerModalCallbacks({
onButtonPress: async () => {
setIsReferralConfirmed(true);
// CRITICAL: setTimeout ensures React completes render cycle before navigation
// Without this, navigation happens with stale state causing flow to re-trigger
setTimeout(() => {
navigation.goBack();
}, 100);
},
onModalDismiss: () => {
setIsReferralConfirmed(false);
useUserStore.getState().clearDeepLinkReferrer();
},
});
navigation.navigate('Modal', {
titleText: 'Referral Confirmation',
bodyText:
'Seems like you opened the app from a referral link. Please confirm to continue.',
buttonText: 'Confirm',
secondaryButtonText: 'Dismiss',
callbackId,
});
}, [navigation]);
// Reset the trigger flag when referrer changes or is cleared
useEffect(() => {
hasTriggeredFlowRef.current = false;
}, [referrer]);
// Handle referral confirmation flow
useEffect(() => {
// === Common validation: Has valid, unregistered referrer ===
const hasValidReferrer =
hasReferrer && referrer && !isReferrerRegistered(referrer);
// === CHECK 1: Execute callback after user confirms (evaluated first due to early return) ===
const shouldExecuteCallback =
hasValidReferrer &&
isReferralConfirmed === true &&
!hasTriggeredFlowRef.current;
if (shouldExecuteCallback) {
console.log('[Referral] Scheduling onConfirmed callback');
hasTriggeredFlowRef.current = true;
// CRITICAL: setTimeout ensures React completes render cycle before executing callback
// This prevents stale closure issues where the callback has old state values
setTimeout(() => {
console.log('[Referral] Executing onConfirmed callback');
onConfirmed();
}, 150);
return;
}
// === CHECK 2: Show modal for unconfirmed referrals ===
const shouldShowModal =
hasValidReferrer && isReferralConfirmed === undefined;
if (shouldShowModal) {
showReferralConfirmationModal();
}
}, [
hasReferrer,
referrer,
isReferralConfirmed,
isReferrerRegistered,
showReferralConfirmationModal,
onConfirmed,
]);
return { isReferralConfirmed };
};

View File

@@ -0,0 +1,59 @@
// 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 { useEffect, useMemo, useState } from 'react';
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
import { useSettingStore } from '@/stores/settingStore';
interface ReferralMessageResult {
message: string;
referralLink: string;
}
const buildReferralMessageFromAddress = (
userPointsAddress: string,
): ReferralMessageResult => {
const baseDomain = 'https://referral.self.xyz';
const referralLink = `${baseDomain}/referral/${userPointsAddress}`;
return {
message: `Join Self and use my referral link:\n\n${referralLink}`,
referralLink,
};
};
export const useReferralMessage = () => {
const pointsAddress = useSettingStore(state => state.pointsAddress);
const [fetchedAddress, setFetchedAddress] = useState<string | null>(null);
// Use store address if available, otherwise use fetched address
const address = pointsAddress ?? fetchedAddress;
// Compute message synchronously when address is available
const result = useMemo(
() => (address ? buildReferralMessageFromAddress(address) : null),
[address],
);
useEffect(() => {
if (!pointsAddress) {
// Only fetch if not already in store
const loadReferralData = async () => {
const fetchedAddr = await getOrGeneratePointsAddress();
setFetchedAddress(fetchedAddr);
};
loadReferralData();
}
}, [pointsAddress]);
return useMemo(
() => ({
message: result?.message ?? '',
referralLink: result?.referralLink ?? '',
isLoading: !result,
}),
[result],
);
};

View File

@@ -0,0 +1,47 @@
// 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 { useEffect } from 'react';
import { useRoute } from '@react-navigation/native';
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
import useUserStore from '@/stores/userStore';
/**
* Hook to handle referral registration when a referrer is present in route params.
* Automatically registers the referral if:
* - A referrer is present in route params
* - The referrer hasn't been registered yet
* - Registration is not already in progress
*/
export const useReferralRegistration = () => {
const route = useRoute();
const params = route.params as { referrer?: string } | undefined;
const referrer = params?.referrer;
const { registerReferral, isLoading: isRegisteringReferral } =
useRegisterReferral();
useEffect(() => {
if (!referrer || isRegisteringReferral) {
return;
}
const store = useUserStore.getState();
// Check if this referrer has already been registered
if (store.isReferrerRegistered(referrer)) {
return;
}
// Register the referral
const register = async () => {
const result = await registerReferral(referrer);
if (result.success) {
store.markReferrerAsRegistered(referrer);
}
};
register();
}, [referrer, isRegisteringReferral, registerReferral]);
};

View File

@@ -0,0 +1,50 @@
// 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 { ethers } from 'ethers';
import { useCallback, useState } from 'react';
import { recordReferralPointEvent } from '@/utils/points';
export const useRegisterReferral = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const registerReferral = useCallback(async (referrer: string) => {
setIsLoading(true);
setError(null);
try {
// Validate referrer address format
if (!ethers.isAddress(referrer)) {
const errorMessage =
'Invalid referrer address. Must be a valid hex address.';
setError(errorMessage);
return { success: false, error: errorMessage };
}
// recordReferralPointEvent handles both API registration and local event recording
const result = await recordReferralPointEvent(referrer);
if (result.success) {
return { success: true };
}
const errorMessage = result.error || 'Failed to register referral';
setError(errorMessage);
return { success: false, error: errorMessage };
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'An unexpected error occurred';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setIsLoading(false);
}
}, []);
return {
registerReferral,
isLoading,
error,
};
};

View File

@@ -0,0 +1,60 @@
// 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 } from 'react';
import useUserStore from '@/stores/userStore';
import { IS_DEV_MODE } from '@/utils/devUtils';
const TEST_REFERRER = '0x1234567890123456789012345678901234567890';
/**
* Hook for testing referral flow in DEV mode.
* Provides automatic timeout trigger (3 seconds) and manual trigger function.
*
* Flow: Sets referrer → shows confirmation modal → on confirm, checks prerequisites
* → if identity doc & points disclosure done → registers referral → navigates to Gratification
*
* @param shouldAutoTrigger - Whether to automatically trigger the flow after 3 seconds (default: false)
*/
export const useTestReferralFlow = (shouldAutoTrigger = false) => {
const referralTimerRef = useRef<NodeJS.Timeout | null>(null);
const triggerReferralFlow = useCallback(() => {
if (IS_DEV_MODE) {
const testReferrer = TEST_REFERRER;
const store = useUserStore.getState();
store.setDeepLinkReferrer(testReferrer);
// Trigger the referral confirmation modal
// The useReferralConfirmation hook will handle showing the modal
}
}, []);
// Automatic trigger after 3 seconds (only if shouldAutoTrigger is true)
useEffect(() => {
if (IS_DEV_MODE && shouldAutoTrigger) {
referralTimerRef.current = setTimeout(() => {
triggerReferralFlow();
}, 3000);
}
return () => {
if (referralTimerRef.current) {
clearTimeout(referralTimerRef.current);
}
};
}, [triggerReferralFlow, shouldAutoTrigger]);
const handleTestReferralFlow = useCallback(() => {
if (IS_DEV_MODE) {
triggerReferralFlow();
}
}, [triggerReferralFlow]);
return {
handleTestReferralFlow,
isDevMode: IS_DEV_MODE,
};
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 208 KiB

View File

@@ -0,0 +1,3 @@
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 8.88281C0 8.58594 0.121094 8.32422 0.363281 8.09766L8.12109 0.351562C8.24609 0.226562 8.37109 0.136719 8.49609 0.0820312C8.62891 0.0273437 8.76562 0 8.90625 0C9.19531 0 9.4375 0.0976562 9.63281 0.292969C9.83594 0.480469 9.9375 0.71875 9.9375 1.00781C9.9375 1.14844 9.91016 1.28516 9.85547 1.41797C9.80859 1.54297 9.73828 1.65234 9.64453 1.74609L7.01953 4.41797L2.37891 8.66016L2.13281 8.07422L5.90625 7.83984H20.7305C21.0352 7.83984 21.2812 7.9375 21.4688 8.13281C21.6641 8.32812 21.7617 8.57812 21.7617 8.88281C21.7617 9.1875 21.6641 9.4375 21.4688 9.63281C21.2812 9.82812 21.0352 9.92578 20.7305 9.92578H5.90625L2.13281 9.69141L2.37891 9.11719L7.01953 13.3477L9.64453 16.0195C9.73828 16.1133 9.80859 16.2266 9.85547 16.3594C9.91016 16.4844 9.9375 16.6172 9.9375 16.7578C9.9375 17.0469 9.83594 17.2852 9.63281 17.4727C9.4375 17.668 9.19531 17.7656 8.90625 17.7656C8.625 17.7656 8.37109 17.6562 8.14453 17.4375L0.363281 9.66797C0.121094 9.44141 0 9.17969 0 8.88281Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="22" height="24" viewBox="0 0 22 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.04004 18.7129C1.41699 18.7129 0.919271 18.5446 0.546875 18.208C0.181641 17.8714 -0.000976562 17.4274 -0.000976562 16.876C-0.000976562 16.4678 0.0921224 16.0846 0.27832 15.7266C0.464518 15.3613 0.708008 15.0212 1.00879 14.7061C1.30957 14.3838 1.62826 14.0723 1.96484 13.7715C2.24414 13.5352 2.4554 13.2129 2.59863 12.8047C2.74902 12.3965 2.86361 11.9346 2.94238 11.4189C3.02116 10.9033 3.09993 10.3626 3.17871 9.79688C3.26465 8.57943 3.46875 7.48014 3.79102 6.49902C4.11328 5.51074 4.56803 4.67643 5.15527 3.99609C5.74967 3.30859 6.49089 2.80729 7.37891 2.49219C7.62956 1.76888 8.05208 1.17448 8.64648 0.708984C9.24089 0.236328 9.93555 0 10.7305 0C11.3607 0 11.9193 0.14681 12.4062 0.44043C11.9622 0.905924 11.6077 1.4502 11.3428 2.07324C11.085 2.68913 10.9561 3.34798 10.9561 4.0498C10.9561 5.00944 11.1924 5.8903 11.665 6.69238C12.1449 7.4873 12.7822 8.12467 13.5771 8.60449C14.3792 9.08431 15.2637 9.32422 16.2305 9.32422C16.5885 9.32422 16.9359 9.28841 17.2725 9.2168C17.6162 9.13802 17.9421 9.02702 18.25 8.88379C18.3001 9.20605 18.3431 9.53906 18.3789 9.88281C18.4147 10.2194 18.4469 10.5596 18.4756 10.9033C18.5186 11.2829 18.5794 11.6553 18.6582 12.0205C18.737 12.3857 18.8408 12.7223 18.9697 13.0303C19.1058 13.3382 19.2812 13.5853 19.4961 13.7715C19.8327 14.0723 20.1514 14.3838 20.4521 14.7061C20.7529 15.0212 20.9964 15.3613 21.1826 15.7266C21.376 16.0846 21.4727 16.4678 21.4727 16.876C21.4727 17.4274 21.2865 17.8714 20.9141 18.208C20.5417 18.5446 20.0439 18.7129 19.4209 18.7129H2.04004ZM10.7412 23.3428C10.068 23.3428 9.46289 23.1995 8.92578 22.9131C8.39583 22.6338 7.96973 22.2614 7.64746 21.7959C7.3252 21.3304 7.14258 20.8219 7.09961 20.2705H14.3828C14.3398 20.8219 14.1572 21.3304 13.835 21.7959C13.5127 22.2614 13.083 22.6338 12.5459 22.9131C12.016 23.1995 11.4144 23.3428 10.7412 23.3428ZM16.2412 7.73438C15.568 7.73438 14.9521 7.56608 14.3936 7.22949C13.835 6.8929 13.3874 6.44531 13.0508 5.88672C12.7214 5.32812 12.5566 4.71582 12.5566 4.0498C12.5566 3.37663 12.7214 2.76074 13.0508 2.20215C13.3874 1.64355 13.835 1.19954 14.3936 0.870117C14.9521 0.533529 15.568 0.365234 16.2412 0.365234C16.9072 0.365234 17.5195 0.533529 18.0781 0.870117C18.6367 1.19954 19.0843 1.64355 19.4209 2.20215C19.7575 2.76074 19.9258 3.37663 19.9258 4.0498C19.9258 4.71582 19.7575 5.32812 19.4209 5.88672C19.0843 6.44531 18.6367 6.8929 18.0781 7.22949C17.5195 7.56608 16.9072 7.73438 16.2412 7.73438Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,3 @@
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.71289 20.2451C0.589844 19.1318 0.0234375 18.0088 0.0136719 16.876C0.00390625 15.7334 0.555664 14.6006 1.66895 13.4775L13.4902 1.6709C14.6035 0.557617 15.7266 0.00585937 16.8594 0.015625C18.002 0.0253906 19.1348 0.591797 20.2578 1.71484L32.0059 13.4629C33.1289 14.5859 33.6904 15.7188 33.6904 16.8613C33.7002 18.0039 33.1484 19.127 32.0352 20.2305L20.2285 32.0518C19.1152 33.1553 17.9873 33.7021 16.8447 33.6924C15.7119 33.6924 14.584 33.1309 13.4609 32.0078L1.71289 20.2451ZM15.2334 24.083C15.5068 24.083 15.7559 24.0195 15.9805 23.8926C16.2148 23.7656 16.4199 23.5752 16.5957 23.3213L23.4512 12.6279C23.5488 12.4717 23.6367 12.3057 23.7148 12.1299C23.793 11.9443 23.832 11.7686 23.832 11.6025C23.832 11.2217 23.6855 10.9141 23.3926 10.6797C23.1094 10.4453 22.7871 10.3281 22.4258 10.3281C21.9473 10.3281 21.5518 10.582 21.2393 11.0898L15.1748 20.7871L12.3623 17.2129C12.167 16.9688 11.9766 16.7979 11.791 16.7002C11.6055 16.6025 11.3955 16.5537 11.1611 16.5537C10.79 16.5537 10.4727 16.6904 10.209 16.9639C9.95508 17.2275 9.82812 17.5449 9.82812 17.916C9.82812 18.1016 9.8623 18.2822 9.93066 18.458C9.99902 18.6338 10.0967 18.8047 10.2236 18.9707L13.8125 23.3359C14.0273 23.5996 14.2471 23.79 14.4717 23.9072C14.6963 24.0244 14.9502 24.083 15.2334 24.083Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 4V8L10.5 9.5" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,3 @@
<svg width="38" height="26" viewBox="0 0 38 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.2539 26.002H9.00977C7.73047 26.002 6.54395 25.7822 5.4502 25.3428C4.36621 24.9131 3.41406 24.3271 2.59375 23.585C1.77344 22.833 1.13379 21.9639 0.674805 20.9775C0.225586 19.9912 0.000976562 18.9414 0.000976562 17.8281C0.000976562 16.6074 0.220703 15.4844 0.660156 14.459C1.09961 13.4238 1.72461 12.5645 2.53516 11.8809C3.3457 11.1973 4.30762 10.7578 5.4209 10.5625C5.45996 9.53711 5.70898 8.60938 6.16797 7.7793C6.62695 6.93945 7.22266 6.24609 7.95508 5.69922C8.69727 5.14258 9.52246 4.77148 10.4307 4.58594C11.3389 4.39063 12.252 4.41992 13.1699 4.67383C13.7852 3.78516 14.5176 2.98926 15.3672 2.28613C16.2168 1.58301 17.1787 1.02637 18.2529 0.616211C19.3369 0.206055 20.5332 0.000976562 21.8418 0.000976562C23.375 0.000976562 24.8008 0.279297 26.1191 0.835938C27.4375 1.39258 28.5898 2.18359 29.5762 3.20898C30.5723 4.23438 31.3438 5.44043 31.8906 6.82715C32.4473 8.21387 32.7256 9.7373 32.7256 11.3975C33.6924 11.7979 34.5273 12.3545 35.2305 13.0674C35.9336 13.7803 36.4707 14.6006 36.8418 15.5283C37.2129 16.4561 37.3984 17.4375 37.3984 18.4727C37.3984 19.5176 37.1885 20.4941 36.7686 21.4023C36.3584 22.3105 35.7822 23.1113 35.04 23.8047C34.2979 24.4883 33.4287 25.0254 32.4326 25.416C31.4463 25.8066 30.3867 26.002 29.2539 26.002ZM17.6377 19.9961C18.1748 19.9961 18.5947 19.7568 18.8975 19.2783L24.6396 10.0498C24.7178 9.92285 24.791 9.78125 24.8594 9.625C24.9375 9.45898 24.9766 9.28809 24.9766 9.1123C24.9766 8.76074 24.8447 8.46777 24.5811 8.2334C24.3174 7.99902 24.0049 7.88184 23.6436 7.88184C23.1553 7.88184 22.7695 8.12109 22.4863 8.59961L17.5791 16.7588L15.0889 13.5654C14.8057 13.165 14.4346 12.9648 13.9756 12.9648C13.624 12.9648 13.3164 13.0918 13.0527 13.3457C12.7988 13.5898 12.6719 13.9023 12.6719 14.2832C12.6719 14.6055 12.7939 14.9229 13.0381 15.2354L16.3193 19.3076C16.5049 19.542 16.7051 19.7178 16.9199 19.835C17.1348 19.9424 17.374 19.9961 17.6377 19.9961Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.79297 18.6377C2.56836 18.6377 1.63021 18.3154 0.978516 17.6709C0.326823 17.0192 0.000976562 16.0846 0.000976562 14.8672V3.77051C0.000976562 2.55306 0.326823 1.62207 0.978516 0.977539C1.63021 0.325846 2.56836 0 3.79297 0H14.8467C16.0641 0 16.9987 0.325846 17.6504 0.977539C18.3021 1.62207 18.6279 2.55306 18.6279 3.77051V6.41309H15.8564V4.11426C15.8564 3.65592 15.7419 3.31934 15.5127 3.10449C15.2907 2.88249 14.9648 2.77148 14.5352 2.77148H4.09375C3.66406 2.77148 3.33464 2.88249 3.10547 3.10449C2.88346 3.31934 2.77246 3.65592 2.77246 4.11426V14.5234C2.77246 14.9818 2.88346 15.3219 3.10547 15.5439C3.33464 15.7588 3.66406 15.8662 4.09375 15.8662H6.92969V18.6377H3.79297ZM9.38965 23.9766C8.15788 23.9766 7.21615 23.6507 6.56445 22.999C5.91276 22.3545 5.58691 21.4235 5.58691 20.2061V9.10938C5.58691 7.89193 5.91276 6.96094 6.56445 6.31641C7.21615 5.66471 8.15788 5.33887 9.38965 5.33887H20.4326C21.6572 5.33887 22.5918 5.66471 23.2363 6.31641C23.888 6.9681 24.2139 7.89909 24.2139 9.10938V20.2061C24.2139 21.4235 23.888 22.3545 23.2363 22.999C22.5918 23.6507 21.6572 23.9766 20.4326 23.9766H9.38965ZM9.69043 21.2051H20.1211C20.5508 21.2051 20.8766 21.0941 21.0986 20.8721C21.3278 20.6572 21.4424 20.3206 21.4424 19.8623V9.45312C21.4424 8.99479 21.3278 8.6582 21.0986 8.44336C20.8766 8.22852 20.5508 8.12109 20.1211 8.12109H9.69043C9.25358 8.12109 8.92057 8.22852 8.69141 8.44336C8.4694 8.6582 8.3584 8.99479 8.3584 9.45312V19.8623C8.3584 20.3206 8.4694 20.6572 8.69141 20.8721C8.92057 21.0941 9.25358 21.2051 9.69043 21.2051Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -0,0 +1,3 @@
<svg width="23" height="21" viewBox="0 0 23 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.1826 20.7539C11.0036 20.7539 10.7887 20.6895 10.5381 20.5605C10.2874 20.4316 10.0404 20.2848 9.79688 20.1201C7.82747 18.8311 6.10514 17.474 4.62988 16.0488C3.16178 14.6165 2.02311 13.1449 1.21387 11.6338C0.404622 10.1156 0 8.59017 0 7.05762C0 5.99772 0.168294 5.03809 0.504883 4.17871C0.848633 3.31217 1.31771 2.56738 1.91211 1.94434C2.50651 1.32129 3.19043 0.841471 3.96387 0.504883C4.74447 0.168294 5.5752 0 6.45605 0C7.55176 0 8.49349 0.275716 9.28125 0.827148C10.0762 1.37142 10.71 2.0804 11.1826 2.9541C11.6553 2.0804 12.2855 1.37142 13.0732 0.827148C13.8682 0.275716 14.8135 0 15.9092 0C16.7829 0 17.61 0.168294 18.3906 0.504883C19.1712 0.841471 19.8551 1.32129 20.4424 1.94434C21.0368 2.56738 21.5023 3.31217 21.8389 4.17871C22.1826 5.03809 22.3545 5.99772 22.3545 7.05762C22.3545 8.59017 21.9499 10.1156 21.1406 11.6338C20.3314 13.1449 19.1927 14.6165 17.7246 16.0488C16.2565 17.474 14.5378 18.8311 12.5684 20.1201C12.3249 20.2848 12.0778 20.4316 11.8271 20.5605C11.5765 20.6895 11.3617 20.7539 11.1826 20.7539Z" fill="#DC2626"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.60938 21.9775C1.75 21.9775 1.09831 21.7484 0.654297 21.29C0.217448 20.8317 -0.000976562 20.137 -0.000976562 19.2061V11.2246C-0.000976562 10.3008 0.217448 9.61328 0.654297 9.16211C1.09831 8.70378 1.75 8.47461 2.60938 8.47461H13.083C13.9424 8.47461 14.5905 8.70378 15.0273 9.16211C15.4714 9.61328 15.6934 10.3008 15.6934 11.2246V19.2061C15.6934 20.137 15.4714 20.8317 15.0273 21.29C14.5905 21.7484 13.9424 21.9775 13.083 21.9775H2.60938ZM2.11523 9.50586V6.03613C2.11523 4.75423 2.37305 3.66569 2.88867 2.77051C3.41146 1.86816 4.10612 1.18066 4.97266 0.708008C5.83919 0.235352 6.79525 -0.000976562 7.84082 -0.000976562C8.89355 -0.000976562 9.85319 0.235352 10.7197 0.708008C11.5863 1.18066 12.2773 1.86816 12.793 2.77051C13.3158 3.66569 13.5771 4.75423 13.5771 6.03613V9.50586H11.0312V5.89648C11.0312 5.17318 10.888 4.55729 10.6016 4.04883C10.3151 3.5332 9.92839 3.13932 9.44141 2.86719C8.96159 2.59505 8.42806 2.45898 7.84082 2.45898C7.25358 2.45898 6.72005 2.59505 6.24023 2.86719C5.76042 3.13932 5.37728 3.5332 5.09082 4.04883C4.81152 4.55729 4.67188 5.17318 4.67188 5.89648V9.50586H2.11523Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,22 @@
<svg width="37" height="37" viewBox="0 0 37 37" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_16462_4504)">
<path d="M18.5051 14.1988H18.5C16.1245 14.1988 14.1987 16.1245 14.1987 18.5V18.5051C14.1987 20.8807 16.1245 22.8064 18.5 22.8064H18.5051C20.8806 22.8064 22.8064 20.8807 22.8064 18.5051V18.5C22.8064 16.1245 20.8806 14.1988 18.5051 14.1988Z" fill="#00FFB6"/>
<path d="M10.0619 14.5174C10.0619 11.9633 12.1329 9.89236 14.6869 9.89236H23.6183L33.5107 0H8.84917L0 8.84917V23.4076H10.0619V14.5122V14.5174Z" fill="url(#paint0_linear_16462_4504)"/>
<path d="M26.9381 13.5564V22.1435C26.9381 24.6975 24.8671 26.7685 22.3131 26.7685H13.726L3.48932 37.0051H28.1508L37 28.156V13.5615H26.9381V13.5564Z" fill="url(#paint1_linear_16462_4504)"/>
</g>
<defs>
<linearGradient id="paint0_linear_16462_4504" x1="0" y1="11.7038" x2="33.5107" y2="11.7038" gradientUnits="userSpaceOnUse">
<stop stop-color="#E2EDF8"/>
<stop offset="0.63" stop-color="white"/>
<stop offset="1" stop-color="#EAF1F9"/>
</linearGradient>
<linearGradient id="paint1_linear_16462_4504" x1="3.48932" y1="25.2808" x2="37" y2="25.2808" gradientUnits="userSpaceOnUse">
<stop stop-color="#E2EDF8"/>
<stop offset="0.63" stop-color="white"/>
<stop offset="1" stop-color="#EAF1F9"/>
</linearGradient>
<clipPath id="clip0_16462_4504">
<rect width="37" height="37" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg width="33" height="31" viewBox="0 0 33 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.63477 30.3789C5.04232 30.3789 4.65495 30.1966 4.47266 29.832C4.29948 29.4766 4.39518 29.0755 4.75977 28.6289C4.95117 28.3828 5.20182 28.0684 5.51172 27.6855C5.83073 27.3027 6.16341 26.8789 6.50977 26.4141C6.85612 25.9583 7.17513 25.4889 7.4668 25.0059C7.53971 24.86 7.55794 24.7324 7.52148 24.623C7.48503 24.5137 7.38477 24.4134 7.2207 24.3223C5.67122 23.4746 4.35872 22.472 3.2832 21.3145C2.20768 20.1569 1.39193 18.8991 0.835938 17.541C0.279948 16.1738 0.00195312 14.752 0.00195312 13.2754C0.00195312 11.4342 0.425781 9.71159 1.27344 8.10742C2.12109 6.49414 3.29232 5.08138 4.78711 3.86914C6.29102 2.6569 8.0319 1.70898 10.0098 1.02539C11.9967 0.341797 14.1296 0 16.4082 0C18.6868 0 20.8151 0.341797 22.793 1.02539C24.7799 1.70898 26.5254 2.6569 28.0293 3.86914C29.5332 5.08138 30.7044 6.49414 31.543 8.10742C32.3906 9.71159 32.8145 11.4342 32.8145 13.2754C32.8145 14.916 32.5182 16.4245 31.9258 17.8008C31.3424 19.1771 30.513 20.4076 29.4375 21.4922C28.362 22.5677 27.0859 23.4837 25.6094 24.2402C24.1419 24.9876 22.5195 25.5573 20.7422 25.9492C18.974 26.3503 17.1009 26.5508 15.123 26.5508C15.0684 26.5508 15.0046 26.5508 14.9316 26.5508C14.8678 26.5508 14.7949 26.5462 14.7129 26.5371C14.5762 26.5371 14.444 26.5553 14.3164 26.5918C14.1979 26.6283 14.0566 26.7012 13.8926 26.8105C13.2181 27.2845 12.4889 27.7357 11.7051 28.1641C10.9303 28.5924 10.1556 28.9707 9.38086 29.2988C8.61523 29.6361 7.9043 29.9004 7.24805 30.0918C6.5918 30.2832 6.05404 30.3789 5.63477 30.3789Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9531 23.9062C10.3203 23.9062 8.78125 23.5938 7.33594 22.9688C5.89844 22.3438 4.62891 21.4805 3.52734 20.3789C2.42578 19.2773 1.5625 18.0078 0.9375 16.5703C0.3125 15.125 0 13.5859 0 11.9531C0 10.3203 0.3125 8.78516 0.9375 7.34766C1.5625 5.90234 2.42188 4.62891 3.51562 3.52734C4.61719 2.42578 5.88672 1.5625 7.32422 0.9375C8.76953 0.3125 10.3086 0 11.9414 0C13.5742 0 15.1133 0.3125 16.5586 0.9375C18.0039 1.5625 19.2773 2.42578 20.3789 3.52734C21.4805 4.62891 22.3438 5.90234 22.9688 7.34766C23.5938 8.78516 23.9062 10.3203 23.9062 11.9531C23.9062 13.5859 23.5938 15.125 22.9688 16.5703C22.3438 18.0078 21.4805 19.2773 20.3789 20.3789C19.2773 21.4805 18.0039 22.3438 16.5586 22.9688C15.1211 23.5938 13.5859 23.9062 11.9531 23.9062ZM6.23438 11.9648C6.23438 12.2617 6.33203 12.5039 6.52734 12.6914C6.72266 12.8711 6.97266 12.9609 7.27734 12.9609H10.9453V16.6406C10.9453 16.9453 11.0352 17.1953 11.2148 17.3906C11.4023 17.5781 11.6445 17.6719 11.9414 17.6719C12.2461 17.6719 12.4922 17.5781 12.6797 17.3906C12.875 17.1953 12.9727 16.9453 12.9727 16.6406V12.9609H16.6523C16.9492 12.9609 17.1953 12.8711 17.3906 12.6914C17.5859 12.5039 17.6836 12.2617 17.6836 11.9648C17.6836 11.6602 17.5859 11.4141 17.3906 11.2266C17.2031 11.0312 16.957 10.9336 16.6523 10.9336H12.9727V7.26562C12.9727 6.96094 12.875 6.71094 12.6797 6.51562C12.4922 6.32031 12.2461 6.22266 11.9414 6.22266C11.6445 6.22266 11.4023 6.32031 11.2148 6.51562C11.0352 6.71094 10.9453 6.96094 10.9453 7.26562V10.9336H7.27734C6.97266 10.9336 6.72266 11.0312 6.52734 11.2266C6.33203 11.4141 6.23438 11.6602 6.23438 11.9648Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,3 @@
<svg width="35" height="31" viewBox="0 0 35 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.04297 27.3174C6.70508 27.3174 5.51367 27.2051 4.46875 26.9805C3.43359 26.7559 2.54004 26.2676 1.78809 25.5156C1.02637 24.7734 0.538086 23.8848 0.323242 22.8496C0.108398 21.8047 0.000976562 20.6133 0.000976562 19.2754V8.01074C0.000976562 6.68262 0.108398 5.50098 0.323242 4.46582C0.547852 3.43066 1.03613 2.54199 1.78809 1.7998C2.54004 1.03809 3.43359 0.549805 4.46875 0.334961C5.51367 0.110352 6.69531 -0.00195312 8.01367 -0.00195312H19.2783C20.6064 -0.00195312 21.793 0.110352 22.8379 0.334961C23.8828 0.549805 24.7861 1.03809 25.5479 1.7998C26.29 2.54199 26.7686 3.43555 26.9834 4.48047C27.208 5.51563 27.3203 6.70215 27.3203 8.04004V12.8594C26.002 12.8594 24.7617 13.1133 23.5996 13.6211C22.4473 14.1191 21.4268 14.8125 20.5381 15.7012C19.6494 16.5801 18.9561 17.6006 18.458 18.7627C17.96 19.915 17.7109 21.1553 17.7109 22.4834C17.7109 23.3623 17.8232 24.207 18.0479 25.0176C18.2822 25.8379 18.6094 26.6045 19.0293 27.3174H8.04297ZM27.335 30.0273C26.2998 30.0273 25.3281 29.832 24.4199 29.4414C23.5117 29.0508 22.7109 28.5039 22.0176 27.8008C21.3242 27.1074 20.7773 26.3066 20.377 25.3984C19.9863 24.4902 19.791 23.5186 19.791 22.4834C19.791 21.4482 19.9863 20.4766 20.377 19.5684C20.7773 18.6602 21.3242 17.8594 22.0176 17.166C22.7109 16.4629 23.5117 15.916 24.4199 15.5254C25.3281 15.1348 26.2998 14.9395 27.335 14.9395C28.3701 14.9395 29.3418 15.1348 30.25 15.5254C31.1582 15.916 31.959 16.458 32.6523 17.1514C33.3457 17.8447 33.8877 18.6504 34.2783 19.5684C34.6787 20.4766 34.8789 21.4482 34.8789 22.4834C34.8789 23.5088 34.6787 24.4756 34.2783 25.3838C33.8877 26.3018 33.3408 27.1074 32.6377 27.8008C31.9443 28.4941 31.1387 29.0361 30.2207 29.4268C29.3125 29.8271 28.3506 30.0273 27.335 30.0273ZM26.4854 26.5703C26.8662 26.5703 27.1494 26.4385 27.335 26.1748L31.583 20.3447C31.6611 20.2373 31.7148 20.1299 31.7441 20.0225C31.7832 19.915 31.8027 19.8174 31.8027 19.7295C31.8027 19.4365 31.6953 19.1924 31.4805 18.9971C31.2754 18.792 31.0312 18.6895 30.748 18.6895C30.3867 18.6895 30.0938 18.8457 29.8691 19.1582L26.4121 23.9336L24.7129 22.0732C24.625 21.9756 24.5127 21.8975 24.376 21.8389C24.249 21.7803 24.1025 21.751 23.9365 21.751C23.6533 21.751 23.4092 21.8486 23.2041 22.0439C22.999 22.2295 22.8965 22.4785 22.8965 22.791C22.8965 22.918 22.9209 23.0498 22.9697 23.1865C23.0186 23.3135 23.0869 23.4355 23.1748 23.5527L25.6504 26.2188C25.748 26.3359 25.875 26.4238 26.0312 26.4824C26.1875 26.541 26.3389 26.5703 26.4854 26.5703Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,3 @@
<svg width="26" height="33" viewBox="0 0 26 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.82812 32.0332C3.26953 32.0332 2.07552 31.623 1.24609 30.8027C0.416667 29.9824 0.00195312 28.793 0.00195312 27.2344V14.1094C0.00195312 12.5599 0.416667 11.375 1.24609 10.5547C2.07552 9.72526 3.26953 9.31055 4.82812 9.31055H8.5332V12.8379H5.21094C4.66406 12.8379 4.24479 12.9792 3.95312 13.2617C3.67057 13.5352 3.5293 13.9635 3.5293 14.5469V26.8105C3.5293 27.3848 3.67057 27.8086 3.95312 28.082C4.24479 28.3646 4.66406 28.5059 5.21094 28.5059H20.2773C20.8242 28.5059 21.2435 28.3646 21.5352 28.082C21.8268 27.8086 21.9727 27.3848 21.9727 26.8105V14.5469C21.9727 13.9635 21.8268 13.5352 21.5352 13.2617C21.2435 12.9792 20.8242 12.8379 20.2773 12.8379H16.9688V9.31055H20.6738C22.2324 9.31055 23.4264 9.72526 24.2559 10.5547C25.0853 11.375 25.5 12.5599 25.5 14.1094V27.2344C25.5 28.7839 25.0853 29.9688 24.2559 30.7891C23.4264 31.6185 22.2324 32.0332 20.6738 32.0332H4.82812ZM12.7441 20.8086C12.3066 20.8086 11.9329 20.6536 11.623 20.3438C11.3223 20.0339 11.1719 19.6647 11.1719 19.2363V5.93359L11.3086 3.92383L10.584 4.99023L8.94336 6.74023C8.66081 7.04102 8.31445 7.19141 7.9043 7.19141C7.53971 7.19141 7.2207 7.06836 6.94727 6.82227C6.67383 6.57617 6.53711 6.26172 6.53711 5.87891C6.53711 5.52344 6.67839 5.19987 6.96094 4.9082L11.5 0.560547C11.7096 0.350911 11.9147 0.205078 12.1152 0.123047C12.3249 0.0410156 12.5345 0 12.7441 0C12.9629 0 13.1725 0.0410156 13.373 0.123047C13.5827 0.205078 13.7923 0.350911 14.002 0.560547L18.541 4.9082C18.8236 5.19987 18.9648 5.52344 18.9648 5.87891C18.9648 6.26172 18.8236 6.57617 18.541 6.82227C18.2676 7.06836 17.9531 7.19141 17.5977 7.19141C17.1875 7.19141 16.8411 7.04102 16.5586 6.74023L14.9043 4.99023L14.1934 3.92383L14.3301 5.93359V19.2363C14.3301 19.6647 14.1751 20.0339 13.8652 20.3438C13.5645 20.6536 13.1908 20.8086 12.7441 20.8086Z" fill="#2563EB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.85547 23.3975C4.55469 23.1683 4.37207 22.8675 4.30762 22.4951C4.24316 22.1227 4.29329 21.6966 4.45801 21.2168L6.5957 14.8574L1.13867 10.9365C0.723307 10.6429 0.429688 10.3278 0.257812 9.99121C0.0859375 9.64746 0.0644531 9.29297 0.193359 8.92773C0.315104 8.56966 0.54069 8.30469 0.870117 8.13281C1.20671 7.95378 1.62565 7.86784 2.12695 7.875L8.83008 7.92871L10.8711 1.52637C11.0286 1.03223 11.2399 0.65625 11.5049 0.398438C11.777 0.133464 12.1029 0.000976562 12.4824 0.000976562C12.862 0.000976562 13.1842 0.133464 13.4492 0.398438C13.7214 0.65625 13.9362 1.03223 14.0938 1.52637L16.124 7.92871L22.8271 7.875C23.3356 7.86784 23.7546 7.95378 24.084 8.13281C24.4206 8.30469 24.6497 8.57324 24.7715 8.93848C24.8932 9.30371 24.8682 9.65462 24.6963 9.99121C24.5316 10.3278 24.2415 10.6429 23.8262 10.9365L18.3584 14.8574L20.5068 21.2168C20.6715 21.6966 20.7217 22.1227 20.6572 22.4951C20.5928 22.8675 20.4066 23.1683 20.0986 23.3975C19.7907 23.6338 19.4505 23.7197 19.0781 23.6553C18.7057 23.598 18.3118 23.4189 17.8965 23.1182L12.4824 19.1328L7.05762 23.1182C6.64941 23.4189 6.25553 23.598 5.87598 23.6553C5.50358 23.7197 5.16341 23.6338 4.85547 23.3975Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="33" height="31" viewBox="0 0 33 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.67383 30.5293C6.00846 30.5293 5.47982 30.3516 5.08789 29.9961C4.69596 29.6497 4.47721 29.2168 4.43164 28.6973C4.38607 28.1777 4.54557 27.6719 4.91016 27.1797C5.10156 26.9245 5.31576 26.6146 5.55273 26.25C5.79883 25.8854 6.03581 25.5117 6.26367 25.1289C6.50065 24.737 6.71029 24.3815 6.89258 24.0625C5.48893 23.3151 4.27214 22.3854 3.24219 21.2734C2.21224 20.1615 1.41471 18.9264 0.849609 17.5684C0.284505 16.2012 0.00195312 14.7702 0.00195312 13.2754C0.00195312 11.4342 0.425781 9.71159 1.27344 8.10742C2.12109 6.49414 3.29232 5.08138 4.78711 3.86914C6.29102 2.6569 8.0319 1.70898 10.0098 1.02539C11.9967 0.341797 14.1296 0 16.4082 0C18.6868 0 20.8151 0.341797 22.793 1.02539C24.7799 1.70898 26.5254 2.6569 28.0293 3.86914C29.5332 5.08138 30.7044 6.49414 31.543 8.10742C32.3906 9.71159 32.8145 11.4342 32.8145 13.2754C32.8145 14.7793 32.5365 16.2012 31.9805 17.541C31.4245 18.8809 30.6361 20.1022 29.6152 21.2051C28.5944 22.2988 27.373 23.2467 25.9512 24.0488C24.5384 24.8418 22.9661 25.4525 21.2344 25.8809C19.5026 26.3092 17.6478 26.5189 15.6699 26.5098C14.6491 27.248 13.569 27.918 12.4297 28.5195C11.2904 29.1302 10.2103 29.6178 9.18945 29.9824C8.16862 30.347 7.33008 30.5293 6.67383 30.5293ZM8.65625 26.8789C9.03906 26.7148 9.54036 26.446 10.1602 26.0723C10.7891 25.7077 11.4362 25.3021 12.1016 24.8555C12.7669 24.3997 13.3639 23.9714 13.8926 23.5703C14.1842 23.3333 14.4531 23.1693 14.6992 23.0781C14.9453 22.987 15.2188 22.9414 15.5195 22.9414C15.7018 22.9414 15.8659 22.946 16.0117 22.9551C16.1667 22.9551 16.2988 22.9551 16.4082 22.9551C18.1855 22.9551 19.849 22.7044 21.3984 22.2031C22.9479 21.6927 24.306 20.9954 25.4727 20.1113C26.6484 19.2272 27.5645 18.2018 28.2207 17.0352C28.8861 15.8594 29.2188 14.6061 29.2188 13.2754C29.2188 11.9355 28.8861 10.6823 28.2207 9.51562C27.5645 8.34896 26.6484 7.32357 25.4727 6.43945C24.306 5.54622 22.9479 4.84896 21.3984 4.34766C19.849 3.83724 18.1855 3.58203 16.4082 3.58203C14.6309 3.58203 12.9674 3.83724 11.418 4.34766C9.86849 4.84896 8.50586 5.54622 7.33008 6.43945C6.16341 7.32357 5.2474 8.34896 4.58203 9.51562C3.92578 10.6823 3.59766 11.9355 3.59766 13.2754C3.59766 14.4147 3.84831 15.5039 4.34961 16.543C4.85091 17.5729 5.57096 18.5208 6.50977 19.3867C7.45768 20.2435 8.59701 20.9863 9.92773 21.6152C10.3652 21.834 10.6387 22.1165 10.748 22.4629C10.8665 22.8001 10.8118 23.1829 10.584 23.6113C10.3014 24.1217 9.95052 24.6595 9.53125 25.2246C9.11198 25.7897 8.75651 26.2546 8.46484 26.6191C8.3737 26.7376 8.34635 26.8197 8.38281 26.8652C8.42839 26.9199 8.51953 26.9245 8.65625 26.8789Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
app/src/images/majong.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
app/src/images/referral.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -49,12 +49,12 @@ const accountScreens = {
CloudBackupSettings: {
screen: CloudBackupScreen,
options: {
title: 'Cloud backup',
title: 'Account Backup',
headerStyle: {
backgroundColor: black,
backgroundColor: white,
},
headerTitleStyle: {
color: slate300,
color: black,
},
} as NativeStackNavigationOptions,
},

View File

@@ -9,6 +9,7 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac
import type { DocumentCategory } from '@selfxyz/common/utils/types';
import DeferredLinkingInfoScreen from '@/screens/app/DeferredLinkingInfoScreen';
import GratificationScreen from '@/screens/app/GratificationScreen';
import LaunchScreen from '@/screens/app/LaunchScreen';
import LoadingScreen from '@/screens/app/LoadingScreen';
import type { ModalNavigationParams } from '@/screens/app/ModalScreen';
@@ -56,6 +57,16 @@ const appScreens = {
header: () => <SystemBars style="light" />,
},
},
Gratification: {
screen: GratificationScreen,
options: {
headerShown: false,
contentStyle: { backgroundColor: '#000000' },
} as NativeStackNavigationOptions,
params: {} as {
points?: number;
},
},
};
export default appScreens;

View File

@@ -5,7 +5,11 @@
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import { HomeNavBar } from '@/components/NavBar';
import PointsScreen from '@/components/NavBar/Points';
import { PointsNavBar } from '@/components/NavBar/PointsNavBar';
import ReferralScreen from '@/screens/app/ReferralScreen';
import HomeScreen from '@/screens/home/HomeScreen';
import PointsInfoScreen from '@/screens/home/PointsInfoScreen';
import ProofHistoryDetailScreen from '@/screens/home/ProofHistoryDetailScreen';
import ProofHistoryScreen from '@/screens/home/ProofHistoryScreen';
@@ -18,6 +22,20 @@ const homeScreens = {
presentation: 'card',
} as NativeStackNavigationOptions,
},
Points: {
screen: PointsScreen,
options: {
title: 'Self Points',
header: PointsNavBar,
presentation: 'card',
} as NativeStackNavigationOptions,
},
Referral: {
screen: ReferralScreen,
options: {
headerShown: false,
} as NativeStackNavigationOptions,
},
ProofHistory: {
screen: ProofHistoryScreen,
options: {
@@ -31,6 +49,14 @@ const homeScreens = {
title: 'Approval',
},
},
PointsInfo: {
screen: PointsInfoScreen,
options: {
headerBackTitle: 'close',
title: 'Self Points',
animation: 'slide_from_bottom',
} as NativeStackNavigationOptions,
},
};
export default homeScreens;

View File

@@ -58,22 +58,31 @@ type BaseRootStackParamList = StaticParamList<typeof AppNavigation>;
// Explicitly declare route params that are not inferred from initialParams
export type RootStackParamList = Omit<
BaseRootStackParamList,
| 'ComingSoon'
| 'IDPicker'
| 'AadhaarUpload'
| 'AadhaarUploadError'
| 'WebView'
| 'AadhaarUploadSuccess'
| 'AccountRecovery'
| 'SaveRecoveryPhrase'
| 'AccountVerifiedSuccess'
| 'CloudBackupSettings'
| 'ComingSoon'
| 'ConfirmBelonging'
| 'ProofHistoryDetail'
| 'CreateMock'
| 'Disclaimer'
| 'DocumentNFCScan'
| 'DocumentOnboarding'
| 'Gratification'
| 'Home'
| 'IDPicker'
| 'IdDetails'
| 'Loading'
| 'Modal'
| 'CreateMock'
| 'MockDataDeepLink'
| 'DocumentNFCScan'
| 'AadhaarUploadSuccess'
| 'Points'
| 'PointsInfo'
| 'ProofHistoryDetail'
| 'Prove'
| 'SaveRecoveryPhrase'
| 'WebView'
> & {
// Shared screens
ComingSoon: {
@@ -102,6 +111,7 @@ export type RootStackParamList = Omit<
}
| undefined;
DocumentCameraTrouble: undefined;
DocumentOnboarding: undefined;
// Aadhaar screens
AadhaarUpload: {
@@ -125,14 +135,17 @@ export type RootStackParamList = Omit<
| undefined;
CloudBackupSettings:
| {
nextScreen?: string;
nextScreen?: 'SaveRecoveryPhrase';
returnToScreen?: 'Points';
}
| undefined;
AccountVerifiedSuccess: undefined;
// Proof/Verification screens
ProofHistoryDetail: {
data: ProofHistory;
};
Prove: undefined;
// App screens
Loading: {
@@ -141,6 +154,25 @@ export type RootStackParamList = Omit<
curveOrExponent?: string;
};
Modal: ModalNavigationParams;
Gratification: {
points?: number;
};
// Home screens
Home: {
testReferralFlow?: boolean;
};
Points: undefined;
PointsInfo:
| {
showNextButton?: boolean;
onNextButtonPress?: () => void;
}
| undefined;
IdDetails: undefined;
// Onboarding screens
Disclaimer: undefined;
// Dev screens
CreateMock: undefined;

View File

@@ -137,6 +137,7 @@ async function restoreFromMnemonic(
...options.setOptions,
service: SERVICE_NAME,
});
generateAndStorePointsAddress(mnemonic);
trackEvent(AuthEvents.MNEMONIC_RESTORE_SUCCESS);
return data;
} catch (error: unknown) {
@@ -278,7 +279,7 @@ export const AuthProvider = ({
keychainOptions => loadOrCreateMnemonic(keychainOptions),
str => JSON.parse(str),
{
requireAuth: false,
requireAuth: true,
},
),
[],
@@ -328,15 +329,25 @@ function _generateAddressFromMnemonic(mnemonic: string, index: number): string {
return wallet.address;
}
export async function generateAndStorePointsAddress(
mnemonic: string,
): Promise<string> {
const pointsAddr = _generateAddressFromMnemonic(mnemonic, 1);
useSettingStore.getState().setPointsAddress(pointsAddr);
return pointsAddr;
}
/**
* Gets the second address if it exists, or generates and stores it if not.
* By Generate, it means we need the user's biometric auth.
*
* Flow is, when the user visits the points screen for the first time, we need to generate the points address.
*/
export async function getOrGeneratePointsAddress(): Promise<string> {
export async function getOrGeneratePointsAddress(
forceGenerateFromMnemonic: boolean = false,
): Promise<string> {
const pointsAddress = useSettingStore.getState().pointsAddress;
if (pointsAddress) {
if (pointsAddress && !forceGenerateFromMnemonic) {
return pointsAddress;
}
@@ -417,6 +428,32 @@ export async function unsafe_clearSecrets() {
}
}
/**
* Retrieves the private key for the points address (derived at index 1) using the
* same biometric protections and keychain options as other secret accessors.
*/
export async function unsafe_getPointsPrivateKey(
keychainOptions?: KeychainOptions,
) {
const options =
keychainOptions ||
(await createKeychainOptions({
requireAuth: true,
}));
const foundMnemonic = await loadOrCreateMnemonic(options);
if (!foundMnemonic) {
return null;
}
const mnemonic = JSON.parse(foundMnemonic) as Mnemonic;
const wallet = ethers.HDNodeWallet.fromPhrase(
mnemonic.phrase,
undefined,
"m/44'/60'/0'/0/1",
);
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

View File

@@ -146,7 +146,10 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
addListener(SdkEvents.PROVING_ACCOUNT_VERIFIED_SUCCESS, () => {
setTimeout(() => {
if (navigationRef.isReady()) {
navigationRef.navigate('AccountVerifiedSuccess');
navigationRef.navigate({
name: 'AccountVerifiedSuccess',
params: undefined,
});
}
}, 1000);
});
@@ -157,9 +160,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
setTimeout(() => {
if (navigationRef.isReady()) {
if (hasValidDocument) {
navigationRef.navigate('Home');
navigationRef.navigate({ name: 'Home', params: {} });
} else {
navigationRef.navigate('Launch');
navigationRef.navigate({ name: 'Launch', params: undefined });
}
}
}, 3000);

View File

@@ -32,97 +32,153 @@ import {
reStorePassportDataWithRightCSCA,
} from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
import type { Mnemonic } from '@/types/mnemonic';
import { STORAGE_NAME, useBackupMnemonic } from '@/utils/cloudBackup';
import { black, slate500, slate600, white } from '@/utils/colors';
// DISABLED FOR NOW: Turnkey functionality
// import { AuthState, useTurnkey } from '@turnkey/react-native-wallet-kit';
// import { useTurnkeyUtils } from '@/utils/turnkey';
const AccountRecoveryChoiceScreen: React.FC = () => {
const selfClient = useSelfClient();
const { useProtocolStore } = selfClient;
const { trackEvent } = useSelfClient();
const { restoreAccountFromMnemonic } = useAuth();
// DISABLED FOR NOW: Turnkey functionality
// const { turnkeyWallets, refreshWallets } = useTurnkeyUtils();
// const { getMnemonic } = useTurnkeyUtils();
// const { authState } = useTurnkey();
const [restoring, setRestoring] = useState(false);
const { cloudBackupEnabled, toggleCloudBackupEnabled, biometricsAvailable } =
useSettingStore();
const { download } = useBackupMnemonic();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
// DISABLED FOR NOW: Turnkey functionality
// const setTurnkeyBackupEnabled = useSettingStore(
// state => state.setTurnkeyBackupEnabled,
// );
const onRestoreFromCloudNext = useHapticNavigation('AccountVerifiedSuccess');
const onEnterRecoveryPress = useHapticNavigation('RecoverWithPhrase');
// DISABLED FOR NOW: Turnkey functionality
// useEffect(() => {
// refreshWallets();
// }, [refreshWallets]);
const restoreAccountFlow = useCallback(
async (
mnemonic: Mnemonic,
isCloudRestore: boolean = false,
): Promise<boolean> => {
try {
const result = await restoreAccountFromMnemonic(mnemonic.phrase);
if (!result) {
console.warn('Failed to restore account');
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
navigation.navigate('Launch');
setRestoring(false);
return false;
}
const passportDataAndSecret =
(await loadPassportDataAndSecret()) as string;
const { passportData, secret } = JSON.parse(passportDataAndSecret);
const { isRegistered, csca } =
await isUserRegisteredWithAlternativeCSCA(passportData, secret, {
getCommitmentTree(docCategory) {
return useProtocolStore.getState()[docCategory].commitment_tree;
},
getAltCSCA(docCategory) {
if (docCategory === 'aadhaar') {
const publicKeys =
useProtocolStore.getState().aadhaar.public_keys;
// Convert string[] to Record<string, string> format expected by AlternativeCSCA
return publicKeys
? Object.fromEntries(publicKeys.map(key => [key, key]))
: {};
}
return useProtocolStore.getState()[docCategory].alternative_csca;
},
});
if (!isRegistered) {
console.warn(
'Secret provided did not match a registered ID. Please try again.',
);
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED);
navigation.navigate('Launch');
setRestoring(false);
return false;
}
if (isCloudRestore && !cloudBackupEnabled) {
toggleCloudBackupEnabled();
}
reStorePassportDataWithRightCSCA(passportData, csca as string);
await markCurrentDocumentAsRegistered(selfClient);
trackEvent(BackupEvents.CLOUD_RESTORE_SUCCESS);
trackEvent(BackupEvents.ACCOUNT_RECOVERY_COMPLETED);
onRestoreFromCloudNext();
setRestoring(false);
return true;
} catch (e: unknown) {
console.error(e);
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
setRestoring(false);
return false;
}
},
[
trackEvent,
restoreAccountFromMnemonic,
cloudBackupEnabled,
onRestoreFromCloudNext,
navigation,
toggleCloudBackupEnabled,
useProtocolStore,
selfClient,
],
);
// DISABLED FOR NOW: Turnkey functionality
// const onRestoreFromTurnkeyPress = useCallback(async () => {
// setRestoring(true);
// try {
// const mnemonicPhrase = await getMnemonic();
// const mnemonic: Mnemonic = {
// phrase: mnemonicPhrase,
// password: '',
// wordlist: {
// locale: 'en',
// },
// entropy: '',
// };
// const success = await restoreAccountFlow(mnemonic);
// if (success) {
// setTurnkeyBackupEnabled(true);
// }
// } catch (error) {
// console.error('Turnkey restore error:', error);
// trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
// } finally {
// setRestoring(false);
// }
// }, [getMnemonic, restoreAccountFlow, setTurnkeyBackupEnabled, trackEvent]);
const onRestoreFromCloudPress = useCallback(async () => {
setRestoring(true);
try {
const mnemonic = await download();
const result = await restoreAccountFromMnemonic(mnemonic.phrase);
if (!result) {
console.warn('Failed to restore account');
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
navigation.navigate('Launch');
setRestoring(false);
return;
}
const passportDataAndSecret =
(await loadPassportDataAndSecret()) as string;
const { passportData, secret } = JSON.parse(passportDataAndSecret);
const { isRegistered, csca } = await isUserRegisteredWithAlternativeCSCA(
passportData,
secret,
{
getCommitmentTree(docCategory) {
return useProtocolStore.getState()[docCategory].commitment_tree;
},
getAltCSCA(docCategory) {
if (docCategory === 'aadhaar') {
const publicKeys =
useProtocolStore.getState().aadhaar.public_keys;
// Convert string[] to Record<string, string> format expected by AlternativeCSCA
return publicKeys
? Object.fromEntries(publicKeys.map(key => [key, key]))
: {};
}
return useProtocolStore.getState()[docCategory].alternative_csca;
},
},
);
if (!isRegistered) {
console.warn(
'Secret provided did not match a registered ID. Please try again.',
);
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED);
navigation.navigate('Launch');
setRestoring(false);
return;
}
if (!cloudBackupEnabled) {
toggleCloudBackupEnabled();
}
reStorePassportDataWithRightCSCA(passportData, csca as string);
await markCurrentDocumentAsRegistered(selfClient);
trackEvent(BackupEvents.CLOUD_RESTORE_SUCCESS);
trackEvent(BackupEvents.ACCOUNT_RECOVERY_COMPLETED);
onRestoreFromCloudNext();
setRestoring(false);
} catch (e: unknown) {
console.error(e);
await restoreAccountFlow(mnemonic, true);
} catch (error) {
console.error('Cloud restore error:', error);
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
setRestoring(false);
throw new Error('Something wrong happened during cloud recovery');
}
}, [
trackEvent,
download,
restoreAccountFromMnemonic,
cloudBackupEnabled,
onRestoreFromCloudNext,
navigation,
toggleCloudBackupEnabled,
useProtocolStore,
selfClient,
]);
}, [download, restoreAccountFlow, trackEvent]);
const handleManualRecoveryPress = useCallback(() => {
onEnterRecoveryPress();
@@ -146,7 +202,7 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
<Description>
By continuing, you certify that this passport belongs to you and is
not stolen or forged.{' '}
{biometricsAvailable && (
{!biometricsAvailable && (
<>
Your device doesn't support biometrics or is disabled for apps
and is required for cloud storage.
@@ -155,9 +211,25 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
</Description>
<YStack gap="$2.5" width="100%" paddingTop="$6">
{/* DISABLED FOR NOW: Turnkey functionality */}
{/* <PrimaryButton
trackEvent={BackupEvents.CLOUD_BACKUP_STARTED}
onPress={onRestoreFromTurnkeyPress}
testID="button-from-turnkey"
disabled={
restoring ||
!biometricsAvailable ||
(authState === AuthState.Authenticated &&
turnkeyWallets.length === 0)
}
>
{restoring ? 'Restoring' : 'Restore'} from Turnkey
{restoring ? '' : ''}
</PrimaryButton> */}
<PrimaryButton
trackEvent={BackupEvents.CLOUD_BACKUP_STARTED}
onPress={onRestoreFromCloudPress}
testID="button-from-teststorage"
disabled={restoring || !biometricsAvailable}
>
{restoring ? 'Restoring' : 'Restore'} from {STORAGE_NAME}

View File

@@ -3,52 +3,73 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { YStack } from 'tamagui';
import type { StaticScreenProps } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
Caption,
Description,
hasAnyValidRegisteredDocument,
useSelfClient,
} from '@selfxyz/mobile-sdk-alpha';
import {
PrimaryButton,
SecondaryButton,
Title,
} from '@selfxyz/mobile-sdk-alpha/components';
import { BackupEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import BackupDocumentationLink from '@/components/BackupDocumentationLink';
import { useModal } from '@/hooks/useModal';
import Cloud from '@/images/icons/logo_cloud_backup.svg';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import CloudIcon from '@/images/icons/settings_cloud_backup.svg';
import type { RootStackParamList } from '@/navigation';
import { useAuth } from '@/providers/authProvider';
import { useSettingStore } from '@/stores/settingStore';
import { STORAGE_NAME, useBackupMnemonic } from '@/utils/cloudBackup';
import { black, white } from '@/utils/colors';
import { black, blue600, slate200, slate500, white } from '@/utils/colors';
import { advercase, dinot } from '@/utils/fonts';
import { buttonTap, confirmTap } from '@/utils/haptic';
// DISABLED FOR NOW: Turnkey functionality
// import { Wallet } from '@tamagui/lucide-icons';
// import { useTurnkeyUtils } from '@/utils/turnkey';
type NextScreen = keyof Pick<RootStackParamList, 'SaveRecoveryPhrase'>;
type CloudBackupScreenProps = StaticScreenProps<
| {
nextScreen?: NextScreen;
returnToScreen?: 'Points';
}
| undefined
>;
type BackupMethod = 'icloud' | 'turnkey' | null;
const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
route: { params },
}) => {
const { trackEvent } = useSelfClient();
// DISABLED FOR NOW: Turnkey functionality
// const { backupAccount } = useTurnkeyUtils();
const { getOrCreateMnemonic, loginWithBiometrics } = useAuth();
const { cloudBackupEnabled, toggleCloudBackupEnabled, biometricsAvailable } =
useSettingStore();
const {
cloudBackupEnabled,
toggleCloudBackupEnabled,
biometricsAvailable,
// DISABLED FOR NOW: Turnkey functionality
// turnkeyBackupEnabled,
} = useSettingStore();
const { upload, disableBackup } = useBackupMnemonic();
const [pending, setPending] = useState(false);
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { showModal } = useModal(
const [_selectedMethod, setSelectedMethod] = useState<BackupMethod>(null);
const [iCloudPending, setICloudPending] = useState(false);
const selfClient = useSelfClient();
// DISABLED FOR NOW: Turnkey functionality
// const [turnkeyPending, setTurnkeyPending] = useState(false);
const { showModal: showDisableModal } = useModal(
useMemo(
() => ({
titleText: 'Disable cloud backups',
@@ -63,11 +84,11 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
toggleCloudBackupEnabled();
trackEvent(BackupEvents.CLOUD_BACKUP_DISABLED_DONE);
} finally {
setPending(false);
setICloudPending(false);
}
},
onModalDismiss: () => {
setPending(false);
setICloudPending(false);
},
}),
[
@@ -79,108 +100,255 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
),
);
const enableCloudBackups = useCallback(async () => {
const { showModal: showNoRegisteredAccountModal } = useModal(
useMemo(
() => ({
titleText: 'No registered account',
bodyText: 'You need to register an account to enable cloud backups.',
buttonText: 'Register now',
secondaryButtonText: 'Cancel',
onButtonPress: () => {
// setTimeout to ensure modal closes before navigation to prevent navigation conflicts when the modal tries to goBack()
setTimeout(() => {
navigation.navigate('CountryPicker');
}, 100);
},
onModalDismiss: () => {},
}),
[navigation],
),
);
// DISABLED FOR NOW: Turnkey functionality
// const { showModal: showAlreadySignedInModal } = useModal({
// titleText: 'Cannot use this email',
// bodyText:
// 'You cannot use this email. Please try again with a different email address.',
// buttonText: 'OK',
// onButtonPress: () => {},
// onModalDismiss: () => {},
// });
// const { showModal: showAlreadyBackedUpModal } = useModal({
// titleText: 'Already backed up with Turnkey',
// bodyText: 'You have already backed up your account with Turnkey.',
// buttonText: 'OK',
// onButtonPress: () => {},
// onModalDismiss: () => {},
// });
const handleICloudBackup = useCallback(async () => {
buttonTap();
if (cloudBackupEnabled) {
setSelectedMethod('icloud');
const hasAnyValidRegisteredDocumentResult =
await hasAnyValidRegisteredDocument(selfClient);
if (!hasAnyValidRegisteredDocumentResult) {
showNoRegisteredAccountModal();
return;
}
if (cloudBackupEnabled || !biometricsAvailable) {
return;
}
trackEvent(BackupEvents.CLOUD_BACKUP_ENABLE_STARTED);
setICloudPending(true);
setPending(true);
try {
const storedMnemonic = await getOrCreateMnemonic();
if (!storedMnemonic) {
setICloudPending(false);
return;
}
await upload(storedMnemonic.data);
toggleCloudBackupEnabled();
trackEvent(BackupEvents.CLOUD_BACKUP_ENABLED_DONE);
const storedMnemonic = await getOrCreateMnemonic();
if (!storedMnemonic) {
setPending(false);
return;
if (params?.returnToScreen) {
navigation.navigate(params.returnToScreen);
}
} catch (error) {
console.error('iCloud backup error', error);
} finally {
setICloudPending(false);
}
await upload(storedMnemonic.data);
toggleCloudBackupEnabled();
trackEvent(BackupEvents.CLOUD_BACKUP_ENABLED_DONE);
setPending(false);
}, [
cloudBackupEnabled,
biometricsAvailable,
getOrCreateMnemonic,
upload,
toggleCloudBackupEnabled,
trackEvent,
navigation,
params,
selfClient,
showNoRegisteredAccountModal,
]);
const disableCloudBackups = useCallback(() => {
confirmTap();
setPending(true);
showModal();
}, [showModal]);
setICloudPending(true);
showDisableModal();
}, [showDisableModal]);
// DISABLED FOR NOW: Turnkey functionality
// const handleTurnkeyBackup = useCallback(async () => {
// buttonTap();
// setSelectedMethod('turnkey');
// if (turnkeyBackupEnabled) {
// return;
// }
// setTurnkeyPending(true);
// try {
// const mnemonics = await getOrCreateMnemonic();
// if (!mnemonics?.data.phrase) {
// console.error('No mnemonic found');
// setTurnkeyPending(false);
// return;
// }
// await backupAccount(mnemonics.data.phrase);
// setTurnkeyPending(false);
// if (params?.returnToScreen) {
// navigation.navigate(params.returnToScreen);
// }
// } catch (error) {
// if (error instanceof Error && error.message === 'already_exists') {
// console.log('Already signed in with Turnkey');
// showAlreadySignedInModal();
// } else if (
// error instanceof Error &&
// error.message === 'already_backed_up'
// ) {
// console.log('Already backed up with Turnkey');
// if (params?.returnToScreen) {
// navigation.navigate(params.returnToScreen);
// } else if (params?.nextScreen) {
// navigation.navigate(params.nextScreen);
// } else {
// showAlreadyBackedUpModal();
// }
// } else {
// console.error('Turnkey backup error', error);
// }
// setTurnkeyPending(false);
// }
// }, [
// turnkeyBackupEnabled,
// backupAccount,
// getOrCreateMnemonic,
// showAlreadySignedInModal,
// showAlreadyBackedUpModal,
// navigation,
// params,
// ]);
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
<ExpandableBottomLayout.TopSection backgroundColor={black}>
<Cloud height={200} width={140} color={white} />
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection
flexGrow={1}
backgroundColor={white}
<YStack flex={1} backgroundColor={white}>
<YStack
flex={1}
alignItems="center"
justifyContent="center"
paddingHorizontal={20}
paddingBottom={20}
>
<YStack alignItems="center" gap="$2.5" paddingBottom="$2.5">
<Title>
{cloudBackupEnabled
? `${STORAGE_NAME} is enabled`
: `Enable ${STORAGE_NAME}`}
</Title>
<Description>
{cloudBackupEnabled
? `Your account is being end-to-end encrypted backed up to ${STORAGE_NAME} so you can easily restore it if you ever get a new phone.`
: `Your account will be end-to-end encrypted backed up to ${STORAGE_NAME} so you can easily restore it if you ever get a new phone.`}
</Description>
<Caption>
{biometricsAvailable ? (
<>
Learn more about <BackupDocumentationLink />
</>
) : (
<>
Your device doesn't support biometrics or is disabled for apps
and is required for cloud storage.
</>
)}
</Caption>
<View style={styles.content}>
<View style={styles.iconContainer}>
<CloudIcon width={56} height={56} color={white} />
</View>
<YStack gap="$2.5" width="100%" paddingTop="$6">
<View style={styles.descriptionContainer}>
<Text style={styles.title}>Protect your account</Text>
<Text style={styles.description}>
Back up your account so you can restore your data if you lose your
device or get a new one.
</Text>
</View>
<View style={styles.optionsContainer}>
{cloudBackupEnabled ? (
<SecondaryButton
onPress={disableCloudBackups}
disabled={pending || !biometricsAvailable}
disabled={iCloudPending || !biometricsAvailable}
trackEvent={BackupEvents.CLOUD_BACKUP_DISABLE_STARTED}
>
{pending ? 'Disabling' : 'Disable'} {STORAGE_NAME} backups
{pending ? '' : ''}
{iCloudPending ? 'Disabling' : 'Disable'} {STORAGE_NAME} backups
{iCloudPending ? '…' : ''}
</SecondaryButton>
) : (
<PrimaryButton
onPress={enableCloudBackups}
disabled={pending || !biometricsAvailable}
trackEvent={BackupEvents.CLOUD_BACKUP_ENABLE_STARTED}
<Pressable
style={[
styles.optionButton,
(iCloudPending || !biometricsAvailable) &&
styles.optionButtonDisabled,
]}
onPress={handleICloudBackup}
disabled={iCloudPending || !biometricsAvailable}
>
{pending ? 'Enabling' : 'Enable'} {STORAGE_NAME} backups
{pending ? '' : ''}
</PrimaryButton>
<CloudIcon width={24} height={24} color={black} />
<Text style={styles.optionText}>
{iCloudPending ? 'Enabling' : 'Backup with'} {STORAGE_NAME}
{iCloudPending ? '…' : ''}
</Text>
</Pressable>
)}
{/* DISABLED FOR NOW: Turnkey functionality */}
{/* {turnkeyBackupEnabled ? (
<SecondaryButton
disabled
trackEvent={BackupEvents.CLOUD_BACKUP_DISABLE_STARTED}
>
Backed up with Turnkey
</SecondaryButton>
) : (
<Pressable
style={[
styles.optionButton,
turnkeyPending && styles.optionButtonDisabled,
]}
onPress={handleTurnkeyBackup}
disabled={turnkeyPending}
>
<Wallet size={24} color={black} />
<Text style={styles.optionText}>
{turnkeyPending ? 'Importing' : 'Backup with'} Turnkey
{turnkeyPending ? '…' : ''}
</Text>
</Pressable>
)} */}
<BottomButton
cloudBackupEnabled={cloudBackupEnabled}
turnkeyBackupEnabled={false}
nextScreen={params?.nextScreen}
/>
</YStack>
</YStack>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
</View>
{!biometricsAvailable && (
<Text style={styles.warningText}>
Your device doesn't support biometrics or is disabled for apps and
is required for cloud storage.
</Text>
)}
</View>
</YStack>
</YStack>
);
};
function BottomButton({
cloudBackupEnabled,
turnkeyBackupEnabled,
nextScreen,
}: {
cloudBackupEnabled: boolean;
turnkeyBackupEnabled: boolean;
nextScreen?: NextScreen;
}) {
const { trackEvent } = useSelfClient();
@@ -193,7 +361,9 @@ function BottomButton({
navigation.goBack();
};
if (nextScreen && cloudBackupEnabled) {
const hasBackup = cloudBackupEnabled || turnkeyBackupEnabled;
if (nextScreen && hasBackup) {
return (
<PrimaryButton
onPress={() => {
@@ -205,7 +375,7 @@ function BottomButton({
Continue
</PrimaryButton>
);
} else if (nextScreen && !cloudBackupEnabled) {
} else if (nextScreen && !hasBackup) {
return (
<SecondaryButton
onPress={() => {
@@ -217,7 +387,7 @@ function BottomButton({
Back up manually
</SecondaryButton>
);
} else if (cloudBackupEnabled) {
} else if (hasBackup) {
return (
<PrimaryButton
onPress={goBack}
@@ -238,4 +408,74 @@ function BottomButton({
}
}
const styles = StyleSheet.create({
content: {
width: '100%',
alignItems: 'center',
gap: 30,
},
iconContainer: {
width: 120,
height: 120,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: blue600,
},
descriptionContainer: {
width: '100%',
gap: 12,
alignItems: 'center',
},
title: {
width: '100%',
fontSize: 28,
letterSpacing: 1,
fontFamily: advercase,
color: black,
textAlign: 'center',
},
description: {
width: '100%',
fontSize: 18,
fontWeight: '500',
fontFamily: dinot,
color: black,
textAlign: 'center',
},
optionsContainer: {
width: '100%',
gap: 10,
},
optionButton: {
backgroundColor: white,
borderWidth: 1,
borderColor: slate200,
borderRadius: 5,
paddingVertical: 20,
paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
optionButtonDisabled: {
opacity: 0.5,
},
optionText: {
fontFamily: dinot,
fontWeight: '500',
fontSize: 18,
color: black,
},
warningText: {
fontFamily: dinot,
fontWeight: '500',
fontSize: 14,
color: slate500,
textAlign: 'center',
marginTop: 10,
},
});
export default CloudBackupScreen;

View File

@@ -0,0 +1,265 @@
// 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, useState } from 'react';
import {
Dimensions,
Pressable,
StyleSheet,
Text as RNText,
} from 'react-native';
import { SystemBars } from 'react-native-edge-to-edge';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, View, YStack } from 'tamagui';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { X } from '@tamagui/lucide-icons';
import { DelayedLottieView } from '@selfxyz/mobile-sdk-alpha';
import youWinAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/youWin.json';
import { PrimaryButton } from '@selfxyz/mobile-sdk-alpha/components';
import GratificationBg from '@/images/gratification_bg.svg';
import LogoWhite from '@/images/icons/logo_white.svg';
import type { RootStackParamList } from '@/navigation';
import { black, slate700, white } from '@/utils/colors';
import { dinot, dinotBold } from '@/utils/fonts';
const GratificationScreen: React.FC = () => {
const { top, bottom } = useSafeAreaInsets();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute();
const params = route.params as { points?: number } | undefined;
const pointsEarned = params?.points ?? 0;
const [isAnimationFinished, setIsAnimationFinished] = useState(false);
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
const handleExploreRewards = () => {
// Navigate to Points screen
navigation.navigate('Points' as never);
};
const handleInviteFriend = () => {
navigation.navigate('Referral' as never);
};
const handleBackPress = () => {
navigation.navigate('Points' as never);
};
const handleAnimationFinish = useCallback(() => {
setIsAnimationFinished(true);
}, []);
// Show animation first, then content after it finishes
if (!isAnimationFinished) {
return (
<YStack
flex={1}
backgroundColor={black}
alignItems="center"
justifyContent="center"
>
<DelayedLottieView
autoPlay
loop={false}
source={youWinAnimation}
style={styles.animation}
onAnimationFinish={handleAnimationFinish}
resizeMode="contain"
cacheComposition={true}
renderMode="HARDWARE"
/>
</YStack>
);
}
return (
<YStack flex={1} backgroundColor={black}>
<SystemBars style="light" />
{/* Full screen background */}
<View
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
zIndex={0}
alignItems="center"
justifyContent="center"
>
<GratificationBg
width={screenWidth * 1.1}
height={screenHeight * 1.1}
/>
</View>
{/* Black overlay for top safe area (status bar) */}
<View
position="absolute"
top={0}
left={0}
right={0}
height={top}
backgroundColor={black}
zIndex={1}
/>
{/* Black overlay for bottom safe area */}
<View
position="absolute"
bottom={0}
left={0}
right={0}
height={bottom}
backgroundColor={black}
zIndex={1}
/>
{/* Back button */}
<View position="absolute" top={top + 20} left={20} zIndex={10}>
<Pressable onPress={handleBackPress}>
<View
backgroundColor={white}
width={46}
height={46}
borderRadius={23}
alignItems="center"
justifyContent="center"
>
<X width={24} height={24} />
</View>
</Pressable>
</View>
{/* Main content container */}
<YStack
flex={1}
paddingTop={top + 54}
paddingBottom={bottom + 50}
paddingHorizontal={20}
zIndex={2}
>
{/* Dialogue container */}
<YStack
flex={1}
borderRadius={14}
borderTopLeftRadius={14}
borderTopRightRadius={14}
paddingTop={84}
paddingBottom={24}
paddingHorizontal={24}
alignItems="center"
justifyContent="center"
>
{/* Logo icon */}
<View marginBottom={12} style={styles.logoContainer}>
<LogoWhite width={37} height={37} />
</View>
{/* Points display */}
<YStack alignItems="center" gap={0} marginBottom={18}>
<Text
fontFamily={dinotBold}
fontSize={98}
color={white}
textAlign="center"
letterSpacing={-2}
lineHeight={98}
>
{pointsEarned}
</Text>
<Text
fontFamily={dinot}
fontSize={48}
fontWeight="900"
color={white}
textAlign="center"
letterSpacing={-2}
lineHeight={48}
>
points earned
</Text>
</YStack>
{/* Description text */}
<Text
fontFamily={dinot}
fontSize={18}
fontWeight="500"
color={white}
textAlign="center"
lineHeight={24}
marginBottom={20}
paddingHorizontal={0}
>
Earn more points by proving your identity and referring friends
</Text>
</YStack>
{/* Bottom button container */}
<YStack
paddingTop={20}
paddingBottom={20}
paddingHorizontal={20}
gap={12}
>
<PrimaryButton
onPress={handleExploreRewards}
style={styles.primaryButton}
>
Explore rewards
</PrimaryButton>
<Pressable
onPress={handleInviteFriend}
style={({ pressed }) => [
styles.secondaryButton,
pressed && styles.secondaryButtonPressed,
]}
>
<RNText style={styles.secondaryButtonText}>Invite friends</RNText>
</Pressable>
</YStack>
</YStack>
</YStack>
);
};
export default GratificationScreen;
const styles = StyleSheet.create({
primaryButton: {
borderRadius: 60,
borderWidth: 1,
borderColor: slate700,
padding: 14,
},
secondaryButton: {
width: '100%',
backgroundColor: white,
borderWidth: 1,
borderColor: white,
padding: 14,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
},
secondaryButtonPressed: {
opacity: 0.8,
},
secondaryButtonText: {
fontFamily: dinot,
fontSize: 18,
color: black,
textAlign: 'center',
},
logoContainer: {
paddingBottom: 24,
},
animation: {
width: '100%',
height: '100%',
},
});

View File

@@ -2,11 +2,11 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Pressable, StyleSheet, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Anchor, Text, YStack } from 'tamagui';
import { useTurnkey } from '@turnkey/react-native-wallet-kit';
import {
AbstractButton,
@@ -18,6 +18,7 @@ import { AppEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { privacyUrl, termsUrl } from '@/consts/links';
import useConnectionModal from '@/hooks/useConnectionModal';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { useModal } from '@/hooks/useModal';
import IDCardPlaceholder from '@/images/icons/id_card_placeholder.svg';
import {
black,
@@ -31,10 +32,34 @@ import { advercase, dinot } from '@/utils/fonts';
const LaunchScreen: React.FC = () => {
useConnectionModal();
const { handleGoogleOauth, fetchWallets } = useTurnkey();
const onPress = useHapticNavigation('CountryPicker');
const createMock = useHapticNavigation('CreateMock');
const { bottom } = useSafeAreaInsets();
const { showModal: showNoWalletsModal } = useModal({
titleText: 'No wallets found',
bodyText: 'No wallets found. Please sign in with Turnkey to continue.',
buttonText: 'OK',
onButtonPress: () => {},
onModalDismiss: () => {},
});
const onImportWalletPress = async () => {
try {
await handleGoogleOauth();
const fetchedWallets = await fetchWallets();
if (fetchedWallets.length === 0) {
showNoWalletsModal();
return;
}
onPress();
} catch {
console.error('handleGoogleOauth error');
}
};
const devModeTap = Gesture.Tap()
.numberOfTaps(5)
.onStart(() => {
@@ -98,6 +123,13 @@ const LaunchScreen: React.FC = () => {
Get Started
</AbstractButton>
<Pressable onPress={onImportWalletPress}>
<Text style={styles.disclaimer}>
<Text style={styles.haveAnAccount}>{`Have an account? `}</Text>
<Text style={styles.restore}>restore</Text>
</Text>
</Pressable>
<Caption style={styles.notice}>
By continuing, you agree to the&nbsp;
<Anchor style={styles.link} href={termsUrl}>
@@ -149,7 +181,21 @@ const styles = StyleSheet.create({
width: 40,
height: 40,
},
disclaimer: {
width: '100%',
fontSize: 11,
letterSpacing: 0.4,
textTransform: 'uppercase',
fontWeight: '500',
fontFamily: dinot,
textAlign: 'center',
},
haveAnAccount: {
color: '#6b7280',
},
restore: {
color: '#fff',
},
notice: {
fontFamily: dinot,
marginVertical: 10,

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