mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Merge pull request #1950 from selfxyz/release/staging-2026-04-09
Release: staging v2.9.16 — recovery, navigation, security, cleanup
This commit is contained in:
@@ -101,7 +101,7 @@ Follow these strictly:
|
||||
|
||||
1. **Decisions, not options** — "Use local wrappers" not "Consider adding to Euclid or using local wrappers." Every ambiguous implementation choice must be resolved in the spec. If you genuinely can't decide, flag it as a blocker and ask the user — don't embed it as an option.
|
||||
2. **Second person** — "You are fixing...", "You will modify..."
|
||||
3. **Exact file paths with line numbers** — `src/utils/sumsubProvider.ts:118`, not "the provider file"
|
||||
3. **Exact file paths with line numbers** — `src/utils/kycProvider.ts:118`, not "the provider file"
|
||||
4. **Current code, not stale references** — you read the files in Step 2, use what you actually saw
|
||||
5. **Explicit constraints** — "You will NOT modify..." sections prevent scope creep
|
||||
6. **Required vs optional** — mark every item. Don't let agents infer priority.
|
||||
|
||||
4
.github/workflows/mobile-deploy.yml
vendored
4
.github/workflows/mobile-deploy.yml
vendored
@@ -715,7 +715,7 @@ jobs:
|
||||
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
|
||||
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
DIDIT_TEE_URL: ${{ secrets.DIDIT_TEE_URL }}
|
||||
KYC_TEE_URL: ${{ secrets.KYC_TEE_URL }}
|
||||
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 }}
|
||||
@@ -1176,7 +1176,7 @@ jobs:
|
||||
NODE_OPTIONS: "--max-old-space-size=6144"
|
||||
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
DIDIT_TEE_URL: ${{ secrets.DIDIT_TEE_URL }}
|
||||
KYC_TEE_URL: ${{ secrets.KYC_TEE_URL }}
|
||||
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 }}
|
||||
|
||||
6
.github/workflows/mobile-e2e.yml
vendored
6
.github/workflows/mobile-e2e.yml
vendored
@@ -824,6 +824,12 @@ jobs:
|
||||
SIMULATOR_ID="${IOS_SIMULATOR_ID:-iPhone SE (3rd generation)}"
|
||||
echo "Installing on simulator: $SIMULATOR_ID"
|
||||
|
||||
echo "Erasing simulator to ensure clean state..."
|
||||
xcrun simctl shutdown "$SIMULATOR_ID" 2>/dev/null || true
|
||||
xcrun simctl erase "$SIMULATOR_ID"
|
||||
xcrun simctl boot "$SIMULATOR_ID" || true
|
||||
xcrun simctl bootstatus "$SIMULATOR_ID" -b
|
||||
|
||||
echo "Removing any existing app installation..."
|
||||
xcrun simctl uninstall "$SIMULATOR_ID" "$IOS_BUNDLE_ID" 2>/dev/null || true
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ package-lock.json
|
||||
!.claude/skills/
|
||||
**/.claude/settings.json
|
||||
**/.claude/settings.local.json
|
||||
.mcp.json
|
||||
|
||||
# CI-generated tarballs (don't commit these!)
|
||||
mobile-sdk-alpha-ci.tgz
|
||||
|
||||
@@ -73,7 +73,7 @@ Specs are agent-executable prompts. A new Claude Code session with no prior cont
|
||||
- **Make decisions, not options.** "Use local wrappers" not "Consider adding to Euclid or using local wrappers." Agents can't choose between approaches — tell them which one.
|
||||
- **Use second person.** "You are fixing X" not "X should be fixed."
|
||||
- **Be explicit about constraints.** "You will NOT modify..." not just "Focus on..."
|
||||
- **Provide exact file paths with line numbers.** `src/utils/sumsubProvider.ts:118` not "the provider file."
|
||||
- **Provide exact file paths with line numbers.** `src/utils/kycProvider.ts:118` not "the provider file."
|
||||
- **State the validation command.** Agents will run it. If it's not there, they'll skip validation.
|
||||
- **One spec = one PR.** Target the PR size from Key Rules (1k–3k LOC). If a spec would exceed that, split it.
|
||||
- **Mark items as required vs optional.** Don't let agents infer priority.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
tools:ignore="GoogleAppIndexingWarning"
|
||||
tools:replace="android:usesCleartextTraffic">
|
||||
|
||||
<!-- Override conflicting ML Kit dependencies from passportreader (ocr) and Sumsub SDK (face) -->
|
||||
<!-- Override conflicting ML Kit dependencies from passportreader (ocr) and the KYC SDK (face) -->
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="ocr,face"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- Remove FOREGROUND_SERVICE_MICROPHONE merged in by Sumsub SDK (VideoIdent is disabled) -->
|
||||
<!-- Remove FOREGROUND_SERVICE_MICROPHONE merged in by the KYC SDK (VideoIdent is disabled) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" tools:node="remove" />
|
||||
|
||||
<application
|
||||
@@ -112,7 +112,7 @@
|
||||
<meta-data android:name="photopicker_activity:0:required" android:value="" />
|
||||
</service>
|
||||
|
||||
<!-- Override conflicting ML Kit dependencies from passportreader (ocr) and Sumsub SDK (face) -->
|
||||
<!-- Override conflicting ML Kit dependencies from passportreader (ocr) and the KYC SDK (face) -->
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="ocr,face"
|
||||
|
||||
@@ -10,4 +10,4 @@ IS_TEST_BUILD=
|
||||
MIXPANEL_NFC_PROJECT_TOKEN=
|
||||
SEGMENT_KEY=
|
||||
SENTRY_DSN=
|
||||
DIDIT_TEE_URL=
|
||||
KYC_TEE_URL=
|
||||
|
||||
@@ -8,9 +8,6 @@ export const DEFAULT_DOE = undefined;
|
||||
|
||||
export const DEFAULT_PNUMBER = undefined;
|
||||
|
||||
export const DIDIT_TEE_URL =
|
||||
process.env.DIDIT_TEE_URL || 'http://localhost:8080';
|
||||
|
||||
export const ENABLE_DEBUG_LOGS = process.env.ENABLE_DEBUG_LOGS === 'true';
|
||||
|
||||
export const GOOGLE_SIGNIN_ANDROID_CLIENT_ID =
|
||||
@@ -21,15 +18,17 @@ export const GOOGLE_SIGNIN_IOS_CLIENT_ID =
|
||||
|
||||
export const GOOGLE_SIGNIN_WEB_CLIENT_ID =
|
||||
process.env.GOOGLE_SIGNIN_WEB_CLIENT_ID;
|
||||
|
||||
export const GRAFANA_LOKI_PASSWORD = process.env.GRAFANA_LOKI_PASSWORD;
|
||||
export const GRAFANA_LOKI_URL = process.env.GRAFANA_LOKI_URL;
|
||||
|
||||
export const GRAFANA_LOKI_USERNAME = process.env.GRAFANA_LOKI_USERNAME;
|
||||
|
||||
/* This file provides compatiblity between how web expects env variables to be and how native does.
|
||||
* on web it is aliased to @env on native it is not used
|
||||
*/
|
||||
export const IS_TEST_BUILD = process.env.IS_TEST_BUILD === 'true';
|
||||
|
||||
export const KYC_TEE_URL = process.env.KYC_TEE_URL || 'http://localhost:8080';
|
||||
export const MIXPANEL_NFC_PROJECT_TOKEN = undefined;
|
||||
export const SEGMENT_KEY = process.env.SEGMENT_KEY;
|
||||
export const SENTRY_DSN = process.env.SENTRY_DSN;
|
||||
|
||||
@@ -61,6 +61,10 @@ def simulator_arm64_blockers
|
||||
end
|
||||
end
|
||||
|
||||
# Build RNFirebase pods as static frameworks so vendored Firebase XCFrameworks
|
||||
# (e.g. GoogleAppMeasurement, FirebaseAnalytics) link correctly with use_frameworks!
|
||||
$RNFirebaseAsStaticFramework = true
|
||||
|
||||
linkage = ENV["USE_FRAMEWORKS"]
|
||||
if linkage != nil
|
||||
Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green
|
||||
|
||||
@@ -59,6 +59,11 @@ PODS:
|
||||
- Yoga
|
||||
- fast_float (6.1.4)
|
||||
- FBLazyVector (0.77.0)
|
||||
- Firebase/Analytics (11.11.0):
|
||||
- Firebase/Core
|
||||
- Firebase/Core (11.11.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseAnalytics (~> 11.11.0)
|
||||
- Firebase/CoreOnly (11.11.0):
|
||||
- FirebaseCore (~> 11.11.0)
|
||||
- Firebase/Messaging (11.11.0):
|
||||
@@ -69,6 +74,24 @@ PODS:
|
||||
- FirebaseRemoteConfig (~> 11.11.0)
|
||||
- FirebaseABTesting (11.11.0):
|
||||
- FirebaseCore (~> 11.11.0)
|
||||
- FirebaseAnalytics (11.11.0):
|
||||
- FirebaseAnalytics/AdIdSupport (= 11.11.0)
|
||||
- FirebaseCore (~> 11.11.0)
|
||||
- FirebaseInstallations (~> 11.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- FirebaseAnalytics/AdIdSupport (11.11.0):
|
||||
- FirebaseCore (~> 11.11.0)
|
||||
- FirebaseInstallations (~> 11.0)
|
||||
- GoogleAppMeasurement (= 11.11.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- FirebaseCore (11.11.0):
|
||||
- FirebaseCoreInternal (~> 11.11.0)
|
||||
- GoogleUtilities/Environment (~> 8.0)
|
||||
@@ -103,6 +126,26 @@ PODS:
|
||||
- FirebaseSharedSwift (11.15.0)
|
||||
- fmt (11.0.2)
|
||||
- glog (0.3.5)
|
||||
- GoogleAppMeasurement (11.11.0):
|
||||
- GoogleAppMeasurement/AdIdSupport (= 11.11.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/AdIdSupport (11.11.0):
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.11.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (11.11.0):
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
@@ -121,6 +164,9 @@ PODS:
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/MethodSwizzler (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Network (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- "GoogleUtilities/NSData+zlib"
|
||||
@@ -142,12 +188,12 @@ PODS:
|
||||
- hermes-engine (0.77.0):
|
||||
- hermes-engine/Pre-built (= 0.77.0)
|
||||
- hermes-engine/Pre-built (0.77.0)
|
||||
- lottie-ios (4.5.0)
|
||||
- lottie-react-native (7.2.2):
|
||||
- lottie-ios (4.6.0)
|
||||
- lottie-react-native (7.3.6):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
- lottie-ios (= 4.5.0)
|
||||
- lottie-ios (= 4.6.0)
|
||||
- RCT-Folly (= 2024.11.18.00)
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
@@ -1428,7 +1474,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- react-native-compat (2.23.1):
|
||||
- react-native-compat (2.23.9):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1449,13 +1495,14 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- YttriumWrapper (= 0.10.50)
|
||||
- react-native-get-random-values (1.11.0):
|
||||
- React-Core
|
||||
- react-native-netinfo (11.4.1):
|
||||
- React-Core
|
||||
- react-native-nfc-manager (3.17.2):
|
||||
- React-Core
|
||||
- react-native-passkey (3.3.2):
|
||||
- react-native-passkey (3.3.3):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1476,7 +1523,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- react-native-safe-area-context (5.6.2):
|
||||
- react-native-safe-area-context (5.7.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1489,8 +1536,8 @@ PODS:
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- react-native-safe-area-context/common (= 5.6.2)
|
||||
- react-native-safe-area-context/fabric (= 5.6.2)
|
||||
- react-native-safe-area-context/common (= 5.7.0)
|
||||
- react-native-safe-area-context/fabric (= 5.7.0)
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-rendererdebug
|
||||
@@ -1499,7 +1546,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- react-native-safe-area-context/common (5.6.2):
|
||||
- react-native-safe-area-context/common (5.7.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1520,7 +1567,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- react-native-safe-area-context/fabric (5.6.2):
|
||||
- react-native-safe-area-context/fabric (5.7.0):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
@@ -1900,6 +1947,10 @@ PODS:
|
||||
- Yoga
|
||||
- RNDeviceInfo (15.0.2):
|
||||
- React-Core
|
||||
- RNFBAnalytics (21.14.0):
|
||||
- Firebase/Analytics (= 11.11.0)
|
||||
- React-Core
|
||||
- RNFBApp
|
||||
- RNFBApp (21.14.0):
|
||||
- Firebase/CoreOnly (= 11.11.0)
|
||||
- React-Core
|
||||
@@ -1955,7 +2006,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNInAppBrowser (3.7.0):
|
||||
- RNInAppBrowser (3.7.1):
|
||||
- React-Core
|
||||
- RNKeychain (10.0.0):
|
||||
- DoubleConversion
|
||||
@@ -2131,7 +2182,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- SdkReactNative (3.2.7):
|
||||
- SdkReactNative (3.2.8):
|
||||
- DiditSDK (~> 3.2)
|
||||
- DoubleConversion
|
||||
- glog
|
||||
@@ -2153,7 +2204,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- segment-analytics-react-native (2.21.4):
|
||||
- segment-analytics-react-native (2.22.0):
|
||||
- React-Core
|
||||
- sovran-react-native
|
||||
- SelfNFCPassportReader (2.1.1):
|
||||
@@ -2166,6 +2217,7 @@ PODS:
|
||||
- SwiftQRScanner (1.1.6)
|
||||
- SwiftyTesseract (3.1.3)
|
||||
- Yoga (0.0.0)
|
||||
- YttriumWrapper (0.10.50)
|
||||
|
||||
DEPENDENCIES:
|
||||
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
|
||||
@@ -2267,6 +2319,7 @@ DEPENDENCIES:
|
||||
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)"
|
||||
- RNDeviceInfo (from `../node_modules/react-native-device-info`)
|
||||
- "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)"
|
||||
- "RNFBApp (from `../node_modules/@react-native-firebase/app`)"
|
||||
- "RNFBMessaging (from `../node_modules/@react-native-firebase/messaging`)"
|
||||
- "RNFBRemoteConfig (from `../node_modules/@react-native-firebase/remote-config`)"
|
||||
@@ -2292,6 +2345,7 @@ SPEC REPOS:
|
||||
- AppCheckCore
|
||||
- Firebase
|
||||
- FirebaseABTesting
|
||||
- FirebaseAnalytics
|
||||
- FirebaseCore
|
||||
- FirebaseCoreExtension
|
||||
- FirebaseCoreInternal
|
||||
@@ -2300,6 +2354,7 @@ SPEC REPOS:
|
||||
- FirebaseRemoteConfig
|
||||
- FirebaseRemoteConfigInterop
|
||||
- FirebaseSharedSwift
|
||||
- GoogleAppMeasurement
|
||||
- GoogleDataTransport
|
||||
- GoogleSignIn
|
||||
- GoogleUtilities
|
||||
@@ -2315,6 +2370,7 @@ SPEC REPOS:
|
||||
- Sentry
|
||||
- SocketRocket
|
||||
- SwiftyTesseract
|
||||
- YttriumWrapper
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
boost:
|
||||
@@ -2504,6 +2560,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/@react-native-clipboard/clipboard"
|
||||
RNDeviceInfo:
|
||||
:path: "../node_modules/react-native-device-info"
|
||||
RNFBAnalytics:
|
||||
:path: "../node_modules/@react-native-firebase/analytics"
|
||||
RNFBApp:
|
||||
:path: "../node_modules/@react-native-firebase/app"
|
||||
RNFBMessaging:
|
||||
@@ -2570,6 +2628,7 @@ SPEC CHECKSUMS:
|
||||
FBLazyVector: 2bc03a5cf64e29c611bbc5d7eb9d9f7431f37ee6
|
||||
Firebase: 6a8f201c61eda24e98f1ce2b44b1b9c2caf525cc
|
||||
FirebaseABTesting: 8551c24eb28e300ce697f8eb72c1a519bb96eb40
|
||||
FirebaseAnalytics: acfa848bf81e1a4dbf60ef1f0eddd7328fe6673e
|
||||
FirebaseCore: 2321536f9c423b1f857e047a82b8a42abc6d9e2c
|
||||
FirebaseCoreExtension: 3a64994969dd05f4bcb7e6896c654eded238e75b
|
||||
FirebaseCoreInternal: 31ee350d87b30a9349e907f84bf49ef8e6791e5a
|
||||
@@ -2580,14 +2639,15 @@ SPEC CHECKSUMS:
|
||||
FirebaseSharedSwift: e17c654ef1f1a616b0b33054e663ad1035c8fd40
|
||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
||||
glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8
|
||||
GoogleAppMeasurement: 8a82b93a6400c8e6551c0bcd66a9177f2e067aed
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
hermes-engine: 1f783c3d53940aed0d2c84586f0b7a85ab7827ef
|
||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||
lottie-react-native: 6cb05b7b4ea463afe657e3b46784f067858e1a5d
|
||||
lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3
|
||||
lottie-react-native: d73a798e26348851f0ef349df3d40f2e27fd239b
|
||||
Mixpanel-swift: e9bef28a9648faff384d5ba6f48ecc2787eb24c0
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
OpenSSL-Universal: 84efb8a29841f2764ac5403e0c4119a28b713346
|
||||
@@ -2627,12 +2687,12 @@ SPEC CHECKSUMS:
|
||||
react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d
|
||||
react-native-blur: 745703f35133ed6a1210d4bbff358a631911f002
|
||||
react-native-cloud-storage: 796c793dc354bb49f9df27ca25eed0f79a15549e
|
||||
react-native-compat: 10b5f906b469268eaceca83ea2393c177f1ce18a
|
||||
react-native-compat: ad6a412f03632d1c4d97d47e56b22d0597116085
|
||||
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-nfc-manager: c8891e460b4943b695d63f7f4effc6345bbefc83
|
||||
react-native-passkey: 8818f842d1b80e45c06e906a5c85964719782bf5
|
||||
react-native-safe-area-context: 5b5d3eb6ec9ef848f16c064a4eab4a92c7d7895e
|
||||
react-native-passkey: 3c07a93dc2608929d794b7298c0d29d01b379f01
|
||||
react-native-safe-area-context: bf457bf5b3a617e9a3930d1ecd954a3335303cc7
|
||||
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
|
||||
react-native-webview: 05734d99f1e422c5ddfeefbd083d53abd78fccb1
|
||||
React-nativeconfig: 334c9961d74ddd3bc203afb92ee574ed01c7c755
|
||||
@@ -2668,20 +2728,21 @@ SPEC CHECKSUMS:
|
||||
RNCAsyncStorage: 6a8127b6987dc9fbce778669b252b14c8355c7ce
|
||||
RNCClipboard: 9f7b908de4bf4353871fb454c15fc03db4917b88
|
||||
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
|
||||
RNFBApp: 4105e54d9ca4a1c10893a032268470f670181110
|
||||
RNFBMessaging: 6857871d9dff8f26b0c325fc7d97ba69cb77d213
|
||||
RNFBRemoteConfig: 8d3675f18c052483ce294bb97b857428467fb41e
|
||||
RNFBAnalytics: 03c83ba4617a3754c99e66267983efcc908932a9
|
||||
RNFBApp: a448037d2df74af9d374a0b765be12ff1e844dc0
|
||||
RNFBMessaging: 0f0498a95c605e3afcf13ac5f349d0b201ea65f6
|
||||
RNFBRemoteConfig: 4eb5fc9f21dc324153c7d3f5b48c935ab9031876
|
||||
RNGestureHandler: 36aca36e4ef19f55dbf97239199d00fd58494e34
|
||||
RNGoogleSignin: 60c3f470558dbff0ae54f2f164ef82a89d3eb561
|
||||
RNInAppBrowser: 6d3eb68d471b9834335c664704719b8be1bfdb20
|
||||
RNInAppBrowser: 904d24dc75e8e6c6c98a3160329192608946f9df
|
||||
RNKeychain: 35beaa17938f7d8e4990d8a38fad5f8a748fc47c
|
||||
RNLocalize: aa57bee9fcd545b98ce773a8e2404f9a36115b4a
|
||||
RNReactNativeHapticFeedback: eb5395b503c7a8f10de5e6722ef8afd3c61bc4f5
|
||||
RNScreens: b0811b109e1a0b8b579f3348018e177bee374840
|
||||
RNSentry: 98ab9f6a16c9596e36565ccf1a5871323f334766
|
||||
RNSVG: d926926b169d8b81eb06aeb69734076e1dd566a3
|
||||
SdkReactNative: 7f65ca10a978bf9440730a537d54511e68ed50b4
|
||||
segment-analytics-react-native: 0eae155b0e9fa560fa6b17d78941df64537c35b7
|
||||
SdkReactNative: 34ba85b3f3060892c548b7415b06f0bd66fcba1c
|
||||
segment-analytics-react-native: 8ab9c49df1859bbd6be93cf90a91ade17f20a0aa
|
||||
SelfNFCPassportReader: 8b53f9d483e0dd1f1a275953e3dc6dfc733694c5
|
||||
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
|
||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||
@@ -2689,7 +2750,8 @@ SPEC CHECKSUMS:
|
||||
SwiftQRScanner: e85a25f9b843e9231dab89a96e441472fe54a724
|
||||
SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb
|
||||
Yoga: c34725819ab0a5962e85455b9e56679b306910ee
|
||||
YttriumWrapper: d7f63336830536f1da41b745ed8bacedb04228c4
|
||||
|
||||
PODFILE CHECKSUM: 0b99fae2ec87b0be3e6d9b3fd87360fe0a84b25f
|
||||
PODFILE CHECKSUM: 83f631d1b6308502a035e656b2f9dfab30431ae3
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -75,11 +75,6 @@
|
||||
"web:build": "yarn build:deps && vite build",
|
||||
"web:preview": "vite preview"
|
||||
},
|
||||
"resolutions": {
|
||||
"punycode": "npm:punycode.js@2.3.1",
|
||||
"react-native-blur-effect": "1.1.3",
|
||||
"react-native-webview": "13.16.0"
|
||||
},
|
||||
"overrides": {
|
||||
"punycode": "npm:punycode.js@2.3.1",
|
||||
"react-native-blur-effect": "1.1.3",
|
||||
@@ -97,12 +92,13 @@
|
||||
"@react-native-clipboard/clipboard": "1.16.3",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-firebase/analytics": "^21.14.0",
|
||||
"@react-native-firebase/app": "^21.14.0",
|
||||
"@react-native-firebase/messaging": "^21.14.0",
|
||||
"@react-native-firebase/remote-config": "^21.14.0",
|
||||
"@react-native-google-signin/google-signin": "^16.1.2",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@react-navigation/native-stack": "^7.2.0",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"@robinbobin/react-native-google-drive-api-wrapper": "^2.2.6",
|
||||
"@segment/analytics-react-native": "^2.22.0",
|
||||
"@segment/sovran-react-native": "^1.1.3",
|
||||
@@ -112,19 +108,19 @@
|
||||
"@sentry/react": "^9.32.0",
|
||||
"@sentry/react-native": "7.0.0",
|
||||
"@stablelib/cbor": "^2.0.4",
|
||||
"@tamagui/animations-css": "1.126.14",
|
||||
"@tamagui/animations-react-native": "1.126.14",
|
||||
"@tamagui/config": "1.126.14",
|
||||
"@tamagui/lucide-icons": "1.126.14",
|
||||
"@tamagui/toast": "1.126.14",
|
||||
"@tamagui/animations-css": "1.144.4",
|
||||
"@tamagui/animations-react-native": "1.144.4",
|
||||
"@tamagui/config": "1.144.4",
|
||||
"@tamagui/lucide-icons": "1.144.4",
|
||||
"@tamagui/toast": "1.144.4",
|
||||
"@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",
|
||||
"@walletconnect/react-native-compat": "^2.23.9",
|
||||
"@xstate/react": "^5.0.3",
|
||||
"asn1js": "^3.0.7",
|
||||
"axios": "^1.14.0",
|
||||
"axios": "^1.15.0",
|
||||
"buffer": "^6.0.3",
|
||||
"country-emoji": "^1.5.6",
|
||||
"elliptic": "^6.6.1",
|
||||
@@ -136,7 +132,7 @@
|
||||
"js-sha256": "^0.11.1",
|
||||
"js-sha512": "^0.9.0",
|
||||
"lottie-react": "^2.4.1",
|
||||
"lottie-react-native": "7.2.2",
|
||||
"lottie-react-native": "7.3.6",
|
||||
"node-forge": "^1.4.0",
|
||||
"pkijs": "^3.4.0",
|
||||
"poseidon-lite": "^0.2.0",
|
||||
@@ -169,12 +165,12 @@
|
||||
"react-native-svg-web": "1.0.9",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-qr-barcode-scanner": "^2.1.25",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tamagui": "1.126.14",
|
||||
"tamagui": "1.144.4",
|
||||
"uuid": "^11.1.0",
|
||||
"xstate": "^5.20.2",
|
||||
"xstate": "^5.30.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -192,8 +188,8 @@
|
||||
"@react-native/gradle-plugin": "0.77.0",
|
||||
"@react-native/metro-config": "0.77.0",
|
||||
"@react-native/typescript-config": "0.77.0",
|
||||
"@tamagui/types": "1.126.14",
|
||||
"@tamagui/vite-plugin": "1.126.14",
|
||||
"@tamagui/types": "1.144.4",
|
||||
"@tamagui/vite-plugin": "1.144.4",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@tsconfig/react-native": "^3.0.9",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
@@ -206,8 +202,8 @@
|
||||
"@types/react-native-dotenv": "^0.2.2",
|
||||
"@types/react-native-sqlite-storage": "^6.0.5",
|
||||
"@types/react-native-web": "^0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.39.0",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.1",
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"babel-plugin-module-resolver": "^5.0.3",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
|
||||
@@ -29,8 +29,8 @@ interface FeedbackModalProps {
|
||||
}
|
||||
|
||||
const FeedbackModal: React.FC<FeedbackModalProps> = ({ visible, onClose }) => {
|
||||
const handleSupportForm = async () => {
|
||||
await openSupportForm();
|
||||
const handleSupportForm = () => {
|
||||
openSupportForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 {
|
||||
black,
|
||||
blue600,
|
||||
slate50,
|
||||
slate200,
|
||||
slate300,
|
||||
slate400,
|
||||
slate500,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
|
||||
import HeartIcon from '@/assets/icons/heart.svg';
|
||||
import StarBlackIcon from '@/assets/icons/star_black.svg';
|
||||
import type { PointEvent } from '@/services/points';
|
||||
import { usePointEventStore } from '@/stores/pointEventStore';
|
||||
|
||||
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;
|
||||
@@ -47,9 +47,15 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => {
|
||||
<NavBar.Title
|
||||
color={headerTitleStyle?.color as string}
|
||||
style={{
|
||||
fontFamily: headerTitleStyle?.fontFamily,
|
||||
fontSize: headerTitleStyle?.fontSize,
|
||||
fontWeight: headerTitleStyle?.fontWeight,
|
||||
...(headerTitleStyle?.fontFamily && {
|
||||
fontFamily: headerTitleStyle.fontFamily,
|
||||
}),
|
||||
...(headerTitleStyle?.fontSize && {
|
||||
fontSize: headerTitleStyle.fontSize,
|
||||
}),
|
||||
...(headerTitleStyle?.fontWeight && {
|
||||
fontWeight: headerTitleStyle.fontWeight,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{props.options.title}
|
||||
|
||||
@@ -1,600 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 {
|
||||
black,
|
||||
blue600,
|
||||
slate50,
|
||||
slate200,
|
||||
slate500,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
|
||||
import BellWhiteIcon from '@/assets/icons/bell_white.svg';
|
||||
import ClockIcon from '@/assets/icons/clock.svg';
|
||||
import LockWhiteIcon from '@/assets/icons/lock_white.svg';
|
||||
import StarBlackIcon from '@/assets/icons/star_black.svg';
|
||||
import LogoInversed from '@/assets/images/logo_inversed.svg';
|
||||
import MajongImage from '@/assets/images/majong.png';
|
||||
import { PointHistoryList } from '@/components/PointHistoryList';
|
||||
import { appsUrl } from '@/consts/links';
|
||||
import { useIncomingPoints, usePoints } from '@/hooks/usePoints';
|
||||
import { usePointsGuardrail } from '@/hooks/usePointsGuardrail';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { trackScreenView } from '@/services/analytics';
|
||||
import {
|
||||
isTopicSubscribed,
|
||||
requestNotificationPermission,
|
||||
subscribeToTopics,
|
||||
} from '@/services/notifications/notificationService';
|
||||
import {
|
||||
formatTimeUntilDate,
|
||||
POINT_VALUES,
|
||||
recordBackupPointEvent,
|
||||
recordNotificationPointEvent,
|
||||
} from '@/services/points';
|
||||
import { usePointEventStore } from '@/stores/pointEventStore';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { registerModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
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(() => {
|
||||
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: appsUrl,
|
||||
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;
|
||||
@@ -1,60 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 { black, slate50 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
|
||||
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="DINOT-Medium"
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -14,7 +14,6 @@ export const apiBaseUrl = 'https://api.self.xyz';
|
||||
export const apiPingUrl = 'https://api.self.xyz/ping';
|
||||
export const appStoreUrl = 'https://apps.apple.com/app/self-zk/id6478563710';
|
||||
export const appleICloudDocsUrl = 'https://support.apple.com/en-us/102651';
|
||||
export const appsUrl = 'https://apps.self.xyz';
|
||||
export const discordUrl = 'https://discord.gg/selfxyz';
|
||||
export const gitHubUrl = 'https://github.com/selfxyz/self';
|
||||
export const googleDriveAppDataScope =
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 {
|
||||
hasUserAnIdentityDocumentRegistered,
|
||||
hasUserDoneThePointsDisclosure,
|
||||
POINT_VALUES,
|
||||
pointsSelfApp,
|
||||
} from '@/services/points';
|
||||
import useUserStore from '@/stores/userStore';
|
||||
import { registerModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
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('ProvingScreenRouter');
|
||||
}, 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(() => {
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {
|
||||
showPointsDisclosureModal();
|
||||
},
|
||||
onModalDismiss: () => {
|
||||
if (hasReferrer) {
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
navigation.navigate('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId,
|
||||
});
|
||||
}, [hasReferrer, 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 };
|
||||
};
|
||||
@@ -8,28 +8,31 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import { sanitizeErrorMessage } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { createSession, launchDidit } from '@/integrations/didit';
|
||||
import type { DiditVerificationResult } from '@/integrations/didit/types';
|
||||
import {
|
||||
createKycSession,
|
||||
launchKycVerification as startKycVerification,
|
||||
} from '@/integrations/kyc';
|
||||
import type { KycVerificationResult } from '@/integrations/kyc/types';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
|
||||
export type FallbackErrorSource = 'mrz_scan_failed' | 'nfc_scan_failed';
|
||||
|
||||
export interface UseDiditLauncherOptions {
|
||||
export interface UseKycLauncherOptions {
|
||||
/**
|
||||
* Country code for the user's document
|
||||
*/
|
||||
countryCode: string;
|
||||
/**
|
||||
* Error source to track where the Didit launch was initiated from
|
||||
* Error source to track where the KYC launch was initiated from
|
||||
*/
|
||||
errorSource: FallbackErrorSource;
|
||||
/**
|
||||
* Optional callback to handle successful verification.
|
||||
* Receives the Didit result and the sessionId from the session.
|
||||
* Receives the KYC result and the sessionId from the session.
|
||||
* If not provided, defaults to navigating to KycSuccess with the sessionId.
|
||||
*/
|
||||
onSuccess?: (
|
||||
result: DiditVerificationResult,
|
||||
result: KycVerificationResult,
|
||||
sessionId: string,
|
||||
) => void | Promise<void>;
|
||||
/**
|
||||
@@ -41,42 +44,42 @@ export interface UseDiditLauncherOptions {
|
||||
*/
|
||||
onError?: (
|
||||
error: unknown,
|
||||
result?: DiditVerificationResult,
|
||||
result?: KycVerificationResult,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for launching Didit verification with consistent error handling.
|
||||
* Custom hook for launching KYC verification with consistent error handling.
|
||||
*
|
||||
* Abstracts the common pattern of:
|
||||
* 1. Creating a session
|
||||
* 2. Launching Didit SDK
|
||||
* 2. Launching the provider SDK
|
||||
* 3. Handling errors by navigating to fallback screen
|
||||
* 4. Managing loading state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { launchDiditVerification, isLoading } = useDiditLauncher({
|
||||
* const { launchKycVerification, isLoading } = useKycLauncher({
|
||||
* countryCode: 'US',
|
||||
* errorSource: 'nfc_scan_failed',
|
||||
* });
|
||||
*
|
||||
* <Button onPress={launchDiditVerification} disabled={isLoading}>
|
||||
* <Button onPress={launchKycVerification} disabled={isLoading}>
|
||||
* {isLoading ? 'Loading...' : 'Try Alternative Verification'}
|
||||
* </Button>
|
||||
* ```
|
||||
*/
|
||||
export const useDiditLauncher = (options: UseDiditLauncherOptions) => {
|
||||
export const useKycLauncher = (options: UseKycLauncherOptions) => {
|
||||
const { countryCode, errorSource, onSuccess, onCancel, onError } = options;
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const launchDiditVerification = useCallback(async () => {
|
||||
const launchKycVerification = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const session = await createSession();
|
||||
const result = await launchDidit(session.sessionToken);
|
||||
const session = await createKycSession();
|
||||
const result = await startKycVerification(session.sessionToken);
|
||||
|
||||
// Handle user cancellation
|
||||
if (result.type === 'cancelled') {
|
||||
@@ -89,7 +92,7 @@ export const useDiditLauncher = (options: UseDiditLauncherOptions) => {
|
||||
const error =
|
||||
result.error?.message || result.error?.type || 'Unknown error';
|
||||
const safeError = sanitizeErrorMessage(error);
|
||||
console.error('Didit verification failed:', safeError);
|
||||
console.error('KYC verification failed:', safeError);
|
||||
|
||||
// Call custom error handler if provided, otherwise navigate to fallback screen
|
||||
if (onError) {
|
||||
@@ -134,7 +137,7 @@ export const useDiditLauncher = (options: UseDiditLauncherOptions) => {
|
||||
}, [navigation, countryCode, errorSource, onSuccess, onCancel, onError]);
|
||||
|
||||
return {
|
||||
launchDiditVerification,
|
||||
launchKycVerification,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
@@ -4,17 +4,20 @@
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { DIDIT_TEE_URL } from '@env';
|
||||
import { KYC_TEE_URL } from '@env';
|
||||
|
||||
import { deserializeApplicantInfo } from '@selfxyz/common';
|
||||
import type { DocumentType, KycData } from '@selfxyz/common/utils/types';
|
||||
|
||||
import type { ApplicantInfoSerialized } from '@/integrations/didit/types';
|
||||
import type { ApplicantInfoSerialized } from '@/integrations/kyc/types';
|
||||
import { navigationRef } from '@/navigation';
|
||||
import { storeDocumentWithDeduplication } from '@/providers/passportDataProvider';
|
||||
import { usePendingKycStore } from '@/stores/pendingKycStore';
|
||||
|
||||
interface UseDiditWebSocketOptions {
|
||||
const redactSessionId = (id: string) =>
|
||||
id.length > 8 ? `${id.slice(0, 4)}***${id.slice(-4)}` : '***';
|
||||
|
||||
interface UseKycWebSocketOptions {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onVerificationFailed?: (reason: string) => void;
|
||||
@@ -22,11 +25,11 @@ interface UseDiditWebSocketOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook for Didit websocket subscription logic.
|
||||
* Shared hook for KYC websocket subscription logic.
|
||||
* Handles connecting to the TEE service, subscribing to a sessionId,
|
||||
* and processing verification results.
|
||||
*/
|
||||
export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
export function useKycWebSocket(options: UseKycWebSocketOptions = {}) {
|
||||
const {
|
||||
onSuccess,
|
||||
onError,
|
||||
@@ -51,8 +54,8 @@ export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
(sessionId: string) => {
|
||||
if (subscribedSessionIdsRef.current.has(sessionId)) {
|
||||
console.log(
|
||||
'[DiditWebSocket] Already subscribed to sessionId:',
|
||||
sessionId,
|
||||
'[KycWebSocket] Already subscribed to sessionId:',
|
||||
redactSessionId(sessionId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -63,23 +66,23 @@ export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
// Don't retry 'processing' verifications as the proving machine is reading to be triggered.
|
||||
if (isProcessing) {
|
||||
console.log(
|
||||
'[DiditWebSocket] Verification in processing state, skipping for sessionId:',
|
||||
sessionId,
|
||||
'[KycWebSocket] Verification in processing state, skipping for sessionId:',
|
||||
redactSessionId(sessionId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!skipAddPending) {
|
||||
console.log(
|
||||
'[DiditWebSocket] Adding pending verification for sessionId:',
|
||||
sessionId,
|
||||
'[KycWebSocket] Adding pending verification for sessionId:',
|
||||
redactSessionId(sessionId),
|
||||
);
|
||||
addPendingVerification(sessionId);
|
||||
}
|
||||
subscribedSessionIdsRef.current.add(sessionId);
|
||||
|
||||
console.log('[DiditWebSocket] Connecting to WebSocket:', DIDIT_TEE_URL);
|
||||
const socket = io(DIDIT_TEE_URL, {
|
||||
console.log('[KycWebSocket] Connecting to WebSocket:', KYC_TEE_URL);
|
||||
const socket = io(KYC_TEE_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
@@ -87,16 +90,16 @@ export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log(
|
||||
'[DiditWebSocket] Connected, subscribing to user:',
|
||||
sessionId,
|
||||
'[KycWebSocket] Connected, subscribing to user:',
|
||||
redactSessionId(sessionId),
|
||||
);
|
||||
socket.emit('subscribe', sessionId);
|
||||
});
|
||||
|
||||
socket.on('success', async (data: ApplicantInfoSerialized) => {
|
||||
console.log(
|
||||
'[DiditWebSocket] Received applicant info for sessionId:',
|
||||
sessionId,
|
||||
'[KycWebSocket] Received applicant info for sessionId:',
|
||||
redactSessionId(sessionId),
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -113,7 +116,7 @@ export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
};
|
||||
const documentId = await storeDocumentWithDeduplication(kycData);
|
||||
console.log(
|
||||
'[DiditWebSocket] KYC data stored successfully, documentId:',
|
||||
'[KycWebSocket] KYC data stored successfully, documentId:',
|
||||
documentId,
|
||||
);
|
||||
|
||||
@@ -131,7 +134,7 @@ export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
socket.emit('ack_success', sessionId);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
console.error('[DiditWebSocket] Failed to store KYC data:', err);
|
||||
console.error('[KycWebSocket] Failed to store KYC data:', err);
|
||||
updateVerificationStatus(
|
||||
sessionId,
|
||||
'failed',
|
||||
@@ -146,7 +149,7 @@ export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
});
|
||||
|
||||
socket.on('verification_failed', (reason: string) => {
|
||||
console.log('[DiditWebSocket] Verification failed:', reason);
|
||||
console.log('[KycWebSocket] Verification failed:', reason);
|
||||
updateVerificationStatus(sessionId, 'failed', reason);
|
||||
onVerificationFailed?.(reason);
|
||||
|
||||
@@ -156,7 +159,7 @@ export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
});
|
||||
|
||||
socket.on('error', (errorMessage: string) => {
|
||||
console.error('[DiditWebSocket] Socket error:', errorMessage);
|
||||
console.error('[KycWebSocket] Socket error:', errorMessage);
|
||||
updateVerificationStatus(sessionId, 'failed', errorMessage);
|
||||
onError?.(errorMessage);
|
||||
|
||||
@@ -166,7 +169,10 @@ export function useDiditWebSocket(options: UseDiditWebSocketOptions = {}) {
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[DiditWebSocket] Disconnected for sessionId:', sessionId);
|
||||
console.log(
|
||||
'[KycWebSocket] Disconnected for sessionId:',
|
||||
redactSessionId(sessionId),
|
||||
);
|
||||
});
|
||||
},
|
||||
[
|
||||
21
app/src/hooks/useOpenSupportForm.ts
Normal file
21
app/src/hooks/useOpenSupportForm.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 { impactLight } from '@/integrations/haptics';
|
||||
import { openSupportForm } from '@/services/support';
|
||||
|
||||
/**
|
||||
* Hook wrapper around openSupportForm that adds haptic feedback.
|
||||
* Use this inside screen components. For code outside the navigation tree
|
||||
* (providers, modals rendered at root), call openSupportForm() directly.
|
||||
*/
|
||||
const useOpenSupportForm = () =>
|
||||
useCallback(() => {
|
||||
impactLight();
|
||||
openSupportForm();
|
||||
}, []);
|
||||
|
||||
export default useOpenSupportForm;
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useDiditWebSocket } from '@/hooks/useDiditWebSocket';
|
||||
import { useKycWebSocket } from '@/hooks/useKycWebSocket';
|
||||
import { navigationRef } from '@/navigation';
|
||||
import { usePendingKycStore } from '@/stores/pendingKycStore';
|
||||
|
||||
@@ -28,7 +28,7 @@ function getRecoveryIdentifier(verification: RecoveryVerification) {
|
||||
* 2. For each non-expired pending/processing verification, reconnects to websocket
|
||||
* 3. Subscribes to the sessionId to receive any cached results
|
||||
* 4. Updates verification status based on server response
|
||||
* 5. Initiates proving machine after document storage (handled in useDiditWebSocket)
|
||||
* 5. Initiates proving machine after document storage (handled in useKycWebSocket)
|
||||
*
|
||||
* NOTE: This requires the TEE server to cache completed verification results
|
||||
* so they can be retrieved when the app reopens.
|
||||
@@ -51,7 +51,7 @@ export function usePendingKycRecovery() {
|
||||
console.log('[PendingKycRecovery] Verification failed:', reason);
|
||||
}, []);
|
||||
|
||||
const { subscribe, unsubscribeAll } = useDiditWebSocket({
|
||||
const { subscribe, unsubscribeAll } = useKycWebSocket({
|
||||
skipAddPending: true,
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 { getNextSundayNoonUTC, type IncomingPoints } from '@/services/points';
|
||||
import { usePointEventStore } from '@/stores/pointEventStore';
|
||||
|
||||
/*
|
||||
* 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,
|
||||
};
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 '@/services/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]),
|
||||
);
|
||||
};
|
||||
@@ -13,8 +13,7 @@ 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
|
||||
* Flow: Sets referrer → shows confirmation modal → on confirm → registers referral
|
||||
*
|
||||
* @param shouldAutoTrigger - Whether to automatically trigger the flow after 3 seconds (default: false)
|
||||
*/
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
export type {
|
||||
ApplicantInfoSerialized,
|
||||
DiditVerificationResult,
|
||||
KycVerificationResult,
|
||||
SessionResponse,
|
||||
} from '@/integrations/didit/types';
|
||||
} from '@/integrations/kyc/types';
|
||||
export {
|
||||
type DiditConfig,
|
||||
createSession,
|
||||
launchDidit,
|
||||
} from '@/integrations/didit/diditService';
|
||||
type KycLaunchConfig,
|
||||
createKycSession,
|
||||
launchKycVerification,
|
||||
} from '@/integrations/kyc/kycService';
|
||||
@@ -3,22 +3,22 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { startVerification } from '@didit-protocol/sdk-react-native';
|
||||
import { DIDIT_TEE_URL } from '@env';
|
||||
import { KYC_TEE_URL } from '@env';
|
||||
|
||||
import type {
|
||||
DiditVerificationResult,
|
||||
KycVerificationResult,
|
||||
SessionResponse,
|
||||
} from '@/integrations/didit/types';
|
||||
} from '@/integrations/kyc/types';
|
||||
|
||||
export interface DiditConfig {
|
||||
export interface KycLaunchConfig {
|
||||
locale?: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30000;
|
||||
|
||||
export const createSession = async (): Promise<SessionResponse> => {
|
||||
const apiUrl = DIDIT_TEE_URL;
|
||||
export const createKycSession = async (): Promise<SessionResponse> => {
|
||||
const apiUrl = KYC_TEE_URL;
|
||||
console.log('[Didit] createSession URL:', apiUrl);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
@@ -36,9 +36,7 @@ export const createSession = async (): Promise<SessionResponse> => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to create Didit session (HTTP ${response.status})`,
|
||||
);
|
||||
throw new Error(`Failed to create KYC session (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
@@ -54,24 +52,24 @@ export const createSession = async (): Promise<SessionResponse> => {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
`Request to Didit TEE timed out after ${FETCH_TIMEOUT_MS / 1000}s`,
|
||||
`Request to KYC TEE timed out after ${FETCH_TIMEOUT_MS / 1000}s`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Failed to create Didit session: ${err.message}`);
|
||||
throw new Error(`Failed to create KYC session: ${err.message}`);
|
||||
}
|
||||
|
||||
throw new Error('Failed to create Didit session: Unknown error');
|
||||
throw new Error('Failed to create KYC session: Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
export const launchDidit = async (
|
||||
export const launchKycVerification = async (
|
||||
sessionToken: string,
|
||||
config?: DiditConfig,
|
||||
): Promise<DiditVerificationResult> => {
|
||||
config?: KycLaunchConfig,
|
||||
): Promise<KycVerificationResult> => {
|
||||
const result = await startVerification(sessionToken, {
|
||||
languageCode: config?.locale ?? 'en',
|
||||
loggingEnabled: config?.debug ?? __DEV__,
|
||||
});
|
||||
|
||||
return result as DiditVerificationResult;
|
||||
return result as KycVerificationResult;
|
||||
};
|
||||
@@ -8,7 +8,7 @@ export interface ApplicantInfoSerialized {
|
||||
pubkey: Array<string>;
|
||||
}
|
||||
|
||||
export interface DiditVerificationResult {
|
||||
export interface KycVerificationResult {
|
||||
type: 'completed' | 'cancelled' | 'failed';
|
||||
session?: {
|
||||
status: string;
|
||||
@@ -58,6 +58,7 @@ const accountScreens = {
|
||||
screen: CloudBackupScreen,
|
||||
options: {
|
||||
title: 'Account Backup',
|
||||
headerTintColor: black,
|
||||
headerStyle: {
|
||||
backgroundColor: white,
|
||||
},
|
||||
@@ -70,6 +71,7 @@ const accountScreens = {
|
||||
screen: ProofSettingsScreen,
|
||||
options: {
|
||||
title: 'Proof Settings',
|
||||
headerTintColor: black,
|
||||
headerStyle: {
|
||||
backgroundColor: white,
|
||||
},
|
||||
@@ -83,6 +85,8 @@ const accountScreens = {
|
||||
options: {
|
||||
animation: 'slide_from_bottom',
|
||||
title: 'Settings',
|
||||
headerBackTitle: 'close',
|
||||
headerTintColor: black,
|
||||
headerStyle: {
|
||||
backgroundColor: white,
|
||||
},
|
||||
@@ -106,6 +110,7 @@ const accountScreens = {
|
||||
} as NativeStackNavigationOptions)
|
||||
: ({
|
||||
title: 'Recovery Phrase',
|
||||
headerTintColor: black,
|
||||
headerStyle: {
|
||||
backgroundColor: white,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { DocumentCategory } from '@selfxyz/common/utils/types';
|
||||
|
||||
import { SystemBars } from '@/components/SystemBars';
|
||||
import DeferredLinkingInfoScreen from '@/screens/app/DeferredLinkingInfoScreen';
|
||||
import GratificationScreen from '@/screens/app/GratificationScreen';
|
||||
import LoadingScreen from '@/screens/app/LoadingScreen';
|
||||
import type { ModalNavigationParams } from '@/screens/app/ModalScreen';
|
||||
import ModalScreen from '@/screens/app/ModalScreen';
|
||||
@@ -50,16 +49,6 @@ const appScreens = {
|
||||
header: () => <SystemBars style="light" />,
|
||||
},
|
||||
},
|
||||
Gratification: {
|
||||
screen: GratificationScreen,
|
||||
options: {
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#000000' },
|
||||
} as NativeStackNavigationOptions,
|
||||
params: {} as {
|
||||
points?: number;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default appScreens;
|
||||
|
||||
@@ -102,35 +102,12 @@ const createDeeplinkNavigationState = (
|
||||
// Store the correct parent screen determined by splash screen
|
||||
let correctParentScreen: string = 'Home';
|
||||
|
||||
// Function for splash screen to get and clear the queued initial URL
|
||||
export const getAndClearQueuedUrl = (): string | null => {
|
||||
const url = queuedInitialUrl;
|
||||
queuedInitialUrl = null;
|
||||
return url;
|
||||
};
|
||||
|
||||
const safeNavigate = (
|
||||
navigationState: ReturnType<typeof createDeeplinkNavigationState>,
|
||||
): void => {
|
||||
const targetScreen = navigationState.routes[1]?.name as
|
||||
| keyof RootStackParamList
|
||||
| undefined;
|
||||
|
||||
const currentRoute = navigationRef.getCurrentRoute();
|
||||
const isColdLaunch = currentRoute?.name === 'Splash';
|
||||
|
||||
if (!isColdLaunch && targetScreen) {
|
||||
// Use object syntax to satisfy TypeScript's strict typing for navigate
|
||||
// The params will be undefined for screens that don't require them
|
||||
navigationRef.navigate({
|
||||
name: targetScreen,
|
||||
params: undefined,
|
||||
} as Parameters<typeof navigationRef.navigate>[0]);
|
||||
} else {
|
||||
navigationRef.reset(navigationState);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUrl = (selfClient: SelfClient, uri: string) => {
|
||||
const validatedParams = parseAndValidateUrlParams(uri);
|
||||
const {
|
||||
@@ -241,6 +218,28 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const safeNavigate = (
|
||||
navigationState: ReturnType<typeof createDeeplinkNavigationState>,
|
||||
): void => {
|
||||
const targetScreen = navigationState.routes[1]?.name as
|
||||
| keyof RootStackParamList
|
||||
| undefined;
|
||||
|
||||
const currentRoute = navigationRef.getCurrentRoute();
|
||||
const isColdLaunch = currentRoute?.name === 'Splash';
|
||||
|
||||
if (!isColdLaunch && targetScreen) {
|
||||
// Use object syntax to satisfy TypeScript's strict typing for navigate
|
||||
// The params will be undefined for screens that don't require them
|
||||
navigationRef.navigate({
|
||||
name: targetScreen,
|
||||
params: undefined,
|
||||
} as Parameters<typeof navigationRef.navigate>[0]);
|
||||
} else {
|
||||
navigationRef.reset(navigationState);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses and validates query parameters from a URL
|
||||
* @param uri - The URL to parse
|
||||
@@ -285,6 +284,9 @@ export const parseAndValidateUrlParams = (uri: string): ValidatedParams => {
|
||||
return validatedParams;
|
||||
};
|
||||
|
||||
// Function for splash screen to get and clear the queued initial URL
|
||||
export const peekQueuedUrl = (): string | null => queuedInitialUrl;
|
||||
|
||||
// Store the initial URL for splash screen to handle after initialization
|
||||
let queuedInitialUrl: string | null = null;
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ const documentsScreens = {
|
||||
screen: ManageDocumentsScreen,
|
||||
options: {
|
||||
title: 'Manage Documents',
|
||||
headerTintColor: black,
|
||||
headerStyle: {
|
||||
backgroundColor: white,
|
||||
},
|
||||
@@ -138,6 +139,7 @@ const documentsScreens = {
|
||||
screen: DocumentDataInfoScreen,
|
||||
options: {
|
||||
title: 'Document Data Info',
|
||||
headerTintColor: black,
|
||||
headerStyle: {
|
||||
backgroundColor: white,
|
||||
},
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
|
||||
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||
|
||||
import { black } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
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';
|
||||
|
||||
@@ -22,14 +21,6 @@ const homeScreens = {
|
||||
presentation: 'card',
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
Points: {
|
||||
screen: PointsScreen,
|
||||
options: {
|
||||
title: 'Self Points',
|
||||
header: PointsNavBar,
|
||||
presentation: 'card',
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
Referral: {
|
||||
screen: ReferralScreen,
|
||||
options: {
|
||||
@@ -41,20 +32,14 @@ const homeScreens = {
|
||||
options: {
|
||||
title: 'Approved Requests',
|
||||
headerBackTitle: 'close',
|
||||
},
|
||||
headerTintColor: black,
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
ProofHistoryDetail: {
|
||||
screen: ProofHistoryDetailScreen,
|
||||
options: {
|
||||
title: 'Approval',
|
||||
},
|
||||
},
|
||||
PointsInfo: {
|
||||
screen: PointsInfoScreen,
|
||||
options: {
|
||||
headerBackTitle: 'close',
|
||||
title: 'Self Points',
|
||||
animation: 'slide_from_bottom',
|
||||
headerTintColor: black,
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,7 +40,6 @@ export type AccountRoutesParamList = {
|
||||
CloudBackupSettings:
|
||||
| {
|
||||
nextScreen?: 'SaveRecoveryPhrase';
|
||||
returnToScreen?: 'Points';
|
||||
}
|
||||
| undefined;
|
||||
ProofSettings: undefined;
|
||||
@@ -58,9 +57,6 @@ export type AppRoutesParamList = {
|
||||
curveOrExponent?: string;
|
||||
};
|
||||
Modal: ModalNavigationParams;
|
||||
Gratification: {
|
||||
points?: number;
|
||||
};
|
||||
StarfallPushCode: undefined;
|
||||
};
|
||||
|
||||
@@ -131,13 +127,6 @@ export type HomeRoutesParamList = {
|
||||
Home: {
|
||||
testReferralFlow?: boolean;
|
||||
};
|
||||
Points: undefined;
|
||||
PointsInfo:
|
||||
| {
|
||||
showNextButton?: boolean;
|
||||
callbackId?: number;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -419,8 +419,13 @@ export function getPrivateKeyFromMnemonic(mnemonic: string) {
|
||||
}
|
||||
|
||||
export async function hasSecretStored() {
|
||||
const seed = await Keychain.getGenericPassword({ service: SERVICE_NAME });
|
||||
return !!seed;
|
||||
try {
|
||||
const seed = await Keychain.getGenericPassword({ service: SERVICE_NAME });
|
||||
return !!seed;
|
||||
} catch (error) {
|
||||
console.warn('Error checking for stored secret:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrates existing mnemonic to use new security settings with accessControl.
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { logNFCEvent, logProofEvent } from '@/config/sentry';
|
||||
import { createSession, launchDidit } from '@/integrations/didit';
|
||||
import { createKycSession, launchKycVerification } from '@/integrations/kyc';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { navigationRef } from '@/navigation';
|
||||
import {
|
||||
@@ -316,7 +316,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
documentTypes: string[];
|
||||
}) => {
|
||||
currentCountryCode = countryCode;
|
||||
// Store country code early so it's available for Didit fallback flows
|
||||
// Store country code early so it's available for KYC fallback flows
|
||||
useMRZStore.getState().update({ countryCode });
|
||||
navigateIfReady('IDPicker', { countryCode, documentTypes });
|
||||
},
|
||||
@@ -346,23 +346,25 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
if (
|
||||
useErrorInjectionStore
|
||||
.getState()
|
||||
.shouldTrigger('didit_initialization')
|
||||
.shouldTrigger('kyc_initialization')
|
||||
) {
|
||||
console.log('[DEV] Injecting Didit initialization error');
|
||||
console.log('[DEV] Injecting KYC initialization error');
|
||||
throw new Error(
|
||||
'Injected Didit initialization error for testing',
|
||||
'Injected KYC initialization error for testing',
|
||||
);
|
||||
}
|
||||
|
||||
const session = await createSession();
|
||||
const result = await launchDidit(session.sessionToken);
|
||||
const session = await createKycSession();
|
||||
const result = await launchKycVerification(
|
||||
session.sessionToken,
|
||||
);
|
||||
|
||||
console.log('[Didit] Result:', JSON.stringify(result));
|
||||
console.log('[KYC] Result type:', result.type);
|
||||
|
||||
// User cancelled/dismissed without completing verification
|
||||
if (result.type === 'cancelled') {
|
||||
console.log(
|
||||
'[Didit] User cancelled or closed without completing',
|
||||
'[KYC] User cancelled or closed without completing',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -370,7 +372,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
// Dev-only: Check for injected verification error
|
||||
const shouldInjectVerificationError = useErrorInjectionStore
|
||||
.getState()
|
||||
.shouldTrigger('didit_verification');
|
||||
.shouldTrigger('kyc_verification');
|
||||
|
||||
// Actual error from provider
|
||||
if (
|
||||
@@ -378,7 +380,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
shouldInjectVerificationError
|
||||
) {
|
||||
if (shouldInjectVerificationError) {
|
||||
console.log('[DEV] Injecting Didit verification error');
|
||||
console.log('[DEV] Injecting KYC verification error');
|
||||
} else {
|
||||
const safeError = sanitizeErrorMessage(
|
||||
result.error?.message ||
|
||||
@@ -400,7 +402,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
// User completed verification
|
||||
// Navigate to KYC success screen
|
||||
console.log(
|
||||
'[Didit] Verification submitted, status:',
|
||||
'[KYC] Verification submitted, status:',
|
||||
result.session?.status,
|
||||
);
|
||||
if (navigationRef.isReady()) {
|
||||
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
loadPassportData,
|
||||
reStorePassportDataWithRightCSCA,
|
||||
} from '@/providers/passportDataProvider';
|
||||
import { STORAGE_NAME, useBackupMnemonic } from '@/services/cloud-backup';
|
||||
import { recoveryCopy } from '@/screens/account/recovery/recoveryCopy';
|
||||
import { useBackupMnemonic } from '@/services/cloud-backup';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import type { Mnemonic } from '@/types/mnemonic';
|
||||
|
||||
@@ -82,25 +83,29 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
if (!result) {
|
||||
console.warn('Failed to restore account');
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
setRestoring(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const passportData = await loadPassportData();
|
||||
const secret = getPrivateKeyFromMnemonic(mnemonic.phrase);
|
||||
|
||||
if (!passportData || !secret) {
|
||||
console.warn('Failed to load passport data or secret');
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_AUTH, {
|
||||
reason: 'no_passport_data_or_secret',
|
||||
if (!passportData) {
|
||||
console.warn(
|
||||
'Recovered secret but no local document data was found. Prompting the user to import their document again.',
|
||||
);
|
||||
if (isCloudRestore && !cloudBackupEnabled) {
|
||||
toggleCloudBackupEnabled();
|
||||
}
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_SUCCESS, {
|
||||
documentImportRequired: true,
|
||||
});
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
navigation.navigate('CountryPicker');
|
||||
setRestoring(false);
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const passportDataParsed = JSON.parse(passportData);
|
||||
const secret = getPrivateKeyFromMnemonic(mnemonic.phrase);
|
||||
|
||||
const { isRegistered, csca } =
|
||||
await isUserRegisteredWithAlternativeCSCA(
|
||||
@@ -140,7 +145,6 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
hasCSCA: !!csca,
|
||||
},
|
||||
);
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
setRestoring(false);
|
||||
return false;
|
||||
}
|
||||
@@ -241,16 +245,10 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
</ExpandableBottomLayout.TopSection>
|
||||
<ExpandableBottomLayout.BottomSection backgroundColor={white}>
|
||||
<YStack alignItems="center" gap="$2.5" paddingBottom="$2.5">
|
||||
<Title>Restore your Self account</Title>
|
||||
<Title>{recoveryCopy.choice.title}</Title>
|
||||
<Description>
|
||||
By continuing, you certify that this passport belongs to you and is
|
||||
not stolen or forged.{' '}
|
||||
{!biometricsAvailable && (
|
||||
<>
|
||||
Your device doesn't support biometrics or is disabled for apps
|
||||
and is required for cloud storage.
|
||||
</>
|
||||
)}
|
||||
{recoveryCopy.choice.description}{' '}
|
||||
{!biometricsAvailable && recoveryCopy.choice.noBiometrics}
|
||||
</Description>
|
||||
|
||||
<YStack gap="$2.5" width="100%" paddingTop="$6">
|
||||
@@ -275,12 +273,11 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
testID="button-from-teststorage"
|
||||
disabled={restoringFromCloud || !biometricsAvailable}
|
||||
>
|
||||
{restoringFromCloud ? 'Restoring' : 'Restore'} from {STORAGE_NAME}
|
||||
{restoringFromCloud ? '…' : ''}
|
||||
{recoveryCopy.choice.actions.cloud(restoringFromCloud)}
|
||||
</PrimaryButton>
|
||||
<XStack gap={64} alignItems="center" justifyContent="space-between">
|
||||
<Separator flexGrow={1} />
|
||||
<Caption>OR</Caption>
|
||||
<Caption>{recoveryCopy.choice.actions.or}</Caption>
|
||||
<Separator flexGrow={1} />
|
||||
</XStack>
|
||||
<SecondaryButton
|
||||
@@ -290,7 +287,9 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
|
||||
<XStack alignItems="center" justifyContent="center">
|
||||
<Keyboard height={25} width={40} color={slate500} />
|
||||
<View paddingLeft={12}>
|
||||
<Description>Enter recovery phrase</Description>
|
||||
<Description>
|
||||
{recoveryCopy.choice.actions.phrase}
|
||||
</Description>
|
||||
</View>
|
||||
</XStack>
|
||||
</SecondaryButton>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import RestoreAccountSvg from '@/assets/icons/restore_account.svg';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import { recoveryCopy } from '@/screens/account/recovery/recoveryCopy';
|
||||
|
||||
const AccountRecoveryScreen: React.FC = () => {
|
||||
const onRestoreAccountPress = useHapticNavigation('AccountRecoveryChoice');
|
||||
@@ -44,24 +45,21 @@ const AccountRecoveryScreen: React.FC = () => {
|
||||
</ExpandableBottomLayout.TopSection>
|
||||
<ExpandableBottomLayout.BottomSection backgroundColor={white}>
|
||||
<YStack alignItems="center" gap="$2.5" paddingBottom="$2.5">
|
||||
<Title>Restore your Self account</Title>
|
||||
<Description>
|
||||
By continuing, you certify that this passport belongs to you and is
|
||||
not stolen or forged.
|
||||
</Description>
|
||||
<Title>{recoveryCopy.entry.title}</Title>
|
||||
<Description>{recoveryCopy.entry.description}</Description>
|
||||
|
||||
<YStack gap="$2.5" width="100%" paddingTop="$6">
|
||||
<PrimaryButton
|
||||
trackEvent={BackupEvents.ACCOUNT_RECOVERY_STARTED}
|
||||
onPress={onRestoreAccountPress}
|
||||
>
|
||||
Restore my account
|
||||
{recoveryCopy.entry.actions.recover}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton
|
||||
trackEvent={BackupEvents.CREATE_NEW_ACCOUNT}
|
||||
onPress={onCreateAccountPress}
|
||||
>
|
||||
Create new account
|
||||
{recoveryCopy.entry.actions.register}
|
||||
</SecondaryButton>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
@@ -46,7 +46,7 @@ const DocumentDataNotFoundScreen: React.FC = () => {
|
||||
<ExpandableBottomLayout.Layout backgroundColor={black}>
|
||||
<ExpandableBottomLayout.TopSection backgroundColor={black}>
|
||||
<Title style={{ textAlign: 'center', color: white }}>
|
||||
✨ Are you new here?
|
||||
No document found
|
||||
</Title>
|
||||
<Description
|
||||
style={{
|
||||
@@ -55,7 +55,8 @@ const DocumentDataNotFoundScreen: React.FC = () => {
|
||||
color: slate200,
|
||||
}}
|
||||
>
|
||||
It seems like you need to go through the registration flow first.
|
||||
We couldn't find a registered document on this device. Register your
|
||||
ID to continue.
|
||||
</Description>
|
||||
</ExpandableBottomLayout.TopSection>
|
||||
<ExpandableBottomLayout.BottomSection
|
||||
@@ -63,7 +64,7 @@ const DocumentDataNotFoundScreen: React.FC = () => {
|
||||
height={150}
|
||||
backgroundColor={white}
|
||||
>
|
||||
<PrimaryButton onPress={onPress}>Go to Registration</PrimaryButton>
|
||||
<PrimaryButton onPress={onPress}>Continue</PrimaryButton>
|
||||
</ExpandableBottomLayout.BottomSection>
|
||||
</ExpandableBottomLayout.Layout>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
loadPassportData,
|
||||
reStorePassportDataWithRightCSCA,
|
||||
} from '@/providers/passportDataProvider';
|
||||
import { recoveryCopy } from '@/screens/account/recovery/recoveryCopy';
|
||||
|
||||
const RecoverWithPhraseScreen: React.FC = () => {
|
||||
const navigation =
|
||||
@@ -70,26 +71,22 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_AUTH, {
|
||||
mnemonicLength: slimMnemonic.split(' ').length,
|
||||
});
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
setRestoring(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const passportData = await loadPassportData();
|
||||
const secret = getPrivateKeyFromMnemonic(slimMnemonic);
|
||||
|
||||
if (!passportData || !secret) {
|
||||
if (!passportData) {
|
||||
console.warn(
|
||||
'No passport data found on device. Please scan or import your document.',
|
||||
'Recovered secret but no local document data was found. Prompting the user to import their document again.',
|
||||
);
|
||||
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_AUTH, {
|
||||
reason: 'no_passport_data',
|
||||
});
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
navigation.navigate('CountryPicker');
|
||||
setRestoring(false);
|
||||
return;
|
||||
}
|
||||
const passportDataParsed = JSON.parse(passportData);
|
||||
const secret = getPrivateKeyFromMnemonic(slimMnemonic);
|
||||
|
||||
const { isRegistered, csca } = await isUserRegisteredWithAlternativeCSCA(
|
||||
passportDataParsed,
|
||||
@@ -124,7 +121,6 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
reason: 'document_not_registered',
|
||||
hasCSCA: !!csca,
|
||||
});
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
setRestoring(false);
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +139,6 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
error: error instanceof Error ? error.message : 'unknown',
|
||||
});
|
||||
setRestoring(false);
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
}
|
||||
}, [
|
||||
mnemonic,
|
||||
@@ -162,8 +157,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
style={styles.layout}
|
||||
>
|
||||
<Description style={{ color: slate300 }}>
|
||||
Your recovery phrase has 24 words. Enter the words in the correct order,
|
||||
separated by spaces.
|
||||
{recoveryCopy.phrase.instructions}
|
||||
</Description>
|
||||
<View width="100%" position="relative">
|
||||
<TextArea
|
||||
@@ -172,7 +166,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
color={slate400}
|
||||
borderWidth="$1"
|
||||
borderRadius="$5"
|
||||
placeholder="Enter or paste your recovery phrase"
|
||||
placeholder={recoveryCopy.phrase.placeholder}
|
||||
width="100%"
|
||||
minHeight={230}
|
||||
verticalAlign="top"
|
||||
@@ -193,7 +187,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
onPress={onPaste}
|
||||
>
|
||||
<Paste color={white} height={20} width={20} />
|
||||
<Text style={styles.pasteText}>PASTE</Text>
|
||||
<Text style={styles.pasteText}>{recoveryCopy.phrase.paste}</Text>
|
||||
</XStack>
|
||||
</View>
|
||||
|
||||
@@ -201,7 +195,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
|
||||
disabled={!mnemonic || restoring}
|
||||
onPress={restoreAccount}
|
||||
>
|
||||
Continue
|
||||
{recoveryCopy.phrase.submit}
|
||||
</SecondaryButton>
|
||||
</YStack>
|
||||
);
|
||||
|
||||
41
app/src/screens/account/recovery/recoveryCopy.ts
Normal file
41
app/src/screens/account/recovery/recoveryCopy.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { STORAGE_NAME } from '@/services/cloud-backup';
|
||||
|
||||
export const recoveryCopy = {
|
||||
/** First screen: recover vs register decision */
|
||||
entry: {
|
||||
title: 'Recover your Self account',
|
||||
description:
|
||||
'If you already registered a document, recover your existing account first. Re-scanning your passport without recovering will create a new account and your previous registration will be lost.',
|
||||
actions: {
|
||||
recover: 'Recover my account',
|
||||
register: 'Register a new ID instead',
|
||||
},
|
||||
},
|
||||
|
||||
/** Second screen: pick a recovery method */
|
||||
choice: {
|
||||
title: 'Recover your Self account',
|
||||
description: 'Choose how you want to recover your account.',
|
||||
noBiometrics:
|
||||
'Cloud recovery requires biometrics, which are unavailable on this device. You can still recover using your recovery phrase.',
|
||||
actions: {
|
||||
cloud: (restoring: boolean) =>
|
||||
`${restoring ? 'Recovering' : 'Recover'} from ${STORAGE_NAME}${restoring ? '\u2026' : ''}`,
|
||||
or: 'OR',
|
||||
phrase: 'Enter your recovery phrase',
|
||||
},
|
||||
},
|
||||
|
||||
/** Recovery phrase entry screen */
|
||||
phrase: {
|
||||
instructions:
|
||||
'Your recovery phrase has 24 words. Enter the words in the correct order, separated by spaces.',
|
||||
placeholder: 'Enter or paste your recovery phrase',
|
||||
paste: 'PASTE',
|
||||
submit: 'Continue',
|
||||
},
|
||||
} as const;
|
||||
@@ -40,7 +40,6 @@ type NextScreen = keyof Pick<RootStackParamList, 'SaveRecoveryPhrase'>;
|
||||
type CloudBackupScreenProps = StaticScreenProps<
|
||||
| {
|
||||
nextScreen?: NextScreen;
|
||||
returnToScreen?: 'Points';
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
@@ -175,10 +174,6 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
await upload(storedMnemonic.data);
|
||||
toggleCloudBackupEnabled();
|
||||
trackEvent(BackupEvents.CLOUD_BACKUP_ENABLED_DONE);
|
||||
|
||||
if (params?.returnToScreen) {
|
||||
navigation.navigate(params.returnToScreen);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('iCloud backup error', error);
|
||||
} finally {
|
||||
@@ -191,8 +186,6 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
upload,
|
||||
toggleCloudBackupEnabled,
|
||||
trackEvent,
|
||||
navigation,
|
||||
params,
|
||||
selfClient,
|
||||
showNoRegisteredAccountModal,
|
||||
]);
|
||||
@@ -226,9 +219,6 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
// 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');
|
||||
@@ -238,9 +228,7 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
// error.message === 'already_backed_up'
|
||||
// ) {
|
||||
// console.log('Already backed up with Turnkey');
|
||||
// if (params?.returnToScreen) {
|
||||
// navigation.navigate(params.returnToScreen);
|
||||
// } else if (params?.nextScreen) {
|
||||
// if (params?.nextScreen) {
|
||||
// navigation.navigate(params.nextScreen);
|
||||
// } else {
|
||||
// showAlreadyBackedUpModal();
|
||||
|
||||
@@ -42,9 +42,9 @@ import {
|
||||
telegramUrl,
|
||||
xUrl,
|
||||
} from '@/consts/links';
|
||||
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
|
||||
import { impactLight } from '@/integrations/haptics';
|
||||
import { usePassport } from '@/providers/passportDataProvider';
|
||||
import { openSupportForm } from '@/services/support';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
|
||||
@@ -58,6 +58,7 @@ interface MenuButtonProps extends PropsWithChildren {
|
||||
interface SocialButtonProps {
|
||||
Icon: React.FC<SvgProps>;
|
||||
href: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
// Avoid importing RootStackParamList; we only need string route names plus a few literals
|
||||
@@ -139,8 +140,12 @@ const MenuButton: React.FC<MenuButtonProps> = ({ children, Icon, onPress }) => (
|
||||
</Button>
|
||||
);
|
||||
|
||||
const SocialButton: React.FC<SocialButtonProps> = ({ Icon, href }) => {
|
||||
const onPress = useCallback(() => {
|
||||
const SocialButton: React.FC<SocialButtonProps> = ({
|
||||
Icon,
|
||||
href,
|
||||
onPress: customOnPress,
|
||||
}) => {
|
||||
const defaultOnPress = useCallback(() => {
|
||||
impactLight();
|
||||
Linking.openURL(href);
|
||||
}, [href]);
|
||||
@@ -149,7 +154,7 @@ const SocialButton: React.FC<SocialButtonProps> = ({ Icon, href }) => {
|
||||
<Button
|
||||
unstyled
|
||||
hitSlop={8}
|
||||
onPress={onPress}
|
||||
onPress={customOnPress ?? defaultOnPress}
|
||||
icon={<Icon height={32} width={32} color={warmCream} />}
|
||||
/>
|
||||
);
|
||||
@@ -157,9 +162,14 @@ const SocialButton: React.FC<SocialButtonProps> = ({ Icon, href }) => {
|
||||
|
||||
const SettingsScreen: React.FC = () => {
|
||||
const { isDevMode, setDevModeOn } = useSettingStore();
|
||||
const openSupportForm = useOpenSupportForm();
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<MinimalRootStackParamList>>();
|
||||
const { loadDocumentCatalog } = usePassport();
|
||||
const openSelfWebsite = useCallback(() => {
|
||||
impactLight();
|
||||
navigation.navigate('WebView', { url: selfUrl, title: 'Self' });
|
||||
}, [navigation]);
|
||||
const [hasRealDocument, setHasRealDocument] = useState<boolean | null>(null);
|
||||
|
||||
const refreshDocumentAvailability = useCallback(async () => {
|
||||
@@ -221,16 +231,7 @@ const SettingsScreen: React.FC = () => {
|
||||
break;
|
||||
|
||||
case 'support_form':
|
||||
try {
|
||||
await openSupportForm();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'SettingsScreen: failed to open support form:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
// Error is already handled and displayed to user in openSupportForm,
|
||||
// but we log here for debugging purposes
|
||||
}
|
||||
openSupportForm();
|
||||
break;
|
||||
|
||||
case 'ManageDocuments':
|
||||
@@ -243,7 +244,7 @@ const SettingsScreen: React.FC = () => {
|
||||
}
|
||||
};
|
||||
},
|
||||
[navigation],
|
||||
[navigation, openSupportForm],
|
||||
);
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
return (
|
||||
@@ -306,7 +307,12 @@ const SettingsScreen: React.FC = () => {
|
||||
</Button>
|
||||
<XStack gap={32}>
|
||||
{social.map(([Icon, href], i) => (
|
||||
<SocialButton key={i} Icon={Icon} href={href} />
|
||||
<SocialButton
|
||||
key={i}
|
||||
Icon={Icon}
|
||||
href={href}
|
||||
onPress={href === selfUrl ? openSelfWebsite : undefined}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
<BodyText style={{ color: warmCream, fontSize: 15 }}>
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 { 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 {
|
||||
black,
|
||||
slate700,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot, dinotBold } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
|
||||
import GratificationBg from '@/assets/images/gratification_bg.svg';
|
||||
import SelfLogo from '@/assets/logos/self.svg';
|
||||
import { SystemBars } from '@/components/SystemBars';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
|
||||
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}>
|
||||
<SelfLogo 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%',
|
||||
},
|
||||
});
|
||||
@@ -76,8 +76,8 @@ const ReferralScreen: React.FC = () => {
|
||||
gap={42}
|
||||
>
|
||||
<ReferralInfo
|
||||
title="Invite friends and earn points"
|
||||
description="When friends install Self and use your referral link you'll both receive exclusive points."
|
||||
title="Invite friends to Self"
|
||||
description="When friends install Self and use your referral link you'll both get rewarded."
|
||||
learnMoreText="Learn more"
|
||||
/>
|
||||
|
||||
|
||||
@@ -20,18 +20,32 @@ import type { RootStackParamList } from '@/navigation';
|
||||
import {
|
||||
getAndClearQueuedUrl,
|
||||
handleUrl,
|
||||
peekQueuedUrl,
|
||||
setDeeplinkParentScreen,
|
||||
} from '@/navigation/deeplinks';
|
||||
import { migrateToSecureKeychain, useAuth } from '@/providers/authProvider';
|
||||
import {
|
||||
hasSecretStored,
|
||||
migrateToSecureKeychain,
|
||||
useAuth,
|
||||
} from '@/providers/authProvider';
|
||||
import {
|
||||
checkAndUpdateRegistrationStates,
|
||||
checkIfAnyDocumentsNeedMigration,
|
||||
initializeNativeModules,
|
||||
migrateFromLegacyStorage,
|
||||
} from '@/providers/passportDataProvider';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import {
|
||||
getStartupNavigationTarget,
|
||||
hasStartupRecoverySignal,
|
||||
} from '@/screens/app/startupRouting';
|
||||
import {
|
||||
useSettingStore,
|
||||
waitForSettingStoreHydration,
|
||||
} from '@/stores/settingStore';
|
||||
import { IS_DEV_MODE } from '@/utils/devUtils';
|
||||
|
||||
const INIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
const SplashScreen: React.FC = ({}) => {
|
||||
const selfClient = useSelfClient();
|
||||
const navigation =
|
||||
@@ -44,6 +58,7 @@ const SplashScreen: React.FC = ({}) => {
|
||||
);
|
||||
const [queuedDeepLink, setQueuedDeepLink] = useState<string | null>(null);
|
||||
const dataLoadInitiatedRef = useRef(false);
|
||||
const settledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataLoadInitiatedRef.current) {
|
||||
@@ -56,9 +71,14 @@ const SplashScreen: React.FC = ({}) => {
|
||||
});
|
||||
|
||||
const loadDataAndDetermineNextScreen = async () => {
|
||||
const startTime = Date.now();
|
||||
const elapsed = () => `${Date.now() - startTime}ms`;
|
||||
|
||||
try {
|
||||
// Initialize native modules first, before any data operations
|
||||
const modulesReady = await initializeNativeModules();
|
||||
console.log(
|
||||
`SplashScreen: initializeNativeModules complete (${elapsed()})`,
|
||||
);
|
||||
if (!modulesReady) {
|
||||
console.warn(
|
||||
'Native modules not ready, proceeding with limited functionality',
|
||||
@@ -66,26 +86,60 @@ const SplashScreen: React.FC = ({}) => {
|
||||
}
|
||||
|
||||
await migrateFromLegacyStorage();
|
||||
console.log(
|
||||
`SplashScreen: migrateFromLegacyStorage complete (${elapsed()})`,
|
||||
);
|
||||
await waitForSettingStoreHydration();
|
||||
|
||||
const needsMigration = await checkIfAnyDocumentsNeedMigration();
|
||||
console.log(
|
||||
`SplashScreen: checkIfAnyDocumentsNeedMigration complete (${elapsed()})`,
|
||||
);
|
||||
if (needsMigration) {
|
||||
await checkAndUpdateRegistrationStates(selfClient);
|
||||
console.log(
|
||||
`SplashScreen: checkAndUpdateRegistrationStates complete (${elapsed()})`,
|
||||
);
|
||||
}
|
||||
|
||||
await hasAnyValidRegisteredDocument(selfClient);
|
||||
const parentScreen = 'Home';
|
||||
const [hasRegisteredDocument, hasStoredSecret] = await Promise.all([
|
||||
hasAnyValidRegisteredDocument(selfClient),
|
||||
hasSecretStored(),
|
||||
]);
|
||||
console.log(
|
||||
`SplashScreen: hasAnyValidRegisteredDocument complete (${elapsed()})`,
|
||||
);
|
||||
const settings = useSettingStore.getState();
|
||||
const startupTarget = getStartupNavigationTarget({
|
||||
hasPrivacyNoteBeenDismissed: settings.hasPrivacyNoteBeenDismissed,
|
||||
hasRecoverySignal: hasStartupRecoverySignal({
|
||||
cloudBackupEnabled: settings.cloudBackupEnabled,
|
||||
hasViewedRecoveryPhrase: settings.hasViewedRecoveryPhrase,
|
||||
pointsAddress: settings.pointsAddress,
|
||||
}),
|
||||
hasSecretStored: hasStoredSecret,
|
||||
hasValidRegisteredDocument: hasRegisteredDocument,
|
||||
});
|
||||
const parentScreen = startupTarget.route;
|
||||
|
||||
// Migrate keychain to secure storage with biometric protection
|
||||
try {
|
||||
await migrateToSecureKeychain();
|
||||
console.log(
|
||||
`SplashScreen: migrateToSecureKeychain complete (${elapsed()})`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Keychain migration failed, continuing:', error);
|
||||
}
|
||||
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
|
||||
setDeeplinkParentScreen(parentScreen);
|
||||
|
||||
const queuedUrl = getAndClearQueuedUrl();
|
||||
if (queuedUrl) {
|
||||
const queuedUrl = startupTarget.allowQueuedDeepLink
|
||||
? getAndClearQueuedUrl()
|
||||
: peekQueuedUrl();
|
||||
if (queuedUrl && startupTarget.allowQueuedDeepLink) {
|
||||
if (IS_DEV_MODE) {
|
||||
console.log('Processing queued deeplink:', queuedUrl);
|
||||
}
|
||||
@@ -94,13 +148,40 @@ const SplashScreen: React.FC = ({}) => {
|
||||
setNextScreen(parentScreen);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in SplashScreen data loading: ${error}`);
|
||||
setDeeplinkParentScreen('Home');
|
||||
setNextScreen('Home');
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
|
||||
console.error(
|
||||
`SplashScreen: initialization failed (${elapsed()})`,
|
||||
error,
|
||||
);
|
||||
const fallbackScreen = useSettingStore.getState()
|
||||
.hasPrivacyNoteBeenDismissed
|
||||
? 'Home'
|
||||
: 'Disclaimer';
|
||||
setDeeplinkParentScreen(fallbackScreen);
|
||||
setNextScreen(fallbackScreen);
|
||||
}
|
||||
};
|
||||
|
||||
loadDataAndDetermineNextScreen();
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (settledRef.current) return;
|
||||
settledRef.current = true;
|
||||
|
||||
console.error(
|
||||
`SplashScreen: initialization timed out after ${INIT_TIMEOUT_MS}ms`,
|
||||
);
|
||||
const fallbackScreen = useSettingStore.getState()
|
||||
.hasPrivacyNoteBeenDismissed
|
||||
? 'Home'
|
||||
: 'Disclaimer';
|
||||
setDeeplinkParentScreen(fallbackScreen);
|
||||
setNextScreen(fallbackScreen);
|
||||
}, INIT_TIMEOUT_MS);
|
||||
|
||||
loadDataAndDetermineNextScreen().finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
}, [checkBiometricsAvailable, setBiometricsAvailable, selfClient]);
|
||||
|
||||
|
||||
61
app/src/screens/app/startupRouting.ts
Normal file
61
app/src/screens/app/startupRouting.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
|
||||
export type StartupNavigationTarget = {
|
||||
allowQueuedDeepLink: boolean;
|
||||
route: keyof RootStackParamList;
|
||||
};
|
||||
|
||||
type StartupRoutingParams = {
|
||||
hasPrivacyNoteBeenDismissed: boolean;
|
||||
hasRecoverySignal: boolean;
|
||||
hasSecretStored: boolean;
|
||||
hasValidRegisteredDocument: boolean;
|
||||
};
|
||||
|
||||
export function getStartupNavigationTarget(
|
||||
params: StartupRoutingParams,
|
||||
): StartupNavigationTarget {
|
||||
const {
|
||||
hasPrivacyNoteBeenDismissed,
|
||||
hasRecoverySignal,
|
||||
hasSecretStored,
|
||||
hasValidRegisteredDocument,
|
||||
} = params;
|
||||
|
||||
if (!hasSecretStored) {
|
||||
if (hasValidRegisteredDocument || hasRecoverySignal) {
|
||||
return {
|
||||
allowQueuedDeepLink: false,
|
||||
route: 'AccountRecoveryChoice',
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasPrivacyNoteBeenDismissed) {
|
||||
return {
|
||||
allowQueuedDeepLink: false,
|
||||
route: 'Disclaimer',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowQueuedDeepLink: true,
|
||||
route: 'Home',
|
||||
};
|
||||
}
|
||||
|
||||
export function hasStartupRecoverySignal(params: {
|
||||
cloudBackupEnabled: boolean;
|
||||
hasViewedRecoveryPhrase: boolean;
|
||||
pointsAddress: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
params.cloudBackupEnabled ||
|
||||
params.hasViewedRecoveryPhrase ||
|
||||
params.pointsAddress !== null
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
|
||||
import WarningIcon from '@/assets/images/warning.svg';
|
||||
import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import { useKycLauncher } from '@/hooks/useKycLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
@@ -70,7 +70,7 @@ const AadhaarUploadErrorScreen: React.FC = () => {
|
||||
const errorType = route.params?.errorType || 'general';
|
||||
const { title, description } = getErrorMessages(errorType);
|
||||
|
||||
const { launchDiditVerification, isLoading: isRetrying } = useDiditLauncher({
|
||||
const { launchKycVerification, isLoading: isRetrying } = useKycLauncher({
|
||||
countryCode: 'IND',
|
||||
errorSource: 'mrz_scan_failed', // Use a compatible error source
|
||||
onCancel: () => {
|
||||
@@ -93,8 +93,8 @@ const AadhaarUploadErrorScreen: React.FC = () => {
|
||||
|
||||
const handleTryAlternative = useCallback(async () => {
|
||||
trackEvent(AadhaarEvents.HELP_BUTTON_PRESSED, { errorType });
|
||||
await launchDiditVerification();
|
||||
}, [errorType, launchDiditVerification, trackEvent]);
|
||||
await launchKycVerification();
|
||||
}, [errorType, launchKycVerification, trackEvent]);
|
||||
|
||||
return (
|
||||
<YStack flex={1} backgroundColor={slate100}>
|
||||
|
||||
@@ -16,8 +16,8 @@ import QrScan from '@/assets/icons/qr_scan.svg';
|
||||
import Star from '@/assets/icons/star.svg';
|
||||
import type { TipProps } from '@/components/Tips';
|
||||
import Tips from '@/components/Tips';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { useKycLauncher } from '@/hooks/useKycLauncher';
|
||||
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
|
||||
import { flush as flushAnalytics } from '@/services/analytics';
|
||||
|
||||
@@ -54,7 +54,7 @@ const DocumentCameraTroubleScreen: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
const { useMRZStore } = selfClient;
|
||||
const { countryCode } = useMRZStore();
|
||||
const { launchDiditVerification, isLoading } = useDiditLauncher({
|
||||
const { launchKycVerification, isLoading } = useKycLauncher({
|
||||
countryCode,
|
||||
errorSource: 'mrz_scan_failed',
|
||||
});
|
||||
@@ -88,7 +88,7 @@ const DocumentCameraTroubleScreen: React.FC = () => {
|
||||
</Caption>
|
||||
|
||||
<SecondaryButton
|
||||
onPress={launchDiditVerification}
|
||||
onPress={launchKycVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
|
||||
@@ -57,6 +57,7 @@ import { logNFCEvent } from '@/config/sentry';
|
||||
import { useErrorInjection } from '@/hooks/useErrorInjection';
|
||||
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
|
||||
import {
|
||||
buttonTap,
|
||||
feedbackSuccess,
|
||||
@@ -75,7 +76,6 @@ import {
|
||||
trackNfcEvent,
|
||||
} from '@/services/analytics';
|
||||
import {
|
||||
openSupportForm,
|
||||
SUPPORT_FORM_BUTTON_TEXT,
|
||||
SUPPORT_FORM_MESSAGE,
|
||||
} from '@/services/support';
|
||||
@@ -103,6 +103,7 @@ type DocumentNFCScanRoute = RouteProp<
|
||||
const DocumentNFCScanScreen: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
const { trackEvent, useMRZStore } = selfClient;
|
||||
const openSupportForm = useOpenSupportForm();
|
||||
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
@@ -181,7 +182,7 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
|
||||
const onReportIssue = useCallback(() => {
|
||||
openSupportForm();
|
||||
}, []);
|
||||
}, [openSupportForm]);
|
||||
|
||||
const openErrorModal = useCallback(
|
||||
(message: string) => {
|
||||
|
||||
@@ -14,13 +14,14 @@ import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import type { TipProps } from '@/components/Tips';
|
||||
import Tips from '@/components/Tips';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { useKycLauncher } from '@/hooks/useKycLauncher';
|
||||
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
|
||||
import { selectionChange } from '@/integrations/haptics';
|
||||
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
|
||||
import { flushAllAnalytics } from '@/services/analytics';
|
||||
import { openSupportForm, SUPPORT_FORM_BUTTON_TEXT } from '@/services/support';
|
||||
import { SUPPORT_FORM_BUTTON_TEXT } from '@/services/support';
|
||||
|
||||
const tips: TipProps[] = [
|
||||
{
|
||||
@@ -50,6 +51,7 @@ const tips: TipProps[] = [
|
||||
];
|
||||
|
||||
const DocumentNFCTroubleScreen: React.FC = () => {
|
||||
const openSupportForm = useOpenSupportForm();
|
||||
const navigation = useNavigation();
|
||||
const handleDismiss = useCallback(() => {
|
||||
selectionChange();
|
||||
@@ -61,7 +63,7 @@ const DocumentNFCTroubleScreen: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
const { useMRZStore } = selfClient;
|
||||
const { countryCode } = useMRZStore();
|
||||
const { launchDiditVerification, isLoading } = useDiditLauncher({
|
||||
const { launchKycVerification, isLoading } = useKycLauncher({
|
||||
countryCode,
|
||||
errorSource: 'nfc_scan_failed',
|
||||
});
|
||||
@@ -96,7 +98,7 @@ const DocumentNFCTroubleScreen: React.FC = () => {
|
||||
</SecondaryButton>
|
||||
|
||||
<SecondaryButton
|
||||
onPress={launchDiditVerification}
|
||||
onPress={launchKycVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
|
||||
import WarningIcon from '@/assets/images/warning.svg';
|
||||
import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import { useKycLauncher } from '@/hooks/useKycLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
@@ -66,7 +66,7 @@ const RegistrationFallbackMRZScreen: React.FC = () => {
|
||||
|
||||
const headerTitle = getHeaderTitle(documentType);
|
||||
|
||||
const { launchDiditVerification, isLoading: isRetrying } = useDiditLauncher({
|
||||
const { launchKycVerification, isLoading: isRetrying } = useKycLauncher({
|
||||
countryCode,
|
||||
errorSource: 'mrz_scan_failed',
|
||||
onCancel: () => {
|
||||
@@ -87,8 +87,8 @@ const RegistrationFallbackMRZScreen: React.FC = () => {
|
||||
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', {
|
||||
errorSource: 'mrz_scan_failed',
|
||||
});
|
||||
await launchDiditVerification();
|
||||
}, [launchDiditVerification, trackEvent]);
|
||||
await launchKycVerification();
|
||||
}, [launchKycVerification, trackEvent]);
|
||||
|
||||
const handleRetryOriginal = useCallback(() => {
|
||||
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
|
||||
import WarningIcon from '@/assets/images/warning.svg';
|
||||
import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { useDiditLauncher } from '@/hooks/useDiditLauncher';
|
||||
import { useKycLauncher } from '@/hooks/useKycLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
@@ -67,7 +67,7 @@ const RegistrationFallbackNFCScreen: React.FC = () => {
|
||||
|
||||
const headerTitle = getHeaderTitle(documentType);
|
||||
|
||||
const { launchDiditVerification, isLoading: isRetrying } = useDiditLauncher({
|
||||
const { launchKycVerification, isLoading: isRetrying } = useKycLauncher({
|
||||
countryCode,
|
||||
errorSource: 'nfc_scan_failed',
|
||||
onCancel: () => {
|
||||
@@ -93,8 +93,8 @@ const RegistrationFallbackNFCScreen: React.FC = () => {
|
||||
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', {
|
||||
errorSource: 'nfc_scan_failed',
|
||||
});
|
||||
await launchDiditVerification();
|
||||
}, [launchDiditVerification, trackEvent]);
|
||||
await launchKycVerification();
|
||||
}, [launchKycVerification, trackEvent]);
|
||||
|
||||
const handleRetryOriginal = useCallback(() => {
|
||||
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {
|
||||
|
||||
@@ -25,8 +25,8 @@ import { advercase, dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
import EPassportLogo from '@/assets/icons/epassport_logo.svg';
|
||||
import { DocumentFlowNavBar } from '@/components/navbar/DocumentFlowNavBar';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { createSession, launchDidit } from '@/integrations/didit';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import { createKycSession, launchKycVerification } from '@/integrations/kyc';
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useFeedback } from '@/providers/feedbackProvider';
|
||||
@@ -58,8 +58,8 @@ const LogoConfirmationScreen: React.FC = () => {
|
||||
buttonText: 'Proceed with an external verifier',
|
||||
onButtonPress: async () => {
|
||||
try {
|
||||
const session = await createSession();
|
||||
const result = await launchDidit(session.sessionToken);
|
||||
const session = await createKycSession();
|
||||
const result = await launchKycVerification(session.sessionToken);
|
||||
|
||||
// User cancelled/dismissed without completing verification
|
||||
if (result.type === 'cancelled') {
|
||||
@@ -69,7 +69,7 @@ const LogoConfirmationScreen: React.FC = () => {
|
||||
// Verification failed (provider error/rejection)
|
||||
if (result.type === 'failed') {
|
||||
console.error(
|
||||
'Didit verification failed:',
|
||||
'KYC verification failed:',
|
||||
result.error?.type ?? 'unknown',
|
||||
);
|
||||
navigation.navigate('KycFailure', {
|
||||
@@ -82,7 +82,7 @@ const LogoConfirmationScreen: React.FC = () => {
|
||||
// Verification succeeded - navigate to KycSuccessScreen
|
||||
navigation.navigate('KycSuccess', { sessionId: session.sessionId });
|
||||
} catch {
|
||||
console.error('Error launching Didit verification');
|
||||
console.error('Error launching KYC verification');
|
||||
showModal({
|
||||
titleText: 'Error',
|
||||
bodyText: 'Unable to start verification. Please try again.',
|
||||
|
||||
@@ -4,15 +4,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Pressable } from 'react-native';
|
||||
import {
|
||||
Button,
|
||||
ScrollView,
|
||||
Spinner,
|
||||
Text,
|
||||
View,
|
||||
XStack,
|
||||
YStack,
|
||||
} from 'tamagui';
|
||||
import { ScrollView, Spinner, YStack } from 'tamagui';
|
||||
import {
|
||||
useFocusEffect,
|
||||
useIsFocused,
|
||||
@@ -25,20 +17,10 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { DocumentCatalog, IDDocument } from '@selfxyz/common/utils/types';
|
||||
import type { DocumentMetadata } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import {
|
||||
DocumentEvents,
|
||||
PointEvents,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import {
|
||||
black,
|
||||
blue600,
|
||||
slate50,
|
||||
slate300,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
import { DocumentEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import { black, slate50 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
|
||||
import LogoInversed from '@/assets/images/logo_inversed.svg';
|
||||
import EmptyIdCard from '@/components/homescreen/EmptyIdCard';
|
||||
import ExpiredIdCard from '@/components/homescreen/ExpiredIdCard';
|
||||
import IdCardLayout from '@/components/homescreen/IdCard';
|
||||
@@ -46,9 +28,8 @@ import PendingIdCard from '@/components/homescreen/PendingIdCard';
|
||||
import UnregisteredIdCard from '@/components/homescreen/UnregisteredIdCard';
|
||||
import { useAppUpdates } from '@/hooks/useAppUpdates';
|
||||
import useConnectionModal from '@/hooks/useConnectionModal';
|
||||
import { useEarnPointsFlow } from '@/hooks/useEarnPointsFlow';
|
||||
import { usePoints } from '@/hooks/usePoints';
|
||||
import { useReferralConfirmation } from '@/hooks/useReferralConfirmation';
|
||||
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
|
||||
import { useTestReferralFlow } from '@/hooks/useTestReferralFlow';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { usePassport } from '@/providers/passportDataProvider';
|
||||
@@ -96,8 +77,6 @@ const HomeScreen: React.FC = () => {
|
||||
v => v.status === 'pending' || v.status === 'processing',
|
||||
);
|
||||
|
||||
const { amount: selfPoints } = usePoints();
|
||||
|
||||
// DEV MODE: Test referral flow hook (only show alert when screen is focused)
|
||||
const isFocused = useIsFocused();
|
||||
const route = useRoute();
|
||||
@@ -191,28 +170,28 @@ const HomeScreen: React.FC = () => {
|
||||
// Calculate bottom padding to prevent button bleeding into system navigation
|
||||
const bottomPadding = useSafeBottomPadding(20);
|
||||
|
||||
// Create a stable reference to avoid hook dependency issues
|
||||
const onEarnPointsPressRef = useRef<
|
||||
((skipReferralFlow?: boolean) => Promise<void>) | null
|
||||
>(null);
|
||||
const { registerReferral } = useRegisterReferral();
|
||||
|
||||
const { isReferralConfirmed } = useReferralConfirmation({
|
||||
const handleReferralConfirmed = useCallback(async () => {
|
||||
if (!referrer) {
|
||||
return;
|
||||
}
|
||||
const store = useUserStore.getState();
|
||||
if (!store.isReferrerRegistered(referrer)) {
|
||||
const result = await registerReferral(referrer);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
store.markReferrerAsRegistered(referrer);
|
||||
}
|
||||
store.clearDeepLinkReferrer();
|
||||
}, [referrer, registerReferral]);
|
||||
|
||||
useReferralConfirmation({
|
||||
hasReferrer,
|
||||
onConfirmed: () => {
|
||||
onEarnPointsPressRef.current?.(false);
|
||||
},
|
||||
onConfirmed: handleReferralConfirmed,
|
||||
});
|
||||
|
||||
const { onEarnPointsPress } = useEarnPointsFlow({
|
||||
hasReferrer,
|
||||
isReferralConfirmed,
|
||||
});
|
||||
|
||||
// Update the ref whenever onEarnPointsPress changes
|
||||
useEffect(() => {
|
||||
onEarnPointsPressRef.current = onEarnPointsPress;
|
||||
}, [onEarnPointsPress]);
|
||||
|
||||
const handleDocumentPress = useCallback(
|
||||
(metadata: DocumentMetadata, documentData: IDDocument) => {
|
||||
selfClient.trackEvent(DocumentEvents.DOCUMENT_SELECTED, {
|
||||
@@ -344,86 +323,6 @@ const HomeScreen: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<YStack
|
||||
elevation={8}
|
||||
backgroundColor="white"
|
||||
width="100%"
|
||||
paddingTop={20}
|
||||
paddingHorizontal={20}
|
||||
paddingBottom={bottomPadding}
|
||||
borderTopLeftRadius={18}
|
||||
borderTopRightRadius={18}
|
||||
style={{
|
||||
// Matches: box-shadow: 0 -6px 14px 0 rgba(0, 0, 0, 0.05);
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 14,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
<XStack marginBottom={32} gap={22}>
|
||||
<View
|
||||
width={68}
|
||||
height={68}
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor={slate300}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<LogoInversed width={33} height={33} />
|
||||
</View>
|
||||
<YStack gap={4}>
|
||||
<Text
|
||||
color={black}
|
||||
fontFamily={dinot}
|
||||
fontSize={20}
|
||||
fontStyle="normal"
|
||||
fontWeight="500"
|
||||
lineHeight={22}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{`${selfPoints} SELF POINTS`}
|
||||
</Text>
|
||||
<Text
|
||||
color={black}
|
||||
width="60%"
|
||||
fontFamily={dinot}
|
||||
fontSize={16}
|
||||
fontStyle="normal"
|
||||
fontWeight="500"
|
||||
lineHeight={22}
|
||||
>
|
||||
Earn points by referring friends, disclosing proof requests, and
|
||||
more.
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<Button
|
||||
backgroundColor="white"
|
||||
paddingHorizontal={22}
|
||||
paddingVertical={24}
|
||||
borderRadius={5}
|
||||
borderWidth={1}
|
||||
borderColor={slate300}
|
||||
testID="earn-points-button"
|
||||
onPress={() => {
|
||||
selfClient.trackEvent(PointEvents.HOME_POINT_EARN_POINTS_OPENED);
|
||||
|
||||
onEarnPointsPress(true);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
color={blue600}
|
||||
textAlign="center"
|
||||
fontFamily={dinot}
|
||||
fontSize={18}
|
||||
height={22}
|
||||
>
|
||||
Earn points
|
||||
</Text>
|
||||
</Button>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Image, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { ScrollView, Text, View, XStack, YStack } from 'tamagui';
|
||||
import type { StaticScreenProps } from '@react-navigation/native';
|
||||
|
||||
import { PrimaryButton, Title } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import {
|
||||
black,
|
||||
slate50,
|
||||
slate500,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
|
||||
import CheckmarkSquareIcon from '@/assets/icons/checkmark_square.svg';
|
||||
import CloudBackupIcon from '@/assets/icons/cloud_backup.svg';
|
||||
import PushNotificationsIcon from '@/assets/icons/push_notifications.svg';
|
||||
import StarIcon from '@/assets/icons/star.svg';
|
||||
import Referral from '@/assets/images/referral.png';
|
||||
import {
|
||||
getModalCallbacks,
|
||||
unregisterModalCallbacks,
|
||||
} from '@/utils/modalCallbackRegistry';
|
||||
|
||||
type PointsInfoScreenProps = StaticScreenProps<
|
||||
| {
|
||||
showNextButton?: boolean;
|
||||
callbackId?: number;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
|
||||
interface EarnPointsItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const EarnPointsItem = ({ title, description, icon }: EarnPointsItemProps) => {
|
||||
return (
|
||||
<XStack
|
||||
padding={10}
|
||||
backgroundColor={slate50}
|
||||
borderRadius={10}
|
||||
gap={20}
|
||||
alignItems="center"
|
||||
>
|
||||
<View
|
||||
style={styles.iconContainer}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{icon}
|
||||
</View>
|
||||
<YStack gap={4} flex={1}>
|
||||
<Text style={styles.pointsItemTitle}>{title}</Text>
|
||||
<Text style={styles.pointsItemDescription}>{description}</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
);
|
||||
};
|
||||
|
||||
const EARN_POINTS_ITEMS = [
|
||||
{
|
||||
title: 'Inviting friends to Self',
|
||||
description:
|
||||
"You'll both receive Self Points after your friend signs their first proof.",
|
||||
icon: <StarIcon width={40} height={40} color={black} />,
|
||||
},
|
||||
{
|
||||
title: 'Signing proof requests',
|
||||
description:
|
||||
'Every successful proof that you sign will reward you with Self Points.',
|
||||
icon: <CheckmarkSquareIcon width={40} height={40} color={black} />,
|
||||
},
|
||||
{
|
||||
title: 'Enabling push notifications',
|
||||
description: 'Instantly earn Self Points by activating push notifications.',
|
||||
icon: <PushNotificationsIcon width={40} height={40} color={black} />,
|
||||
},
|
||||
{
|
||||
title: 'Activate cloud back up',
|
||||
description:
|
||||
'Securely back up your account in settings to earn Self Points instantly.',
|
||||
icon: <CloudBackupIcon width={40} height={40} color={black} />,
|
||||
},
|
||||
];
|
||||
|
||||
const PointsInfoScreen: React.FC<PointsInfoScreenProps> = ({
|
||||
route: { params },
|
||||
}) => {
|
||||
const { showNextButton, callbackId } = params || {};
|
||||
const { left, right, bottom } = useSafeAreaInsets();
|
||||
const callbacks = useMemo(
|
||||
() => (callbackId ? getModalCallbacks(callbackId) : undefined),
|
||||
[callbackId],
|
||||
);
|
||||
const buttonPressedRef = useRef(false);
|
||||
|
||||
// Handle button press: mark as pressed and call the callback
|
||||
const handleNextPress = useCallback(() => {
|
||||
if (callbackId !== undefined) {
|
||||
buttonPressedRef.current = true;
|
||||
}
|
||||
callbacks?.onButtonPress();
|
||||
}, [callbackId, callbacks]);
|
||||
|
||||
// Cleanup: Call onModalDismiss and unregister callbacks when component unmounts
|
||||
// Only call onModalDismiss if user navigated back (didn't press the button)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (callbackId !== undefined) {
|
||||
// Always unregister on unmount to prevent memory leaks
|
||||
if (!buttonPressedRef.current) {
|
||||
// User navigated back without pressing "Next" - call onModalDismiss to clear referrer
|
||||
callbacks?.onModalDismiss();
|
||||
}
|
||||
unregisterModalCallbacks(callbackId);
|
||||
}
|
||||
};
|
||||
}, [callbackId, callbacks]);
|
||||
|
||||
return (
|
||||
<YStack flex={1} gap={40} paddingBottom={bottom} backgroundColor={white}>
|
||||
<Image
|
||||
source={Referral}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 300,
|
||||
resizeMode: 'cover',
|
||||
}}
|
||||
/>
|
||||
<ScrollView paddingLeft={20 + left} paddingRight={20 + right}>
|
||||
<YStack gap={20}>
|
||||
<YStack gap={2}>
|
||||
<Title>How it works</Title>
|
||||
<Text style={styles.description}>
|
||||
Self Points are rewards you earn for engaging with the Self
|
||||
platform. You can earn Points by:
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack gap={10}>
|
||||
{EARN_POINTS_ITEMS.map(item => (
|
||||
<EarnPointsItem key={item.title} {...item} />
|
||||
))}
|
||||
</YStack>
|
||||
<YStack gap={2}>
|
||||
<Title>Points are deposited at noon UTC every Sunday</Title>
|
||||
<Text style={styles.description}>
|
||||
To ensure privacy and security on-chain, points are deposited into
|
||||
your wallet every Sunday at noon UTC.
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack style={styles.instructionsContainer} gap={12}>
|
||||
<Text style={styles.instructionsText}>
|
||||
Any points that you earn during the week will be added to your
|
||||
account on the following Sunday.
|
||||
</Text>
|
||||
<Text style={styles.instructionsText}>
|
||||
You can track your incoming points in the Self app along with the
|
||||
countdown to Self Sunday every week.
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
{showNextButton && (
|
||||
<View paddingTop={20} paddingLeft={20 + left} paddingRight={20 + right}>
|
||||
<PrimaryButton onPress={handleNextPress}>Next</PrimaryButton>
|
||||
</View>
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PointsInfoScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
description: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
color: black,
|
||||
},
|
||||
instructionsContainer: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: slate500,
|
||||
backgroundColor: slate50,
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 10,
|
||||
},
|
||||
instructionsText: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: slate500,
|
||||
},
|
||||
nextButton: {
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
pointsItemTitle: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
color: black,
|
||||
},
|
||||
pointsItemDescription: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: slate500,
|
||||
},
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import { useDiditWebSocket } from '@/hooks/useDiditWebSocket';
|
||||
import { useKycWebSocket } from '@/hooks/useKycWebSocket';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import {
|
||||
@@ -66,7 +66,7 @@ const KycSuccessScreen: React.FC<KycSuccessRouteParams> = ({
|
||||
console.log('[KycSuccessScreen] Verification failed:', reason);
|
||||
}, []);
|
||||
|
||||
const { subscribe, unsubscribeAll } = useDiditWebSocket({
|
||||
const { subscribe, unsubscribeAll } = useKycWebSocket({
|
||||
onSuccess: handleWebSocketSuccess,
|
||||
onError: handleWebSocketError,
|
||||
onVerificationFailed: handleVerificationFailed,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { YStack } from 'tamagui';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
@@ -33,50 +33,56 @@ const DisclaimerScreen: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ExpandableBottomLayout.Layout backgroundColor={black}>
|
||||
<ExpandableBottomLayout.TopSection backgroundColor={black}>
|
||||
<DelayedLottieView
|
||||
autoPlay
|
||||
loop={false}
|
||||
source={warningAnimation}
|
||||
style={styles.animation}
|
||||
cacheComposition={true}
|
||||
renderMode="HARDWARE"
|
||||
/>
|
||||
<YStack flex={1} justifyContent="flex-end" paddingBottom="$4">
|
||||
<SubHeader style={{ color: white }}>Caution</SubHeader>
|
||||
</YStack>
|
||||
</ExpandableBottomLayout.TopSection>
|
||||
<ExpandableBottomLayout.BottomSection backgroundColor={white}>
|
||||
<YStack gap="$2.5">
|
||||
<Caution>
|
||||
Apps that request sensitive or personally identifiable information
|
||||
(like passwords, Social Security numbers, or financial details)
|
||||
should be trusted only if they're secure and necessary.
|
||||
</Caution>
|
||||
<Caution style={{ marginTop: 10 }}>
|
||||
Always verify an app's legitimacy before sharing your data.
|
||||
</Caution>
|
||||
<PrimaryButton
|
||||
trackEvent={AppEvents.DISMISS_PRIVACY_DISCLAIMER}
|
||||
style={{ marginVertical: 30 }}
|
||||
onPress={() => {
|
||||
confirmTap();
|
||||
dismissPrivacyNote();
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</PrimaryButton>
|
||||
</YStack>
|
||||
</ExpandableBottomLayout.BottomSection>
|
||||
</ExpandableBottomLayout.Layout>
|
||||
<View testID="disclaimer-screen-root" style={styles.root}>
|
||||
<ExpandableBottomLayout.Layout backgroundColor={black}>
|
||||
<ExpandableBottomLayout.TopSection backgroundColor={black}>
|
||||
<DelayedLottieView
|
||||
autoPlay
|
||||
loop={false}
|
||||
source={warningAnimation}
|
||||
style={styles.animation}
|
||||
cacheComposition={true}
|
||||
renderMode="HARDWARE"
|
||||
/>
|
||||
<YStack flex={1} justifyContent="flex-end" paddingBottom="$4">
|
||||
<SubHeader style={{ color: white }}>Caution</SubHeader>
|
||||
</YStack>
|
||||
</ExpandableBottomLayout.TopSection>
|
||||
<ExpandableBottomLayout.BottomSection backgroundColor={white}>
|
||||
<YStack gap="$2.5">
|
||||
<Caution>
|
||||
Apps that request sensitive or personally identifiable information
|
||||
(like passwords, Social Security numbers, or financial details)
|
||||
should be trusted only if they're secure and necessary.
|
||||
</Caution>
|
||||
<Caution style={{ marginTop: 10 }}>
|
||||
Always verify an app's legitimacy before sharing your data.
|
||||
</Caution>
|
||||
<PrimaryButton
|
||||
testID="disclaimer-dismiss-button"
|
||||
trackEvent={AppEvents.DISMISS_PRIVACY_DISCLAIMER}
|
||||
style={{ marginVertical: 30 }}
|
||||
onPress={() => {
|
||||
confirmTap();
|
||||
dismissPrivacyNote();
|
||||
navigation.navigate({ name: 'Home', params: {} });
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</PrimaryButton>
|
||||
</YStack>
|
||||
</ExpandableBottomLayout.BottomSection>
|
||||
</ExpandableBottomLayout.Layout>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisclaimerScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
animation: {
|
||||
position: 'absolute',
|
||||
width: '125%',
|
||||
|
||||
@@ -22,12 +22,12 @@ import {
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
|
||||
import { notificationError } from '@/integrations/haptics';
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import type { SharedRoutesParamList } from '@/navigation/types';
|
||||
import { flush as flushAnalytics } from '@/services/analytics';
|
||||
import {
|
||||
openSupportForm,
|
||||
SUPPORT_FORM_COMING_SOON_BUTTON_TEXT,
|
||||
SUPPORT_FORM_COMING_SOON_MESSAGE,
|
||||
} from '@/services/support';
|
||||
@@ -39,6 +39,7 @@ type ComingSoonScreenProps = NativeStackScreenProps<
|
||||
|
||||
const ComingSoonScreen: React.FC<ComingSoonScreenProps> = ({ route }) => {
|
||||
const navigateToHome = useHapticNavigation('Home');
|
||||
const openSupportForm = useOpenSupportForm();
|
||||
|
||||
const { countryName, countryCode, documentTypeText } = useMemo(() => {
|
||||
try {
|
||||
@@ -85,12 +86,8 @@ const ComingSoonScreen: React.FC<ComingSoonScreenProps> = ({ route }) => {
|
||||
navigateToHome();
|
||||
};
|
||||
|
||||
const onNotifyMe = async () => {
|
||||
try {
|
||||
await openSupportForm();
|
||||
} catch (error) {
|
||||
console.error('Failed to open support form:', error);
|
||||
}
|
||||
const onNotifyMe = () => {
|
||||
openSupportForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -51,7 +51,7 @@ type WebViewScreenProps = NativeStackScreenProps<
|
||||
>;
|
||||
|
||||
const defaultUrl = selfUrl;
|
||||
const fallbackUrl = 'https://apps.self.xyz';
|
||||
const fallbackUrl = 'https://self.xyz';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webViewContainer: {
|
||||
|
||||
@@ -6,8 +6,7 @@ import type { LottieViewProps } from 'lottie-react-native';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Linking, StyleSheet, View } from 'react-native';
|
||||
import { ScrollView, Spinner } from 'tamagui';
|
||||
import { useIsFocused, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
|
||||
import { DelayedLottieView, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import loadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json';
|
||||
@@ -30,8 +29,6 @@ import {
|
||||
notificationSuccess,
|
||||
} from '@/integrations/haptics';
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { getWhiteListedDisclosureAddresses } from '@/services/points/utils';
|
||||
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
|
||||
import { ProofStatus } from '@/stores/proofTypes';
|
||||
|
||||
@@ -42,8 +39,6 @@ const SuccessScreen: React.FC = () => {
|
||||
const selfApp = useSelfAppStore(state => state.selfApp);
|
||||
const appName = selfApp?.appName;
|
||||
const goHome = useHapticNavigation('Home');
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
const { updateProofStatus } = useProofHistoryStore();
|
||||
|
||||
@@ -58,28 +53,18 @@ const SuccessScreen: React.FC = () => {
|
||||
useState<LottieViewProps['source']>(loadingAnimation);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const [countdownStarted, setCountdownStarted] = useState(false);
|
||||
const [whitelistedPoints, setWhitelistedPoints] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const onOkPress = useCallback(async () => {
|
||||
buttonTap();
|
||||
|
||||
if (whitelistedPoints !== null) {
|
||||
navigation.navigate('Gratification', {
|
||||
points: whitelistedPoints,
|
||||
});
|
||||
setTimeout(() => {
|
||||
goHome();
|
||||
const completedSessionId = sessionId;
|
||||
setTimeout(() => {
|
||||
if (useProvingStore.getState().uuid === completedSessionId) {
|
||||
selfClient.getSelfAppState().cleanSelfApp();
|
||||
}, 2000);
|
||||
} else {
|
||||
goHome();
|
||||
setTimeout(() => {
|
||||
selfClient.getSelfAppState().cleanSelfApp();
|
||||
}, 2000);
|
||||
}
|
||||
}, [whitelistedPoints, navigation, goHome, selfClient]);
|
||||
}
|
||||
}, 2000);
|
||||
}, [goHome, selfClient, sessionId, useProvingStore]);
|
||||
|
||||
function cancelDeeplinkCallbackRedirect() {
|
||||
setCountdown(null);
|
||||
@@ -105,27 +90,6 @@ const SuccessScreen: React.FC = () => {
|
||||
appName,
|
||||
});
|
||||
|
||||
if (selfApp?.endpoint && whitelistedPoints === null) {
|
||||
const checkWhitelist = async () => {
|
||||
try {
|
||||
const whitelistedContracts =
|
||||
await getWhiteListedDisclosureAddresses();
|
||||
const endpoint = selfApp.endpoint.toLowerCase();
|
||||
const whitelistedContract = whitelistedContracts.find(
|
||||
c => c.contract_address.toLowerCase() === endpoint,
|
||||
);
|
||||
|
||||
if (whitelistedContract) {
|
||||
setWhitelistedPoints(whitelistedContract.points_per_disclosure);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking whitelist:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkWhitelist();
|
||||
}
|
||||
|
||||
if (isFocused && !countdownStarted && selfApp?.deeplinkCallback) {
|
||||
if (selfApp?.deeplinkCallback) {
|
||||
try {
|
||||
@@ -170,9 +134,7 @@ const SuccessScreen: React.FC = () => {
|
||||
reason,
|
||||
updateProofStatus,
|
||||
selfApp?.deeplinkCallback,
|
||||
selfApp?.endpoint,
|
||||
countdownStarted,
|
||||
whitelistedPoints,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -11,10 +11,10 @@ import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import type { TipProps } from '@/components/Tips';
|
||||
import Tips from '@/components/Tips';
|
||||
import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
|
||||
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
|
||||
import { flushAllAnalytics } from '@/services/analytics';
|
||||
import {
|
||||
openSupportForm,
|
||||
SUPPORT_FORM_BUTTON_TEXT,
|
||||
SUPPORT_FORM_TIP_MESSAGE,
|
||||
} from '@/services/support';
|
||||
@@ -50,6 +50,7 @@ const tipsDeeplink: TipProps[] = [
|
||||
];
|
||||
|
||||
const QRCodeTrouble: React.FC = () => {
|
||||
const openSupportForm = useOpenSupportForm();
|
||||
const go = useHapticNavigation('Home', { action: 'cancel' });
|
||||
|
||||
// error screen, flush analytics
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { Alert, Linking } from 'react-native';
|
||||
import { Linking } from 'react-native';
|
||||
|
||||
import { supportFormUrl } from '@/consts/links';
|
||||
import { navigationRef } from '@/navigation';
|
||||
|
||||
export const SUPPORT_FORM_BUTTON_TEXT = 'Send feedback';
|
||||
|
||||
@@ -17,26 +18,20 @@ export const SUPPORT_FORM_MESSAGE = 'Have feedback? Please fill out our form.';
|
||||
|
||||
export const SUPPORT_FORM_TIP_MESSAGE = 'Have feedback? Let us know.';
|
||||
|
||||
export const openSupportForm = async (): Promise<void> => {
|
||||
try {
|
||||
const canOpen = await Linking.canOpenURL(supportFormUrl);
|
||||
if (canOpen) {
|
||||
await Linking.openURL(supportFormUrl);
|
||||
} else {
|
||||
console.warn('Cannot open support form URL - no handler available');
|
||||
Alert.alert(
|
||||
'Unable to Open Link',
|
||||
'No app is available to open the support form. Please try again using a web browser.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to open support form:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
Alert.alert(
|
||||
'Error',
|
||||
'Unable to open support form. Please try again later or contact support through another method.',
|
||||
/**
|
||||
* Imperatively open the support form using navigationRef.
|
||||
* Safe to call from anywhere — inside or outside the React Navigation tree.
|
||||
* Falls back to opening the URL in the system browser if navigation is not ready.
|
||||
*/
|
||||
export const openSupportForm = (): void => {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('WebView', {
|
||||
url: supportFormUrl,
|
||||
title: 'Get Support',
|
||||
});
|
||||
} else {
|
||||
Linking.openURL(supportFormUrl).catch(err =>
|
||||
console.warn('Failed to open support form URL:', err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,8 +16,8 @@ export type InjectedErrorType =
|
||||
| 'nfc_parse_failure'
|
||||
| 'api_network_error'
|
||||
| 'api_timeout'
|
||||
| 'didit_initialization'
|
||||
| 'didit_verification';
|
||||
| 'kyc_initialization'
|
||||
| 'kyc_verification';
|
||||
|
||||
export const ERROR_GROUPS = {
|
||||
MRZ: ['mrz_invalid_format', 'mrz_unknown_error'] as InjectedErrorType[],
|
||||
@@ -27,7 +27,7 @@ export const ERROR_GROUPS = {
|
||||
'nfc_parse_failure',
|
||||
] as InjectedErrorType[],
|
||||
API: ['api_network_error', 'api_timeout'] as InjectedErrorType[],
|
||||
Didit: ['didit_initialization', 'didit_verification'] as InjectedErrorType[],
|
||||
KYC: ['kyc_initialization', 'kyc_verification'] as InjectedErrorType[],
|
||||
};
|
||||
|
||||
export const ERROR_LABELS: Record<InjectedErrorType, string> = {
|
||||
@@ -38,8 +38,8 @@ export const ERROR_LABELS: Record<InjectedErrorType, string> = {
|
||||
nfc_parse_failure: 'NFC: Parse failure',
|
||||
api_network_error: 'API: Network error',
|
||||
api_timeout: 'API: Timeout',
|
||||
didit_initialization: 'Didit: Initialization',
|
||||
didit_verification: 'Didit: Verification',
|
||||
kyc_initialization: 'KYC: Initialization',
|
||||
kyc_verification: 'KYC: Verification',
|
||||
};
|
||||
|
||||
interface ErrorInjectionState {
|
||||
|
||||
@@ -167,3 +167,23 @@ export const useSettingStore = create<SettingsState>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function waitForSettingStoreHydration(): Promise<void> {
|
||||
if (useSettingStore.persist.hasHydrated()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>(resolve => {
|
||||
let resolved = false;
|
||||
const unsubscribe = useSettingStore.persist.onFinishHydration(() => {
|
||||
resolved = true;
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
|
||||
if (useSettingStore.persist.hasHydrated() && !resolved) {
|
||||
resolved = true;
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export const TRUSTED_DOMAINS = Object.freeze([
|
||||
'coinbase.com', // Coinbase - Main domain
|
||||
'karmahq.xyz', // Karma - Launch & fund projects
|
||||
'lemonade.social', // Lemonade - Events and communities
|
||||
'notion.site', // Notion - Support/feedback forms
|
||||
'self.xyz', // Base domain and all subdomains (*.self.xyz) - includes espresso.self.xyz
|
||||
'talent.app', // Talent Protocol - Main app
|
||||
'talentprotocol.com', // Talent Protocol - Marketing/info site
|
||||
|
||||
@@ -3,5 +3,11 @@ appId: com.proofofpassportapp
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "home-screen-root"
|
||||
id: "disclaimer-screen-root"
|
||||
timeout: ${LAUNCH_WAIT_MS:120000}
|
||||
- tapOn:
|
||||
id: "disclaimer-dismiss-button"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "home-screen-root"
|
||||
timeout: 30000
|
||||
|
||||
@@ -3,5 +3,11 @@ appId: com.warroom.proofofpassport
|
||||
- launchApp
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "home-screen-root"
|
||||
id: "disclaimer-screen-root"
|
||||
timeout: ${LAUNCH_WAIT_MS:120000}
|
||||
- tapOn:
|
||||
id: "disclaimer-dismiss-button"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "home-screen-root"
|
||||
timeout: 30000
|
||||
|
||||
@@ -62,7 +62,6 @@ describe('links', () => {
|
||||
expect(links.selfUrl).toContain('self.xyz');
|
||||
expect(links.privacyUrl).toContain('self.xyz');
|
||||
expect(links.termsUrl).toContain('self.xyz');
|
||||
expect(links.appsUrl).toContain('self.xyz');
|
||||
expect(links.referralBaseUrl).toContain('self.xyz');
|
||||
expect(links.apiBaseUrl).toContain('self.xyz');
|
||||
expect(links.pointsApiBaseUrl).toContain('self.xyz');
|
||||
|
||||
@@ -1,777 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { useEarnPointsFlow } from '@/hooks/useEarnPointsFlow';
|
||||
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
|
||||
import {
|
||||
hasUserAnIdentityDocumentRegistered,
|
||||
hasUserDoneThePointsDisclosure,
|
||||
POINT_VALUES,
|
||||
pointsSelfApp,
|
||||
} from '@/services/points';
|
||||
import useUserStore from '@/stores/userStore';
|
||||
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||
useSelfClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useRegisterReferral', () => ({
|
||||
useRegisterReferral: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/services/points', () => ({
|
||||
hasUserAnIdentityDocumentRegistered: jest.fn(),
|
||||
hasUserDoneThePointsDisclosure: jest.fn(),
|
||||
pointsSelfApp: jest.fn(),
|
||||
POINT_VALUES: {
|
||||
referee: 24,
|
||||
},
|
||||
}));
|
||||
|
||||
// userStore is used as-is, no mock needed
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
const mockUseNavigation = useNavigation as jest.MockedFunction<
|
||||
typeof useNavigation
|
||||
>;
|
||||
const mockUseSelfClient = useSelfClient as jest.MockedFunction<
|
||||
typeof useSelfClient
|
||||
>;
|
||||
const mockUseRegisterReferral = useRegisterReferral as jest.MockedFunction<
|
||||
typeof useRegisterReferral
|
||||
>;
|
||||
const mockHasUserAnIdentityDocumentRegistered =
|
||||
hasUserAnIdentityDocumentRegistered as jest.MockedFunction<
|
||||
typeof hasUserAnIdentityDocumentRegistered
|
||||
>;
|
||||
const mockHasUserDoneThePointsDisclosure =
|
||||
hasUserDoneThePointsDisclosure as jest.MockedFunction<
|
||||
typeof hasUserDoneThePointsDisclosure
|
||||
>;
|
||||
const mockPointsSelfApp = pointsSelfApp as jest.MockedFunction<
|
||||
typeof pointsSelfApp
|
||||
>;
|
||||
|
||||
describe('useEarnPointsFlow', () => {
|
||||
const mockSetSelfApp = jest.fn();
|
||||
const mockSelfClient = {
|
||||
getSelfAppState: jest.fn(() => ({
|
||||
setSelfApp: mockSetSelfApp,
|
||||
})),
|
||||
};
|
||||
const mockRegisterReferral = jest.fn();
|
||||
const mockSelfApp = {
|
||||
appName: '✨ Self Points',
|
||||
endpoint: '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0',
|
||||
sessionId: 'test-session-id',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSetSelfApp.mockClear();
|
||||
jest.useFakeTimers();
|
||||
|
||||
mockUseNavigation.mockReturnValue({
|
||||
navigate: mockNavigate,
|
||||
} as any);
|
||||
|
||||
mockUseSelfClient.mockReturnValue(mockSelfClient as any);
|
||||
|
||||
mockUseRegisterReferral.mockReturnValue({
|
||||
registerReferral: mockRegisterReferral,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Reset user store state
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
useUserStore.getState().registeredReferrers.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Identity verification flow', () => {
|
||||
it('should show identity verification modal when user has no identity document', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockHasUserAnIdentityDocumentRegistered).toHaveBeenCalled();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('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: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to CountryPicker when identity verification modal button is pressed', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
expect(callbacks).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('CountryPicker');
|
||||
});
|
||||
|
||||
it('should clear referrer when identity verification modal is dismissed with referrer', async () => {
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
act(() => {
|
||||
callbacks!.onModalDismiss();
|
||||
});
|
||||
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Points disclosure flow', () => {
|
||||
it('should show points disclosure modal when user has not done disclosure', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockHasUserAnIdentityDocumentRegistered).toHaveBeenCalled();
|
||||
expect(mockHasUserDoneThePointsDisclosure).toHaveBeenCalled();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
// We pass callbackId to retrieve and invoke the callback that displays the points disclosure modal
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
await act(async () => {
|
||||
await callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('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: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to Prove screen when points disclosure modal button is pressed', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
mockPointsSelfApp.mockResolvedValue(mockSelfApp as any);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
const pointsInfoCallbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const pointsInfoCallbacks = getModalCallbacks(pointsInfoCallbackId);
|
||||
|
||||
await act(async () => {
|
||||
await pointsInfoCallbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[1][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
expect(callbacks).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
await callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
expect(mockPointsSelfApp).toHaveBeenCalled();
|
||||
|
||||
// setSelfApp is called synchronously after pointsSelfApp resolves
|
||||
expect(mockSetSelfApp).toHaveBeenCalledWith(mockSelfApp);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('ProvingScreenRouter');
|
||||
});
|
||||
|
||||
it('should clear referrer when points disclosure modal is dismissed with referrer', async () => {
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
const pointsInfoCallbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const pointsInfoCallbacks = getModalCallbacks(pointsInfoCallbackId);
|
||||
|
||||
await act(async () => {
|
||||
await pointsInfoCallbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[1][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
act(() => {
|
||||
callbacks!.onModalDismiss();
|
||||
});
|
||||
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Direct navigation flow', () => {
|
||||
it('should navigate to Points screen when user has completed all checks and no referrer', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Points');
|
||||
});
|
||||
|
||||
it('should not navigate when user has completed all checks, has referrer, but skipReferralFlow is true', async () => {
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(true);
|
||||
});
|
||||
|
||||
// Should not navigate to Points or Gratification
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Points');
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Referral flow', () => {
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
|
||||
beforeEach(() => {
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('should handle referral flow when referrer is confirmed and not skipped', async () => {
|
||||
mockRegisterReferral.mockResolvedValue({ success: true });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledWith(referrer);
|
||||
expect(useUserStore.getState().isReferrerRegistered(referrer)).toBe(true);
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not register referral if already registered', async () => {
|
||||
useUserStore.getState().markReferrerAsRegistered(referrer);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error modal and preserve referrer if referral registration fails', async () => {
|
||||
mockRegisterReferral.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Network error occurred',
|
||||
});
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledWith(referrer);
|
||||
|
||||
// Should NOT navigate to Gratification on failure
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
|
||||
// Should show error modal instead
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Modal', {
|
||||
titleText: 'Referral Registration Failed',
|
||||
bodyText: expect.stringContaining('Network error occurred'),
|
||||
buttonText: 'Try Again',
|
||||
secondaryButtonText: 'Dismiss',
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
// Should preserve the referrer for retry
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBe(referrer);
|
||||
|
||||
// Should log the error
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Referral registration failed:',
|
||||
'Network error occurred',
|
||||
);
|
||||
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('should retry referral registration when error modal retry button is pressed', async () => {
|
||||
// First call fails, second call succeeds
|
||||
mockRegisterReferral
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Network error',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
});
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// First attempt - should fail
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Modal', {
|
||||
titleText: 'Referral Registration Failed',
|
||||
bodyText: expect.stringContaining('Network error'),
|
||||
buttonText: 'Try Again',
|
||||
secondaryButtonText: 'Dismiss',
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
// Referrer should still be in store
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBe(referrer);
|
||||
|
||||
// Get the callback from the error modal and trigger retry
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
mockNavigate.mockClear();
|
||||
|
||||
// Retry - should succeed
|
||||
await act(async () => {
|
||||
await callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledTimes(2);
|
||||
expect(mockRegisterReferral).toHaveBeenCalledWith(referrer);
|
||||
|
||||
// Should now navigate to Gratification
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
|
||||
// Should mark referrer as registered and clear it
|
||||
expect(useUserStore.getState().isReferrerRegistered(referrer)).toBe(true);
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('should clear referrer when error modal is dismissed', async () => {
|
||||
mockRegisterReferral.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'API error',
|
||||
});
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
// Dismiss the error modal
|
||||
act(() => {
|
||||
callbacks!.onModalDismiss();
|
||||
});
|
||||
|
||||
// Referrer should be cleared to prevent retry loop
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('should not handle referral flow when isReferralConfirmed is false', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification');
|
||||
});
|
||||
|
||||
it('should not handle referral flow when isReferralConfirmed is undefined', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification');
|
||||
});
|
||||
|
||||
it('should not handle referral flow when hasReferrer is false', async () => {
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification');
|
||||
});
|
||||
|
||||
it('should handle referral flow when referrer is not in store but hasReferrer is true', async () => {
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
mockRegisterReferral.mockResolvedValue({ success: true });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
// Should not call registerReferral if referrer is not in store
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle errors in hasUserAnIdentityDocumentRegistered gracefully', async () => {
|
||||
// Mock to return false on error (as the actual function catches errors and returns false)
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
// The function catches errors and returns false, so it should show identity verification modal
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'Modal',
|
||||
expect.objectContaining({
|
||||
titleText: 'Identity Verification Required',
|
||||
callbackId: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors in hasUserDoneThePointsDisclosure gracefully', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
// Mock to return false on error (as the actual function catches errors and returns false)
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
const pointsInfoCallbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const pointsInfoCallbacks = getModalCallbacks(pointsInfoCallbackId);
|
||||
|
||||
await act(async () => {
|
||||
await pointsInfoCallbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
// The function catches errors and returns false, so it should show points disclosure modal
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'Modal',
|
||||
expect.objectContaining({
|
||||
titleText: 'Points Disclosure Required',
|
||||
callbackId: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call pointsSelfApp when navigating to points proof', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
mockPointsSelfApp.mockResolvedValue(mockSelfApp as any);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
const pointsInfoCallbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const pointsInfoCallbacks = getModalCallbacks(pointsInfoCallbackId);
|
||||
|
||||
await act(async () => {
|
||||
await pointsInfoCallbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[1][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
await act(async () => {
|
||||
await callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
// Verify pointsSelfApp was called
|
||||
expect(mockPointsSelfApp).toHaveBeenCalled();
|
||||
|
||||
// setSelfApp should be called when pointsSelfApp succeeds
|
||||
expect(mockSetSelfApp).toHaveBeenCalledWith(mockSelfApp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Callback dependencies', () => {
|
||||
it('should update callbacks when dependencies change', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(true);
|
||||
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockRegisterReferral.mockResolvedValue({ success: true });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ hasReferrer, isReferralConfirmed }) =>
|
||||
useEarnPointsFlow({ hasReferrer, isReferralConfirmed }),
|
||||
{
|
||||
initialProps: {
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Points');
|
||||
|
||||
mockNavigate.mockClear();
|
||||
|
||||
rerender({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledWith(referrer);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
app/tests/src/hooks/useOpenSupportForm.test.ts
Normal file
42
app/tests/src/hooks/useOpenSupportForm.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
|
||||
import { supportFormUrl } from '@/consts/links';
|
||||
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
|
||||
import { impactLight } from '@/integrations/haptics';
|
||||
import { navigationRef } from '@/navigation';
|
||||
|
||||
jest.mock('@/integrations/haptics', () => ({
|
||||
impactLight: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/navigation', () => ({
|
||||
navigationRef: {
|
||||
isReady: jest.fn(),
|
||||
navigate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useOpenSupportForm', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(navigationRef.isReady as jest.Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('triggers haptic feedback and navigates to the support form WebView', () => {
|
||||
const { result } = renderHook(() => useOpenSupportForm());
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(impactLight).toHaveBeenCalledTimes(1);
|
||||
expect(navigationRef.navigate).toHaveBeenCalledWith('WebView', {
|
||||
url: supportFormUrl,
|
||||
title: 'Get Support',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,8 @@ import { usePendingKycRecovery } from '@/hooks/usePendingKycRecovery';
|
||||
import { navigationRef } from '@/navigation';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/hooks/useDiditWebSocket', () => ({
|
||||
useDiditWebSocket: jest.fn(() => ({
|
||||
jest.mock('@/hooks/useKycWebSocket', () => ({
|
||||
useKycWebSocket: jest.fn(() => ({
|
||||
subscribe: jest.fn(),
|
||||
unsubscribeAll: jest.fn(),
|
||||
})),
|
||||
@@ -39,8 +39,8 @@ describe('usePendingKycRecovery', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Setup default mocks
|
||||
const { useDiditWebSocket } = jest.requireMock('@/hooks/useDiditWebSocket');
|
||||
useDiditWebSocket.mockReturnValue({
|
||||
const { useKycWebSocket } = jest.requireMock('@/hooks/useKycWebSocket');
|
||||
useKycWebSocket.mockReturnValue({
|
||||
subscribe: mockSubscribe,
|
||||
unsubscribeAll: mockUnsubscribeAll,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ jest.mock('@/services/analytics', () => ({
|
||||
flush: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Didit SDK to prevent ES module parsing errors in isolateModules
|
||||
// Mock KYC SDK to prevent ES module parsing errors in isolateModules
|
||||
jest.mock('@didit-protocol/sdk-react-native', () => ({
|
||||
__esModule: true,
|
||||
startVerification: jest.fn().mockResolvedValue({
|
||||
@@ -76,7 +76,6 @@ describe('navigation', () => {
|
||||
'DocumentNFCTrouble',
|
||||
'DocumentOnboarding',
|
||||
'DocumentSelectorForProving',
|
||||
'Gratification',
|
||||
'Home',
|
||||
'IDPicker',
|
||||
'IdDetails',
|
||||
@@ -89,8 +88,6 @@ describe('navigation', () => {
|
||||
'ManageDocuments',
|
||||
'MockDataDeepLink',
|
||||
'Modal',
|
||||
'Points',
|
||||
'PointsInfo',
|
||||
'ProofHistory',
|
||||
'ProofHistoryDetail',
|
||||
'ProofRequestStatus',
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { render, waitFor } from '@testing-library/react-native';
|
||||
|
||||
import GratificationScreen from '@/screens/app/GratificationScreen';
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const MockView = ({ children, ...props }: any) => (
|
||||
<mock-view {...props}>{children}</mock-view>
|
||||
);
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<mock-text {...props}>{children}</mock-text>
|
||||
);
|
||||
const mockDimensions = {
|
||||
get: jest.fn(() => ({ width: 320, height: 640 })),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
Dimensions: mockDimensions,
|
||||
Platform: { OS: 'ios', select: jest.fn() },
|
||||
Pressable: ({ onPress, children }: any) => (
|
||||
<button onClick={onPress} type="button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
StyleSheet: {
|
||||
create: (styles: any) => styles,
|
||||
flatten: (style: any) => style,
|
||||
},
|
||||
Text: MockText,
|
||||
View: MockView,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-edge-to-edge', () => ({
|
||||
SystemBars: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0 })),
|
||||
}));
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: jest.fn(),
|
||||
useRoute: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Tamagui components to avoid theme provider requirement
|
||||
jest.mock('tamagui', () => {
|
||||
const View: any = 'View';
|
||||
const Text: any = 'Text';
|
||||
const createViewComponent = (displayName: string) => {
|
||||
const MockComponent = ({ children, ...props }: any) => (
|
||||
<View {...props} testID={displayName}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
MockComponent.displayName = displayName;
|
||||
return MockComponent;
|
||||
};
|
||||
|
||||
const MockYStack = createViewComponent('YStack');
|
||||
const MockView = createViewComponent('View');
|
||||
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<Text {...props}>{children}</Text>
|
||||
);
|
||||
MockText.displayName = 'Text';
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
YStack: MockYStack,
|
||||
View: MockView,
|
||||
Text: MockText,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||
DelayedLottieView: ({ onAnimationFinish }: any) => {
|
||||
// Simulate animation finishing immediately
|
||||
setTimeout(() => {
|
||||
onAnimationFinish?.();
|
||||
}, 0);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
|
||||
PrimaryButton: ({ children, onPress }: any) => (
|
||||
<button onClick={onPress}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/assets/icons/arrow_left.svg', () => 'ArrowLeft');
|
||||
jest.mock('@/assets/logos/self.svg', () => 'SelfLogo');
|
||||
|
||||
const mockUseNavigation = useNavigation as jest.MockedFunction<
|
||||
typeof useNavigation
|
||||
>;
|
||||
const mockUseRoute = useRoute as jest.MockedFunction<typeof useRoute>;
|
||||
|
||||
describe('GratificationScreen', () => {
|
||||
const mockNavigate = jest.fn();
|
||||
const mockGoBack = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseNavigation.mockReturnValue({
|
||||
navigate: mockNavigate,
|
||||
goBack: mockGoBack,
|
||||
} as any);
|
||||
|
||||
mockUseRoute.mockReturnValue({
|
||||
params: {},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should use default points value when not provided', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
params: {},
|
||||
} as any);
|
||||
|
||||
const { getByText } = render(<GratificationScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('0')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom points value when provided', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
params: { points: 50 },
|
||||
} as any);
|
||||
|
||||
const { getByText } = render(<GratificationScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('50')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display referral points value (24) when passed', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
params: { points: 24 },
|
||||
} as any);
|
||||
|
||||
const { getByText } = render(<GratificationScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('24')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
97
app/tests/src/screens/app/startupRouting.test.ts
Normal file
97
app/tests/src/screens/app/startupRouting.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import {
|
||||
getStartupNavigationTarget,
|
||||
hasStartupRecoverySignal,
|
||||
} from '@/screens/app/startupRouting';
|
||||
|
||||
describe('startupRouting', () => {
|
||||
it('routes to recovery when the secret is missing but a registered document still exists', () => {
|
||||
expect(
|
||||
getStartupNavigationTarget({
|
||||
hasPrivacyNoteBeenDismissed: true,
|
||||
hasRecoverySignal: false,
|
||||
hasSecretStored: false,
|
||||
hasValidRegisteredDocument: true,
|
||||
}),
|
||||
).toEqual({
|
||||
allowQueuedDeepLink: false,
|
||||
route: 'AccountRecoveryChoice',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes to recovery when the secret is missing and recovery signals exist', () => {
|
||||
expect(
|
||||
getStartupNavigationTarget({
|
||||
hasPrivacyNoteBeenDismissed: true,
|
||||
hasRecoverySignal: true,
|
||||
hasSecretStored: false,
|
||||
hasValidRegisteredDocument: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowQueuedDeepLink: false,
|
||||
route: 'AccountRecoveryChoice',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes new users without recovery signals to Disclaimer', () => {
|
||||
expect(
|
||||
getStartupNavigationTarget({
|
||||
hasPrivacyNoteBeenDismissed: false,
|
||||
hasRecoverySignal: false,
|
||||
hasSecretStored: false,
|
||||
hasValidRegisteredDocument: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowQueuedDeepLink: false,
|
||||
route: 'Disclaimer',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes dismissed-disclaimer users without recovery signals to Home', () => {
|
||||
expect(
|
||||
getStartupNavigationTarget({
|
||||
hasPrivacyNoteBeenDismissed: true,
|
||||
hasRecoverySignal: false,
|
||||
hasSecretStored: false,
|
||||
hasValidRegisteredDocument: false,
|
||||
}),
|
||||
).toEqual({
|
||||
allowQueuedDeepLink: true,
|
||||
route: 'Home',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats cloud backup, viewed phrase, or stored points address as recovery signals', () => {
|
||||
expect(
|
||||
hasStartupRecoverySignal({
|
||||
cloudBackupEnabled: true,
|
||||
hasViewedRecoveryPhrase: false,
|
||||
pointsAddress: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasStartupRecoverySignal({
|
||||
cloudBackupEnabled: false,
|
||||
hasViewedRecoveryPhrase: true,
|
||||
pointsAddress: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasStartupRecoverySignal({
|
||||
cloudBackupEnabled: false,
|
||||
hasViewedRecoveryPhrase: false,
|
||||
pointsAddress: '0x123',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasStartupRecoverySignal({
|
||||
cloudBackupEnabled: false,
|
||||
hasViewedRecoveryPhrase: false,
|
||||
pointsAddress: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,339 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 { act, render } from '@testing-library/react-native';
|
||||
|
||||
import PointsInfoScreen from '@/screens/home/PointsInfoScreen';
|
||||
import { unregisterModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const MockView = ({ children, ...props }: any) => (
|
||||
<mock-view {...props}>{children}</mock-view>
|
||||
);
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<mock-text {...props}>{children}</mock-text>
|
||||
);
|
||||
const MockImage = ({ ...props }: any) => <mock-image {...props} />;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
Image: MockImage,
|
||||
Platform: { OS: 'ios', select: jest.fn() },
|
||||
StyleSheet: {
|
||||
create: (styles: any) => styles,
|
||||
flatten: (style: any) => style,
|
||||
},
|
||||
Text: MockText,
|
||||
View: MockView,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock Tamagui components
|
||||
jest.mock('tamagui', () => {
|
||||
const View: any = 'View';
|
||||
const Text: any = 'Text';
|
||||
const createViewComponent = (displayName: string) => {
|
||||
const MockComponent = ({ children, ...props }: any) => (
|
||||
<View {...props} testID={displayName}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
MockComponent.displayName = displayName;
|
||||
return MockComponent;
|
||||
};
|
||||
|
||||
const MockYStack = createViewComponent('YStack');
|
||||
const MockXStack = createViewComponent('XStack');
|
||||
const MockView = createViewComponent('View');
|
||||
const MockScrollView = createViewComponent('ScrollView');
|
||||
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<Text {...props}>{children}</Text>
|
||||
);
|
||||
MockText.displayName = 'Text';
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
YStack: MockYStack,
|
||||
XStack: MockXStack,
|
||||
View: MockView,
|
||||
Text: MockText,
|
||||
ScrollView: MockScrollView,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock mobile SDK components
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
|
||||
PrimaryButton: ({ children, onPress, ...props }: any) => (
|
||||
<mock-view {...props} onPress={onPress} testID="primary-button">
|
||||
{children}
|
||||
</mock-view>
|
||||
),
|
||||
Title: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock SVG icons
|
||||
jest.mock('@/assets/icons/checkmark_square.svg', () => 'CheckmarkSquareIcon');
|
||||
jest.mock('@/assets/icons/cloud_backup.svg', () => 'CloudBackupIcon');
|
||||
jest.mock(
|
||||
'@/assets/icons/push_notifications.svg',
|
||||
() => 'PushNotificationsIcon',
|
||||
);
|
||||
jest.mock('@/assets/icons/star.svg', () => 'StarIcon');
|
||||
|
||||
// Mock images
|
||||
jest.mock('@/assets/images/referral.png', () => 'ReferralImage');
|
||||
|
||||
jest.mock('@/utils/modalCallbackRegistry', () => ({
|
||||
getModalCallbacks: jest.fn(),
|
||||
registerModalCallbacks: jest.fn(),
|
||||
unregisterModalCallbacks: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUnregisterModalCallbacks =
|
||||
unregisterModalCallbacks as jest.MockedFunction<
|
||||
typeof unregisterModalCallbacks
|
||||
>;
|
||||
|
||||
// Mock getModalCallbacks at module level
|
||||
const { getModalCallbacks } = jest.requireMock('@/utils/modalCallbackRegistry');
|
||||
|
||||
describe('PointsInfoScreen', () => {
|
||||
const mockOnButtonPress = jest.fn();
|
||||
const mockOnModalDismiss = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup getModalCallbacks to return our mock callbacks
|
||||
getModalCallbacks.mockImplementation((id: number) => {
|
||||
if (id === 1) {
|
||||
return {
|
||||
onButtonPress: mockOnButtonPress,
|
||||
onModalDismiss: mockOnModalDismiss,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
expect(() => {
|
||||
render(<PointsInfoScreen route={{ params: undefined }} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not show Next button when showNextButton is false', () => {
|
||||
const { queryByTestId } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: false, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify button is not rendered
|
||||
const nextButton = queryByTestId('primary-button');
|
||||
expect(nextButton).toBeNull();
|
||||
});
|
||||
|
||||
it('should show Next button when showNextButton is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify button is rendered
|
||||
const nextButton = getByTestId('primary-button');
|
||||
expect(nextButton).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('Callback handling', () => {
|
||||
it('should call onModalDismiss and unregister callbacks when component unmounts without button press', () => {
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initially, no callbacks should be called
|
||||
expect(mockOnModalDismiss).not.toHaveBeenCalled();
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
expect(mockOnButtonPress).not.toHaveBeenCalled();
|
||||
|
||||
// Unmount the component (simulating user navigating back)
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// onModalDismiss should be called to clear referrer
|
||||
expect(mockOnModalDismiss).toHaveBeenCalledTimes(1);
|
||||
// Callbacks should be unregistered to prevent memory leak
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
|
||||
// onButtonPress should not be called (user didn't press the button)
|
||||
expect(mockOnButtonPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onModalDismiss on unmount even when showNextButton is false', () => {
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: false, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// Callbacks should be called even if button is not shown (callbackId is present)
|
||||
expect(mockOnModalDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle missing callbacks gracefully', () => {
|
||||
// Mock getModalCallbacks to return undefined
|
||||
getModalCallbacks.mockReturnValue(undefined);
|
||||
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 999 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not throw when unmounting with missing callbacks
|
||||
expect(() => {
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still attempt to unregister
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(999);
|
||||
});
|
||||
|
||||
it('should handle missing callbackId gracefully', () => {
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen route={{ params: { showNextButton: true } }} />,
|
||||
);
|
||||
|
||||
// Should not throw when unmounting without callbackId
|
||||
expect(() => {
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// Should not attempt to unregister if no callbackId
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button press handling', () => {
|
||||
it('should call onButtonPress and unregister callbacks when Next button is pressed, then not call onModalDismiss on unmount', () => {
|
||||
const { getByTestId, unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const primaryButton = getByTestId('primary-button');
|
||||
|
||||
// Press the button
|
||||
act(() => {
|
||||
primaryButton.props.onPress();
|
||||
});
|
||||
|
||||
// onButtonPress should be called
|
||||
expect(mockOnButtonPress).toHaveBeenCalledTimes(1);
|
||||
// Callbacks should NOT be unregistered yet (component still mounted)
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
// onModalDismiss should NOT be called (button was pressed)
|
||||
expect(mockOnModalDismiss).not.toHaveBeenCalled();
|
||||
|
||||
// Clear mock calls from button press
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Unmount the component
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// onModalDismiss should NOT be called (button was pressed, not navigated back)
|
||||
expect(mockOnModalDismiss).not.toHaveBeenCalled();
|
||||
// Callbacks should be unregistered to prevent memory leak
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should allow multiple button presses without unregistering callbacks (regression test)', () => {
|
||||
const { getByTestId } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const primaryButton = getByTestId('primary-button');
|
||||
|
||||
// Press the button first time
|
||||
act(() => {
|
||||
primaryButton.props.onPress();
|
||||
});
|
||||
|
||||
expect(mockOnButtonPress).toHaveBeenCalledTimes(1);
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
|
||||
// Press the button again (simulating returning to this screen after modal dismissal)
|
||||
act(() => {
|
||||
primaryButton.props.onPress();
|
||||
});
|
||||
|
||||
// onButtonPress should be called again
|
||||
expect(mockOnButtonPress).toHaveBeenCalledTimes(2);
|
||||
// Callbacks should still NOT be unregistered (component still mounted)
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Referrer cleanup integration', () => {
|
||||
it('should ensure cleanup is called in correct order for referrer clearing', () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const onModalDismissWithTracking = jest.fn(() => {
|
||||
callOrder.push('onModalDismiss');
|
||||
});
|
||||
|
||||
const unregisterWithTracking = jest.fn(() => {
|
||||
callOrder.push('unregister');
|
||||
});
|
||||
|
||||
getModalCallbacks.mockReturnValue({
|
||||
onButtonPress: mockOnButtonPress,
|
||||
onModalDismiss: onModalDismissWithTracking,
|
||||
});
|
||||
|
||||
mockUnregisterModalCallbacks.mockImplementation(unregisterWithTracking);
|
||||
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// Verify onModalDismiss is called before unregister
|
||||
expect(callOrder).toEqual(['onModalDismiss', 'unregister']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,8 +44,8 @@ jest.mock('react-native-edge-to-edge', () => ({
|
||||
SystemBars: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useDiditWebSocket', () => ({
|
||||
useDiditWebSocket: jest.fn(() => ({
|
||||
jest.mock('@/hooks/useKycWebSocket', () => ({
|
||||
useKycWebSocket: jest.fn(() => ({
|
||||
subscribe: jest.fn(),
|
||||
unsubscribe: jest.fn(),
|
||||
unsubscribeAll: jest.fn(),
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 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 { useIsFocused } from '@react-navigation/native';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react-native';
|
||||
|
||||
import ProofRequestStatusScreen from '@/screens/verification/ProofRequestStatusScreen';
|
||||
import { ProofStatus } from '@/stores/proofTypes';
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'mock-view': any;
|
||||
'mock-text': any;
|
||||
'mock-button': any;
|
||||
'mock-spinner': any;
|
||||
'mock-lottie': any;
|
||||
'mock-layout': any;
|
||||
'mock-top': any;
|
||||
'mock-bottom': any;
|
||||
'mock-scroll': any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock('react-native', () => ({
|
||||
__esModule: true,
|
||||
Linking: {
|
||||
openURL: jest.fn(),
|
||||
},
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) => styles,
|
||||
flatten: (style: unknown) => style,
|
||||
},
|
||||
View: ({ children, ...props }: any) => (
|
||||
<mock-view {...props}>{children}</mock-view>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useIsFocused: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('tamagui', () => ({
|
||||
__esModule: true,
|
||||
ScrollView: ({ children, ...props }: any) => (
|
||||
<mock-scroll {...props}>{children}</mock-scroll>
|
||||
),
|
||||
Spinner: (props: any) => <mock-spinner {...props} />,
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/constants/colors', () => ({
|
||||
black: '#000000',
|
||||
white: '#ffffff',
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/constants/analytics', () => ({
|
||||
ProofEvents: {
|
||||
PROOF_COMPLETED: 'PROOF_COMPLETED',
|
||||
PROOF_FAILED: 'PROOF_FAILED',
|
||||
PROOF_RESULT_ACKNOWLEDGED: 'PROOF_RESULT_ACKNOWLEDGED',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/animations/loading/misc.json', () => ({}));
|
||||
jest.mock('@/assets/animations/proof_failed.json', () => ({}));
|
||||
jest.mock('@/assets/animations/proof_success.json', () => ({}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
|
||||
BodyText: ({ children, ...props }: any) => (
|
||||
<mock-text {...props}>{children}</mock-text>
|
||||
),
|
||||
Description: ({ children, ...props }: any) => (
|
||||
<mock-text {...props}>{children}</mock-text>
|
||||
),
|
||||
PrimaryButton: ({ children, onPress, disabled, ...props }: any) => (
|
||||
<mock-button
|
||||
testID="primary-button"
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</mock-button>
|
||||
),
|
||||
Title: ({ children, ...props }: any) => (
|
||||
<mock-text {...props}>{children}</mock-text>
|
||||
),
|
||||
typography: {
|
||||
strong: {},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||
DelayedLottieView: (props: any) => <mock-lottie {...props} />,
|
||||
useSelfClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useHapticNavigation', () => jest.fn());
|
||||
|
||||
jest.mock('@/integrations/haptics', () => ({
|
||||
buttonTap: jest.fn(),
|
||||
notificationError: jest.fn(),
|
||||
notificationSuccess: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/layouts/ExpandableBottomLayout', () => ({
|
||||
ExpandableBottomLayout: {
|
||||
Layout: ({ children, ...props }: any) => (
|
||||
<mock-layout {...props}>{children}</mock-layout>
|
||||
),
|
||||
TopSection: ({ children, ...props }: any) => (
|
||||
<mock-top {...props}>{children}</mock-top>
|
||||
),
|
||||
BottomSection: ({ children, ...props }: any) => (
|
||||
<mock-bottom {...props}>{children}</mock-bottom>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/stores/proofHistoryStore', () => ({
|
||||
useProofHistoryStore: jest.fn(),
|
||||
}));
|
||||
|
||||
const { Linking } = jest.requireMock('react-native') as {
|
||||
Linking: {
|
||||
openURL: jest.Mock;
|
||||
};
|
||||
};
|
||||
const { useSelfClient } = jest.requireMock('@selfxyz/mobile-sdk-alpha') as {
|
||||
useSelfClient: jest.Mock;
|
||||
};
|
||||
const useHapticNavigation = jest.requireMock(
|
||||
'@/hooks/useHapticNavigation',
|
||||
) as jest.Mock;
|
||||
const { buttonTap, notificationSuccess } = jest.requireMock(
|
||||
'@/integrations/haptics',
|
||||
) as {
|
||||
buttonTap: jest.Mock;
|
||||
notificationSuccess: jest.Mock;
|
||||
};
|
||||
const { useProofHistoryStore } = jest.requireMock(
|
||||
'@/stores/proofHistoryStore',
|
||||
) as {
|
||||
useProofHistoryStore: jest.Mock;
|
||||
};
|
||||
|
||||
describe('ProofRequestStatusScreen', () => {
|
||||
const mockGoHome = jest.fn();
|
||||
const mockTrackEvent = jest.fn();
|
||||
const mockCleanSelfApp = jest.fn();
|
||||
const mockUpdateProofStatus = jest.fn();
|
||||
|
||||
let provingState: {
|
||||
currentState: string;
|
||||
reason: string | null;
|
||||
uuid: string;
|
||||
error_code: string | null;
|
||||
};
|
||||
let selfAppState: {
|
||||
selfApp: {
|
||||
appName: string;
|
||||
deeplinkCallback: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
provingState = {
|
||||
currentState: 'completed',
|
||||
reason: null,
|
||||
uuid: 'session-1',
|
||||
error_code: null,
|
||||
};
|
||||
selfAppState = {
|
||||
selfApp: {
|
||||
appName: 'Verifier',
|
||||
deeplinkCallback: null,
|
||||
},
|
||||
};
|
||||
|
||||
(useIsFocused as jest.Mock).mockReturnValue(true);
|
||||
useHapticNavigation.mockReturnValue(mockGoHome);
|
||||
useProofHistoryStore.mockReturnValue({
|
||||
updateProofStatus: mockUpdateProofStatus,
|
||||
});
|
||||
|
||||
const useProvingStore = Object.assign(
|
||||
(selector: (state: typeof provingState) => unknown) =>
|
||||
selector(provingState),
|
||||
{
|
||||
getState: () => provingState,
|
||||
},
|
||||
);
|
||||
const useSelfAppStore = (
|
||||
selector: (state: typeof selfAppState) => unknown,
|
||||
) => selector(selfAppState);
|
||||
|
||||
useSelfClient.mockReturnValue({
|
||||
trackEvent: mockTrackEvent,
|
||||
getSelfAppState: () => ({ cleanSelfApp: mockCleanSelfApp }),
|
||||
useProvingStore,
|
||||
useSelfAppStore,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('goes home and clears the completed session after acknowledgement', async () => {
|
||||
render(<ProofRequestStatusScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateProofStatus).toHaveBeenCalledWith(
|
||||
'session-1',
|
||||
ProofStatus.SUCCESS,
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.press(screen.getByTestId('primary-button'));
|
||||
|
||||
expect(buttonTap).toHaveBeenCalledTimes(1);
|
||||
expect(mockGoHome).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
|
||||
expect(notificationSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('PROOF_COMPLETED', {
|
||||
sessionId: 'session-1',
|
||||
appName: 'Verifier',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not clear self app state if a newer session replaces the completed one', async () => {
|
||||
render(<ProofRequestStatusScreen />);
|
||||
|
||||
fireEvent.press(screen.getByTestId('primary-button'));
|
||||
provingState.uuid = 'session-2';
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mockCleanSelfApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels deeplink redirect before it opens the external URL', async () => {
|
||||
selfAppState.selfApp.deeplinkCallback =
|
||||
'https://callback.self.xyz/complete';
|
||||
|
||||
render(<ProofRequestStatusScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('primary-button').props.children).toBe(
|
||||
'Cancel',
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.press(screen.getByTestId('primary-button'));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000);
|
||||
});
|
||||
|
||||
expect(Linking.openURL).not.toHaveBeenCalled();
|
||||
expect(mockGoHome).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -90,9 +90,9 @@ export interface PassportData extends BaseIDData {
|
||||
passportMetadata?: PassportMetadata;
|
||||
}
|
||||
|
||||
// pending - pending didit verification
|
||||
// processing - didit verification completed and pending onchain confirmation
|
||||
// failed - didit verification failed
|
||||
// pending - pending KYC verification
|
||||
// processing - KYC verification completed and pending onchain confirmation
|
||||
// failed - KYC verification failed
|
||||
export type PendingKycStatus = 'pending' | 'processing' | 'failed';
|
||||
|
||||
export interface PendingKycVerification {
|
||||
|
||||
@@ -8,7 +8,7 @@ pragma solidity 0.8.28;
|
||||
* - E_PASSPORT (1): Electronic passports with NFC chip
|
||||
* - EU_ID_CARD (2): EU biometric ID cards with NFC chip
|
||||
* - AADHAAR (3): Indian Aadhaar identity documents
|
||||
* - KYC (4): African identity documents via SumSub
|
||||
* - KYC (4): KYC-backed identity documents
|
||||
*/
|
||||
library AttestationId {
|
||||
/// @notice Identifier for an E-PASSPORT attestation (electronic passports with NFC chip).
|
||||
@@ -20,6 +20,6 @@ library AttestationId {
|
||||
/// @notice Identifier for an AADHAAR attestation (Indian Aadhaar identity documents).
|
||||
bytes32 constant AADHAAR = bytes32(uint256(3));
|
||||
|
||||
/// @notice Identifier for a KYC attestation (African identity documents via SumSub).
|
||||
/// @notice Identifier for a KYC attestation (KYC-backed identity documents).
|
||||
bytes32 constant KYC = bytes32(uint256(4));
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@sentry/react": "^9.32.0",
|
||||
"@sentry/react-native": "7.0.0",
|
||||
"@stablelib/cbor": "^2.0.1",
|
||||
"@sumsub/react-native-mobilesdk-module": "1.40.2",
|
||||
"@didit-protocol/sdk-react-native": "^3.2.8",
|
||||
"@tamagui/animations-css": "1.126.14",
|
||||
"@tamagui/animations-react-native": "1.126.14",
|
||||
"@tamagui/config": "1.126.14",
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
"@noble/curves": "1.9.7",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@swc/core": "1.7.36",
|
||||
"@tamagui/animations-react-native": "1.126.14",
|
||||
"@tamagui/toast": "1.126.14",
|
||||
"@tamagui/animations-react-native": "1.144.4",
|
||||
"@tamagui/toast": "1.144.4",
|
||||
"@types/node": "^22.18.3",
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -68,6 +68,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-native-passkey": "^3.3.3",
|
||||
"react-native-blur-effect": "1.1.3",
|
||||
"react-native-webview": "13.16.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -88,7 +89,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"packageManager": "yarn@4.13.0",
|
||||
"engines": {
|
||||
"node": ">=22 <23"
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"eslint-plugin-sort-exports": "^0.9.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^25.0.1",
|
||||
"lottie-react-native": "7.2.2",
|
||||
"lottie-react-native": "7.3.6",
|
||||
"poseidon-lite": "^0.3.0",
|
||||
"prettier": "^3.5.3",
|
||||
"react": "^18.3.1",
|
||||
@@ -211,9 +211,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-async-storage/async-storage": ">=1.0.0",
|
||||
"lottie-react-native": "7.2.2",
|
||||
"lottie-react-native": "^7.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-native": ">=0.76.0 <0.78.0",
|
||||
"react-native-blur-effect": "^1.1.3",
|
||||
"react-native-get-random-values": ">=1.0.0",
|
||||
"react-native-haptic-feedback": "*",
|
||||
"react-native-keychain": ">=8.0.0",
|
||||
@@ -225,6 +226,9 @@
|
||||
"@react-native-async-storage/async-storage": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-blur-effect": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-get-random-values": {
|
||||
"optional": true
|
||||
},
|
||||
|
||||
@@ -13,11 +13,11 @@ import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
|
||||
import type { KycProviderResult } from '../../types/kycProvider';
|
||||
import { buildKycDocument } from '../../utils/buildKycDocument';
|
||||
import { waitForAttestation } from '../../utils/diditAttestation';
|
||||
import { createDiditSession, launchDiditWebSdk } from '../../utils/diditProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import { waitForKycAttestation } from '../../utils/kycAttestation';
|
||||
import { createKycSession, launchKycWebSdk } from '../../utils/kycProvider';
|
||||
|
||||
const CONTAINER_ID = 'didit-sdk-container';
|
||||
const CONTAINER_ID = 'kyc-sdk-container';
|
||||
|
||||
type Phase = 'loading' | 'active' | 'waiting' | 'error';
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ProviderLaunchScreen: React.FC = () => {
|
||||
|
||||
const defaultNextPath = nextPath ?? '/onboarding/provider-result';
|
||||
const isTunnelFlow = defaultNextPath.startsWith('/tunnel/') || backPath?.startsWith('/tunnel/') === true;
|
||||
const verificationId = ctxVerificationId ?? `didit-${Date.now()}`;
|
||||
const verificationId = ctxVerificationId ?? `kyc-${Date.now()}`;
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
@@ -57,7 +57,7 @@ export const ProviderLaunchScreen: React.FC = () => {
|
||||
|
||||
if ((result.status === 'success' || result.status === 'partial') && sessionIdRef.current) {
|
||||
setPhase('waiting');
|
||||
const attestationResult = await waitForAttestation(sessionIdRef.current);
|
||||
const attestationResult = await waitForKycAttestation(sessionIdRef.current);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
@@ -163,12 +163,12 @@ export const ProviderLaunchScreen: React.FC = () => {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const session = await createDiditSession(controller.signal);
|
||||
const session = await createKycSession(controller.signal);
|
||||
if (cancelled) return;
|
||||
|
||||
sessionIdRef.current = session.sessionId;
|
||||
|
||||
const destroy = await launchDiditWebSdk({
|
||||
const destroy = await launchKycWebSdk({
|
||||
url: session.url,
|
||||
containerId: CONTAINER_ID,
|
||||
verificationId,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { io } from 'socket.io-client';
|
||||
|
||||
import type { KycProviderAttestation } from '../types/kycProvider';
|
||||
|
||||
const DIDIT_TEE_URL = import.meta.env.VITE_DIDIT_TEE_URL ?? 'https://kyc.self.xyz';
|
||||
const KYC_TEE_URL = import.meta.env.VITE_KYC_TEE_URL ?? 'https://kyc.self.xyz';
|
||||
|
||||
const ATTESTATION_TIMEOUT_MS = 120_000; // 2 minutes
|
||||
|
||||
@@ -22,9 +22,9 @@ export interface AttestationResult {
|
||||
*
|
||||
* After receiving data, emits `ack_success` to trigger session deletion on the TEE.
|
||||
*/
|
||||
export function waitForAttestation(sessionId: string, signal?: AbortSignal): Promise<AttestationResult> {
|
||||
export function waitForKycAttestation(sessionId: string, signal?: AbortSignal): Promise<AttestationResult> {
|
||||
return new Promise(resolve => {
|
||||
const socket = io(DIDIT_TEE_URL, {
|
||||
const socket = io(KYC_TEE_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
@@ -6,9 +6,9 @@ import type { KycProviderResult } from '../types/kycProvider';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
const DIDIT_TEE_URL = import.meta.env.VITE_DIDIT_TEE_URL ?? 'https://kyc.self.xyz';
|
||||
const KYC_TEE_URL = import.meta.env.VITE_KYC_TEE_URL ?? 'https://kyc.self.xyz';
|
||||
|
||||
export interface DiditLaunchConfig {
|
||||
export interface KycLaunchConfig {
|
||||
url: string;
|
||||
containerId: string;
|
||||
verificationId: string;
|
||||
@@ -17,7 +17,7 @@ export interface DiditLaunchConfig {
|
||||
onEvent?: (event: string, payload: unknown) => void;
|
||||
}
|
||||
|
||||
export interface DiditSession {
|
||||
export interface KycSession {
|
||||
sessionId: string;
|
||||
sessionToken: string;
|
||||
url: string;
|
||||
@@ -33,14 +33,14 @@ function buildProviderResult(verificationId: string, overrides: Partial<KycProvi
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDiditSession(signal?: AbortSignal): Promise<DiditSession> {
|
||||
export async function createKycSession(signal?: AbortSignal): Promise<KycSession> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${DIDIT_TEE_URL}/session`, {
|
||||
const response = await fetch(`${KYC_TEE_URL}/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
@@ -50,27 +50,27 @@ export async function createDiditSession(signal?: AbortSignal): Promise<DiditSes
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create Didit session (HTTP ${response.status})`);
|
||||
throw new Error(`Failed to create KYC session (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const body: unknown = await response.json();
|
||||
if (typeof body === 'string') {
|
||||
return JSON.parse(body) as DiditSession;
|
||||
return JSON.parse(body) as KycSession;
|
||||
}
|
||||
return body as DiditSession;
|
||||
return body as KycSession;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
throw new Error(`Didit session request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
|
||||
throw new Error(`KYC session request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
throw new Error(`Failed to create Didit session: ${err.message}`);
|
||||
throw new Error(`Failed to create KYC session: ${err.message}`);
|
||||
}
|
||||
throw new Error('Failed to create Didit session: Unknown error');
|
||||
throw new Error('Failed to create KYC session: Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchDiditWebSdk(config: DiditLaunchConfig): Promise<() => void> {
|
||||
export async function launchKycWebSdk(config: KycLaunchConfig): Promise<() => void> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { DiditSdk } = (await import('@didit-protocol/sdk-web')) as any;
|
||||
|
||||
@@ -56,7 +56,7 @@ Platform adapters implement this — React Native uses keychain, web uses volati
|
||||
| Service | Method | Purpose | Token Persistence |
|
||||
|----------------|-------------|----------------------|-------------------|
|
||||
| Google Drive | OAuth 2.0 | Mnemonic backup | Per-session |
|
||||
| Sumsub KYC | API token | Identity verification | Per-session |
|
||||
| Didit KYC | API token | Identity verification | Per-session |
|
||||
| Turnkey | Google OAuth | Wallet backup | DISABLED |
|
||||
|
||||
## Keychain Security Levels (Adaptive)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| Firebase Remote Config | `@react-native-firebase/remote-config` | Feature flags, remote config | `app/src/providers/remoteConfigProvider.tsx` |
|
||||
| Segment | `@segment/analytics-react-native` | Analytics / event tracking | `app/src/services/analytics.ts` |
|
||||
| Sentry | `@sentry/react-native` | Error tracking / crash reporting | `app/src/config/sentry.ts` |
|
||||
| Sumsub | `@sumsub/react-native-mobilesdk-module` | KYC identity verification | `app/src/integrations/sumsub/` |
|
||||
| Didit | `@didit-protocol/sdk-react-native` | KYC identity verification | `app/src/integrations/kyc/` |
|
||||
| Google Drive | `@robinbobin/react-native-google-drive-api-wrapper` | Cloud mnemonic backup | `app/src/services/cloud-backup/google.ts` |
|
||||
| Google OAuth | `react-native-app-auth` | OAuth 2.0 for Drive access | `app/src/services/cloud-backup/google.ts` |
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
- Provides feature flags and runtime configuration
|
||||
- Fetched on app start, cached locally
|
||||
|
||||
### KYC (Sumsub)
|
||||
### KYC (Didit)
|
||||
- Access token fetched per-session via TEE endpoint
|
||||
- 30-second timeout on token fetch
|
||||
- Token not persisted — fresh for each KYC session
|
||||
|
||||
@@ -50,4 +50,4 @@ Identity verification wallet using NFC passport scanning + zero-knowledge proofs
|
||||
- No Express/Nest/etc server
|
||||
- Smart contracts serve as "backend"
|
||||
- SDK core package handles proving/verification
|
||||
- External services: Firebase, Segment, Sentry, Sumsub KYC
|
||||
- External services: Firebase, Segment, Sentry, Didit KYC
|
||||
|
||||
@@ -127,10 +127,10 @@ Any other domain request returns a `DOMAIN_NOT_FOUND` error response.
|
||||
|
||||
## Related Specs
|
||||
|
||||
| Spec | Relationship |
|
||||
| ---------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| [SDK Overview](../../OVERVIEW.md) | Parent architecture |
|
||||
| [Native Shells Lite](../native-shells-lite/SPEC.md) | Sibling — serves non-KMP consumers |
|
||||
| [Paused Native Shells (KMP)](../../paused/native-shells/SPEC.md) | Historical KMP work — validated foundation |
|
||||
| [Build Pipeline](../build-pipeline/SPEC.md) | Downstream — bundles webview-app into native assets |
|
||||
| Spec | Relationship |
|
||||
| ---------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| [SDK Overview](../../OVERVIEW.md) | Parent architecture |
|
||||
| [Native Shells Lite](../native-shells-lite/SPEC.md) | Sibling — serves non-KMP consumers |
|
||||
| [Paused Native Shells (KMP)](../../paused/native-shells/SPEC.md) | Historical KMP work — validated foundation |
|
||||
| [Build Pipeline](../build-pipeline/SPEC.md) | Downstream — bundles webview-app into native assets |
|
||||
| [SDK Distribution — SD-06](../sdk-distribution/SPEC.md) | Downstream — remote publishing after KR-03 validates artifacts |
|
||||
|
||||
@@ -58,13 +58,13 @@
|
||||
|
||||
## Backlog
|
||||
|
||||
| ID | Title | Status | Priority | Depends On | Plan | PR |
|
||||
| ----- | -------------------------------- | ------ | -------- | ---------- | ------------------------------------------------------------------------------------ | --- |
|
||||
| SD-01 | Android hosted URL loading | Ready | High | NSL-01 | [plans/SD-01-android-hosted-url.md](./plans/SD-01-android-hosted-url.md) | — |
|
||||
| SD-02 | iOS hosted URL loading | Ready | High | NSL-02 | [plans/SD-02-ios-hosted-url.md](./plans/SD-02-ios-hosted-url.md) | — |
|
||||
| SD-03 | WebView app hosting setup | Ready | High | — | [plans/SD-03-hosting-setup.md](./plans/SD-03-hosting-setup.md) | — |
|
||||
| SD-04 | Android Maven publishing | Ready | Medium | SD-01 | [plans/SD-04-android-maven-publishing.md](./plans/SD-04-android-maven-publishing.md) | — |
|
||||
| SD-05 | iOS publishing (SPM + CocoaPods) | Ready | Medium | SD-02 | [plans/SD-05-ios-spm-publishing.md](./plans/SD-05-ios-spm-publishing.md) | — |
|
||||
| ID | Title | Status | Priority | Depends On | Plan | PR |
|
||||
| ----- | --------------------------------- | ------ | -------- | ---------- | ------------------------------------------------------------------------------------ | --- |
|
||||
| SD-01 | Android hosted URL loading | Ready | High | NSL-01 | [plans/SD-01-android-hosted-url.md](./plans/SD-01-android-hosted-url.md) | — |
|
||||
| SD-02 | iOS hosted URL loading | Ready | High | NSL-02 | [plans/SD-02-ios-hosted-url.md](./plans/SD-02-ios-hosted-url.md) | — |
|
||||
| SD-03 | WebView app hosting setup | Ready | High | — | [plans/SD-03-hosting-setup.md](./plans/SD-03-hosting-setup.md) | — |
|
||||
| SD-04 | Android Maven publishing | Ready | Medium | SD-01 | [plans/SD-04-android-maven-publishing.md](./plans/SD-04-android-maven-publishing.md) | — |
|
||||
| SD-05 | iOS publishing (SPM + CocoaPods) | Ready | Medium | SD-02 | [plans/SD-05-ios-spm-publishing.md](./plans/SD-05-ios-spm-publishing.md) | — |
|
||||
| SD-06 | KMP remote publishing (Maven+SPM) | Ready | Medium | KR-03 | [plans/SD-06-kmp-remote-publishing.md](./plans/SD-06-kmp-remote-publishing.md) | — |
|
||||
|
||||
Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done`
|
||||
@@ -105,11 +105,11 @@ Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done`
|
||||
|
||||
## Related Specs
|
||||
|
||||
| Spec | Relationship |
|
||||
| --------------------------------------------------- | -------------------------------------------------------- |
|
||||
| [SDK Overview](../../OVERVIEW.md) | Parent architecture |
|
||||
| [Native Shells Lite](../native-shells-lite/SPEC.md) | Upstream — shells must exist before distribution changes |
|
||||
| [Build Pipeline](../build-pipeline/SPEC.md) | Sibling — bundle script retained for local dev only |
|
||||
| [WebView Spec](../webview/SPEC.md) | Upstream — produces the web app being hosted |
|
||||
| [SDK Core Spec](../sdk-core/SPEC.md) | Sibling — engine consumed by hosted web app |
|
||||
| Spec | Relationship |
|
||||
| --------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| [SDK Overview](../../OVERVIEW.md) | Parent architecture |
|
||||
| [Native Shells Lite](../native-shells-lite/SPEC.md) | Upstream — shells must exist before distribution changes |
|
||||
| [Build Pipeline](../build-pipeline/SPEC.md) | Sibling — bundle script retained for local dev only |
|
||||
| [WebView Spec](../webview/SPEC.md) | Upstream — produces the web app being hosted |
|
||||
| [SDK Core Spec](../sdk-core/SPEC.md) | Sibling — engine consumed by hosted web app |
|
||||
| [KMP Revival](../kmp-revival/SPEC.md) | Upstream — KR-03 validates artifacts before SD-06 publishes |
|
||||
|
||||
Reference in New Issue
Block a user