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:
Justin Hernandez
2026-04-09 15:11:49 -07:00
committed by GitHub
93 changed files with 2880 additions and 5270 deletions

View File

@@ -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.

View File

@@ -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 }}

View File

@@ -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
View File

@@ -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

View File

@@ -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 (1k3k LOC). If a spec would exceed that, split it.
- **Mark items as required vs optional.** Don't let agents infer priority.

View File

@@ -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"

View File

@@ -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"

View File

@@ -10,4 +10,4 @@ IS_TEST_BUILD=
MIXPANEL_NFC_PROJECT_TOKEN=
SEGMENT_KEY=
SENTRY_DSN=
DIDIT_TEE_URL=
KYC_TEE_URL=

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -29,8 +29,8 @@ interface FeedbackModalProps {
}
const FeedbackModal: React.FC<FeedbackModalProps> = ({ visible, onClose }) => {
const handleSupportForm = async () => {
await openSupportForm();
const handleSupportForm = () => {
openSupportForm();
onClose();
};

View File

@@ -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;

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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 =

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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),
);
});
},
[

View 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;

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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]),
);
};

View File

@@ -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)
*/

View File

@@ -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';

View File

@@ -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;
};

View File

@@ -8,7 +8,7 @@ export interface ApplicantInfoSerialized {
pubkey: Array<string>;
}
export interface DiditVerificationResult {
export interface KycVerificationResult {
type: 'completed' | 'cancelled' | 'failed';
session?: {
status: string;

View File

@@ -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,
},

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
},

View File

@@ -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,
},
};

View File

@@ -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;
};
/**

View File

@@ -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.

View File

@@ -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()) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
);

View 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;

View File

@@ -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();

View File

@@ -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 }}>

View File

@@ -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%',
},
});

View File

@@ -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"
/>

View File

@@ -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]);

View 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
);
}

View File

@@ -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}>

View File

@@ -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 }}

View File

@@ -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) => {

View File

@@ -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 }}

View File

@@ -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', {

View File

@@ -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', {

View File

@@ -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.',

View File

@@ -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>
);
};

View File

@@ -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,
},
});

View File

@@ -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,

View File

@@ -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%',

View File

@@ -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(() => {

View File

@@ -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: {

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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),
);
}
};

View File

@@ -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 {

View File

@@ -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();
}
});
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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');

View File

@@ -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);
});
});
});

View 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',
});
});
});

View File

@@ -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,
});

View File

@@ -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',

View File

@@ -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();
});
});
});

View 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);
});
});

View File

@@ -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']);
});
});
});

View File

@@ -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(),

View File

@@ -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();
});
});

View File

@@ -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 {

View File

@@ -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));
}

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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
},

View File

@@ -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,

View File

@@ -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'],
});

View File

@@ -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;

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 |

View File

@@ -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 |

3212
yarn.lock

File diff suppressed because it is too large Load Diff