mirror of
https://github.com/selfxyz/self.git
synced 2026-01-09 14:48:06 -05:00
Start of Web App (#689)
This commit is contained in:
@@ -24,6 +24,7 @@ paths = [
|
|||||||
'''common/src/constants/mockCertificates.ts''',
|
'''common/src/constants/mockCertificates.ts''',
|
||||||
'''Database.refactorlog''',
|
'''Database.refactorlog''',
|
||||||
'''vendor''',
|
'''vendor''',
|
||||||
|
'''.*tamagui-components\.config\.cjs$''',
|
||||||
]
|
]
|
||||||
|
|
||||||
[[rules]]
|
[[rules]]
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ module.exports = {
|
|||||||
'android/',
|
'android/',
|
||||||
'deployments/',
|
'deployments/',
|
||||||
'node_modules/',
|
'node_modules/',
|
||||||
|
'web/dist/',
|
||||||
|
'.tamagui/*',
|
||||||
'metro.config.cjs',
|
'metro.config.cjs',
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
3
app/.gitignore
vendored
3
app/.gitignore
vendored
@@ -89,3 +89,6 @@ yarn-error.log
|
|||||||
|
|
||||||
# Bundle analyzer source maps
|
# Bundle analyzer source maps
|
||||||
*-sourcemap.jsonandroid/.kotlin/errors/
|
*-sourcemap.jsonandroid/.kotlin/errors/
|
||||||
|
|
||||||
|
# web app
|
||||||
|
.tamagui/*
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ src/assets/animations/
|
|||||||
witnesscalc/
|
witnesscalc/
|
||||||
vendor/
|
vendor/
|
||||||
android/
|
android/
|
||||||
|
.tamagui/
|
||||||
|
web/dist/
|
||||||
*.md
|
*.md
|
||||||
|
|||||||
16
app/env.ts
Normal file
16
app/env.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
/* 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 GOOGLE_SIGNIN_WEB_CLIENT_ID =
|
||||||
|
process.env.GOOGLE_SIGNIN_WEB_CLIENT_ID;
|
||||||
|
export const SENTRY_DSN = process.env.SENTRY_DSN;
|
||||||
|
export const SEGMENT_KEY = process.env.SEGMENT_KEY;
|
||||||
|
export const ENABLE_DEBUG_LOGS = process.env.ENABLE_DEBUG_LOGS === 'true';
|
||||||
|
export const DEFAULT_PNUMBER = undefined;
|
||||||
|
export const DEFAULT_DOB = undefined;
|
||||||
|
export const DEFAULT_DOE = undefined;
|
||||||
|
export const MIXPANEL_NFC_PROJECT_TOKEN = undefined;
|
||||||
@@ -46,7 +46,10 @@
|
|||||||
"tag:remove": "node scripts/tag.js remove",
|
"tag:remove": "node scripts/tag.js remove",
|
||||||
"test": "jest --passWithNoTests",
|
"test": "jest --passWithNoTests",
|
||||||
"test:fastlane": "bundle exec ruby -Itest fastlane/test/helpers_test.rb",
|
"test:fastlane": "bundle exec ruby -Itest fastlane/test/helpers_test.rb",
|
||||||
"types": "tsc --noEmit"
|
"types": "tsc --noEmit",
|
||||||
|
"web": "vite",
|
||||||
|
"web:build": "vite build",
|
||||||
|
"web:preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.4",
|
"@babel/runtime": "^7.27.4",
|
||||||
@@ -66,11 +69,16 @@
|
|||||||
"@segment/analytics-react-native": "^2.21.0",
|
"@segment/analytics-react-native": "^2.21.0",
|
||||||
"@segment/sovran-react-native": "^1.1.3",
|
"@segment/sovran-react-native": "^1.1.3",
|
||||||
"@selfxyz/common": "workspace:^",
|
"@selfxyz/common": "workspace:^",
|
||||||
|
"@sentry/react": "^9.32.0",
|
||||||
"@sentry/react-native": "6.10.0",
|
"@sentry/react-native": "6.10.0",
|
||||||
"@stablelib/cbor": "^2.0.1",
|
"@stablelib/cbor": "^2.0.1",
|
||||||
|
"@tamagui/animations-css": "^1.129.3",
|
||||||
|
"@tamagui/animations-react-native": "^1.129.3",
|
||||||
"@tamagui/config": "1.126.14",
|
"@tamagui/config": "1.126.14",
|
||||||
"@tamagui/lucide-icons": "1.126.14",
|
"@tamagui/lucide-icons": "1.126.14",
|
||||||
"@tamagui/toast": "^1.127.2",
|
"@tamagui/toast": "^1.127.2",
|
||||||
|
"@tamagui/vite-plugin": "1.126.14",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
"@xstate/react": "^5.0.3",
|
"@xstate/react": "^5.0.3",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"asn1js": "^3.0.5",
|
"asn1js": "^3.0.5",
|
||||||
@@ -80,6 +88,7 @@
|
|||||||
"ethers": "^6.11.0",
|
"ethers": "^6.11.0",
|
||||||
"expo-modules-core": "^2.2.1",
|
"expo-modules-core": "^2.2.1",
|
||||||
"js-sha512": "^0.9.0",
|
"js-sha512": "^0.9.0",
|
||||||
|
"lottie-react": "^2.4.1",
|
||||||
"lottie-react-native": "7.2.2",
|
"lottie-react-native": "7.2.2",
|
||||||
"msgpack-lite": "^0.1.26",
|
"msgpack-lite": "^0.1.26",
|
||||||
"node-forge": "^1.3.1",
|
"node-forge": "^1.3.1",
|
||||||
@@ -87,6 +96,7 @@
|
|||||||
"pkijs": "^3.2.4",
|
"pkijs": "^3.2.4",
|
||||||
"poseidon-lite": "^0.2.0",
|
"poseidon-lite": "^0.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
"react-native": "0.75.4",
|
"react-native": "0.75.4",
|
||||||
"react-native-app-auth": "^8.0.3",
|
"react-native-app-auth": "^8.0.3",
|
||||||
"react-native-biometrics": "^3.0.1",
|
"react-native-biometrics": "^3.0.1",
|
||||||
@@ -106,9 +116,13 @@
|
|||||||
"react-native-screens": "4.9.0",
|
"react-native-screens": "4.9.0",
|
||||||
"react-native-sqlite-storage": "^6.0.1",
|
"react-native-sqlite-storage": "^6.0.1",
|
||||||
"react-native-svg": "^15.11.1",
|
"react-native-svg": "^15.11.1",
|
||||||
|
"react-native-svg-web": "^1.0.9",
|
||||||
|
"react-native-web": "^0.19.0",
|
||||||
|
"react-qr-barcode-scanner": "^2.1.7",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"tamagui": "1.126.14",
|
"tamagui": "1.126.14",
|
||||||
"uuid": "^11.0.5",
|
"uuid": "^11.0.5",
|
||||||
|
"vite-plugin-svgr": "^4.3.0",
|
||||||
"xstate": "^5.19.2",
|
"xstate": "^5.19.2",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
@@ -135,7 +149,9 @@
|
|||||||
"@types/react-native": "^0.73.0",
|
"@types/react-native": "^0.73.0",
|
||||||
"@types/react-native-dotenv": "^0.2.0",
|
"@types/react-native-dotenv": "^0.2.0",
|
||||||
"@types/react-native-sqlite-storage": "^6.0.5",
|
"@types/react-native-sqlite-storage": "^6.0.5",
|
||||||
|
"@types/react-native-web": "^0",
|
||||||
"@types/react-test-renderer": "^18",
|
"@types/react-test-renderer": "^18",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
"eslint-plugin-header": "^3.1.1",
|
"eslint-plugin-header": "^3.1.1",
|
||||||
@@ -148,7 +164,8 @@
|
|||||||
"react-native-svg-transformer": "^1.5.0",
|
"react-native-svg-transformer": "^1.5.0",
|
||||||
"react-test-renderer": "^18.3.1",
|
"react-test-renderer": "^18.3.1",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^7.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
267
app/src/RemoteConfig.shared.ts
Normal file
267
app/src/RemoteConfig.shared.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
// Shared types and constants for RemoteConfig
|
||||||
|
|
||||||
|
export type FeatureFlagValue = string | boolean | number;
|
||||||
|
|
||||||
|
export interface LocalOverride {
|
||||||
|
[key: string]: FeatureFlagValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCAL_OVERRIDES_KEY = 'feature_flag_overrides';
|
||||||
|
|
||||||
|
export const defaultFlags: Record<string, FeatureFlagValue> = {
|
||||||
|
aesop: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FeatureFlagInfo {
|
||||||
|
key: string;
|
||||||
|
remoteValue?: FeatureFlagValue;
|
||||||
|
overrideValue?: FeatureFlagValue;
|
||||||
|
value: FeatureFlagValue;
|
||||||
|
source: string;
|
||||||
|
type: 'boolean' | 'string' | 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared interfaces for platform-specific implementations
|
||||||
|
export interface StorageBackend {
|
||||||
|
getItem(key: string): Promise<string | null>;
|
||||||
|
setItem(key: string, value: string): Promise<void>;
|
||||||
|
removeItem(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteConfigBackend {
|
||||||
|
getValue(key: string): {
|
||||||
|
asBoolean(): boolean;
|
||||||
|
asNumber(): number;
|
||||||
|
asString(): string;
|
||||||
|
getSource(): string;
|
||||||
|
};
|
||||||
|
getAll(): Record<string, any>;
|
||||||
|
setDefaults(defaults: Record<string, any>): Promise<void> | void;
|
||||||
|
setConfigSettings(settings: any): Promise<void> | void;
|
||||||
|
fetchAndActivate(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to detect and parse remote config values
|
||||||
|
export const getRemoteConfigValue = (
|
||||||
|
remoteConfig: RemoteConfigBackend,
|
||||||
|
key: string,
|
||||||
|
defaultValue: FeatureFlagValue,
|
||||||
|
): FeatureFlagValue => {
|
||||||
|
const configValue = remoteConfig.getValue(key);
|
||||||
|
|
||||||
|
if (typeof defaultValue === 'boolean') {
|
||||||
|
return configValue.asBoolean();
|
||||||
|
} else if (typeof defaultValue === 'number') {
|
||||||
|
return configValue.asNumber();
|
||||||
|
} else if (typeof defaultValue === 'string') {
|
||||||
|
return configValue.asString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to infer type from the remote config value
|
||||||
|
const stringValue = configValue.asString();
|
||||||
|
if (stringValue === 'true' || stringValue === 'false') {
|
||||||
|
return configValue.asBoolean();
|
||||||
|
}
|
||||||
|
if (!Number.isNaN(Number(stringValue)) && stringValue !== '') {
|
||||||
|
return configValue.asNumber();
|
||||||
|
}
|
||||||
|
return stringValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local override management
|
||||||
|
export const getLocalOverrides = async (
|
||||||
|
storage: StorageBackend,
|
||||||
|
): Promise<LocalOverride> => {
|
||||||
|
try {
|
||||||
|
const overrides = await storage.getItem(LOCAL_OVERRIDES_KEY);
|
||||||
|
if (!overrides) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return JSON.parse(overrides);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get local overrides:', error);
|
||||||
|
|
||||||
|
// If JSON parsing fails, clear the corrupt data
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
try {
|
||||||
|
await storage.removeItem(LOCAL_OVERRIDES_KEY);
|
||||||
|
} catch (removeError) {
|
||||||
|
console.error('Failed to clear corrupt local overrides:', removeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setLocalOverride = async (
|
||||||
|
storage: StorageBackend,
|
||||||
|
flag: string,
|
||||||
|
value: FeatureFlagValue,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const overrides = await getLocalOverrides(storage);
|
||||||
|
overrides[flag] = value;
|
||||||
|
await storage.setItem(LOCAL_OVERRIDES_KEY, JSON.stringify(overrides));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set local override:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearLocalOverride = async (
|
||||||
|
storage: StorageBackend,
|
||||||
|
flag: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const overrides = await getLocalOverrides(storage);
|
||||||
|
delete overrides[flag];
|
||||||
|
await storage.setItem(LOCAL_OVERRIDES_KEY, JSON.stringify(overrides));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear local override:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllLocalOverrides = async (
|
||||||
|
storage: StorageBackend,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await storage.removeItem(LOCAL_OVERRIDES_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear all local overrides:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initRemoteConfig = async (
|
||||||
|
remoteConfig: RemoteConfigBackend,
|
||||||
|
): Promise<void> => {
|
||||||
|
await remoteConfig.setDefaults(defaultFlags);
|
||||||
|
await remoteConfig.setConfigSettings({
|
||||||
|
minimumFetchIntervalMillis: __DEV__ ? 0 : 3600000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await remoteConfig.fetchAndActivate();
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Remote config fetch failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFeatureFlag = async <T extends FeatureFlagValue>(
|
||||||
|
remoteConfig: RemoteConfigBackend,
|
||||||
|
storage: StorageBackend,
|
||||||
|
flag: string,
|
||||||
|
defaultValue: T,
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
// Check local overrides first
|
||||||
|
const localOverrides = await getLocalOverrides(storage);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(localOverrides, flag)) {
|
||||||
|
return localOverrides[flag] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return default value for string flags
|
||||||
|
if (typeof defaultValue === 'string') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to remote config for number and boolean flags
|
||||||
|
return getRemoteConfigValue(remoteConfig, flag, defaultValue) as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get feature flag:', error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllFeatureFlags = async (
|
||||||
|
remoteConfig: RemoteConfigBackend,
|
||||||
|
storage: StorageBackend,
|
||||||
|
): Promise<FeatureFlagInfo[]> => {
|
||||||
|
try {
|
||||||
|
const keys = remoteConfig.getAll();
|
||||||
|
const localOverrides = await getLocalOverrides(storage);
|
||||||
|
|
||||||
|
// Get all remote/default flags
|
||||||
|
const remoteFlags = Object.keys(keys).map(key => {
|
||||||
|
const configValue = keys[key];
|
||||||
|
|
||||||
|
// Try to determine the type from default flags or infer from value
|
||||||
|
const defaultValue = defaultFlags[key];
|
||||||
|
const remoteVal =
|
||||||
|
defaultValue !== undefined
|
||||||
|
? getRemoteConfigValue(remoteConfig, key, defaultValue)
|
||||||
|
: configValue.asString(); // Default to string if no default defined
|
||||||
|
|
||||||
|
const hasLocalOverride = Object.prototype.hasOwnProperty.call(
|
||||||
|
localOverrides,
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
const overrideVal = hasLocalOverride ? localOverrides[key] : undefined;
|
||||||
|
const effectiveVal = hasLocalOverride ? overrideVal! : remoteVal;
|
||||||
|
|
||||||
|
// Determine type
|
||||||
|
const type =
|
||||||
|
typeof effectiveVal === 'boolean'
|
||||||
|
? 'boolean'
|
||||||
|
: typeof effectiveVal === 'number'
|
||||||
|
? 'number'
|
||||||
|
: 'string';
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
remoteValue: remoteVal,
|
||||||
|
overrideValue: overrideVal,
|
||||||
|
value: effectiveVal,
|
||||||
|
type: type as 'boolean' | 'string' | 'number',
|
||||||
|
source: hasLocalOverride
|
||||||
|
? 'Local Override'
|
||||||
|
: configValue.getSource() === 'remote'
|
||||||
|
? 'Remote Config'
|
||||||
|
: configValue.getSource() === 'default'
|
||||||
|
? 'Default'
|
||||||
|
: configValue.getSource() === 'static'
|
||||||
|
? 'Static'
|
||||||
|
: 'Unknown',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add any local overrides that don't exist in remote config
|
||||||
|
const localOnlyFlags = Object.keys(localOverrides)
|
||||||
|
.filter(key => !Object.prototype.hasOwnProperty.call(keys, key))
|
||||||
|
.map(key => {
|
||||||
|
const value = localOverrides[key];
|
||||||
|
const type =
|
||||||
|
typeof value === 'boolean'
|
||||||
|
? 'boolean'
|
||||||
|
: typeof value === 'number'
|
||||||
|
? 'number'
|
||||||
|
: 'string';
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
remoteValue: undefined,
|
||||||
|
overrideValue: value,
|
||||||
|
value: value,
|
||||||
|
type: type as 'boolean' | 'string' | 'number',
|
||||||
|
source: 'Local Override',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...remoteFlags, ...localOnlyFlags].sort((a, b) =>
|
||||||
|
a.key.localeCompare(b.key),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get all feature flags:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refreshRemoteConfig = async (
|
||||||
|
remoteConfig: RemoteConfigBackend,
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await remoteConfig.fetchAndActivate();
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Remote config refresh failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,228 +3,77 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import remoteConfig from '@react-native-firebase/remote-config';
|
import remoteConfig from '@react-native-firebase/remote-config';
|
||||||
|
|
||||||
export type FeatureFlagValue = string | boolean | number;
|
import {
|
||||||
|
clearAllLocalOverrides as clearAllLocalOverridesShared,
|
||||||
|
clearLocalOverride as clearLocalOverrideShared,
|
||||||
|
FeatureFlagValue,
|
||||||
|
getAllFeatureFlags as getAllFeatureFlagsShared,
|
||||||
|
getFeatureFlag as getFeatureFlagShared,
|
||||||
|
getLocalOverrides as getLocalOverridesShared,
|
||||||
|
initRemoteConfig as initRemoteConfigShared,
|
||||||
|
refreshRemoteConfig as refreshRemoteConfigShared,
|
||||||
|
RemoteConfigBackend,
|
||||||
|
setLocalOverride as setLocalOverrideShared,
|
||||||
|
StorageBackend,
|
||||||
|
} from './RemoteConfig.shared';
|
||||||
|
|
||||||
interface LocalOverride {
|
// Mobile-specific storage backend using AsyncStorage
|
||||||
[key: string]: FeatureFlagValue;
|
const mobileStorageBackend: StorageBackend = {
|
||||||
}
|
getItem: async (key: string): Promise<string | null> => {
|
||||||
|
return await AsyncStorage.getItem(key);
|
||||||
const LOCAL_OVERRIDES_KEY = 'feature_flag_overrides';
|
},
|
||||||
|
setItem: async (key: string, value: string): Promise<void> => {
|
||||||
const defaultFlags: Record<string, FeatureFlagValue> = {
|
await AsyncStorage.setItem(key, value);
|
||||||
aesop: false,
|
},
|
||||||
|
removeItem: async (key: string): Promise<void> => {
|
||||||
|
await AsyncStorage.removeItem(key);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to detect and parse remote config values
|
// Mobile-specific remote config backend using React Native Firebase
|
||||||
const getRemoteConfigValue = (
|
const mobileRemoteConfigBackend: RemoteConfigBackend = {
|
||||||
key: string,
|
getValue: (key: string) => {
|
||||||
defaultValue: FeatureFlagValue,
|
return remoteConfig().getValue(key);
|
||||||
): FeatureFlagValue => {
|
},
|
||||||
const configValue = remoteConfig().getValue(key);
|
getAll: () => {
|
||||||
|
return remoteConfig().getAll();
|
||||||
if (typeof defaultValue === 'boolean') {
|
},
|
||||||
return configValue.asBoolean();
|
setDefaults: async (defaults: Record<string, any>) => {
|
||||||
} else if (typeof defaultValue === 'number') {
|
await remoteConfig().setDefaults(defaults);
|
||||||
return configValue.asNumber();
|
},
|
||||||
} else if (typeof defaultValue === 'string') {
|
setConfigSettings: async (settings: any) => {
|
||||||
return configValue.asString();
|
await remoteConfig().setConfigSettings(settings);
|
||||||
}
|
},
|
||||||
|
fetchAndActivate: async (): Promise<boolean> => {
|
||||||
// Fallback: try to infer type from the remote config value
|
return await remoteConfig().fetchAndActivate();
|
||||||
const stringValue = configValue.asString();
|
},
|
||||||
if (stringValue === 'true' || stringValue === 'false') {
|
|
||||||
return configValue.asBoolean();
|
|
||||||
}
|
|
||||||
if (!Number.isNaN(Number(stringValue)) && stringValue !== '') {
|
|
||||||
return configValue.asNumber();
|
|
||||||
}
|
|
||||||
return stringValue;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local override management
|
// Export the shared functions with mobile-specific backends
|
||||||
export const getLocalOverrides = async (): Promise<LocalOverride> => {
|
export const getLocalOverrides = () =>
|
||||||
try {
|
getLocalOverridesShared(mobileStorageBackend);
|
||||||
const overrides = await AsyncStorage.getItem(LOCAL_OVERRIDES_KEY);
|
export const setLocalOverride = (flag: string, value: FeatureFlagValue) =>
|
||||||
if (!overrides) {
|
setLocalOverrideShared(mobileStorageBackend, flag, value);
|
||||||
return {};
|
export const clearLocalOverride = (flag: string) =>
|
||||||
}
|
clearLocalOverrideShared(mobileStorageBackend, flag);
|
||||||
return JSON.parse(overrides);
|
export const clearAllLocalOverrides = () =>
|
||||||
} catch (error) {
|
clearAllLocalOverridesShared(mobileStorageBackend);
|
||||||
console.error('Failed to get local overrides:', error);
|
export const initRemoteConfig = () =>
|
||||||
|
initRemoteConfigShared(mobileRemoteConfigBackend);
|
||||||
// If JSON parsing fails, clear the corrupt data
|
export const getFeatureFlag = <T extends FeatureFlagValue>(
|
||||||
if (error instanceof SyntaxError) {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.removeItem(LOCAL_OVERRIDES_KEY);
|
|
||||||
} catch (removeError) {
|
|
||||||
console.error('Failed to clear corrupt local overrides:', removeError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setLocalOverride = async (
|
|
||||||
flag: string,
|
|
||||||
value: FeatureFlagValue,
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const overrides = await getLocalOverrides();
|
|
||||||
overrides[flag] = value;
|
|
||||||
await AsyncStorage.setItem(LOCAL_OVERRIDES_KEY, JSON.stringify(overrides));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to set local override:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearLocalOverride = async (flag: string): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const overrides = await getLocalOverrides();
|
|
||||||
delete overrides[flag];
|
|
||||||
await AsyncStorage.setItem(LOCAL_OVERRIDES_KEY, JSON.stringify(overrides));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear local override:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearAllLocalOverrides = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.removeItem(LOCAL_OVERRIDES_KEY);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear all local overrides:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initRemoteConfig = async () => {
|
|
||||||
await remoteConfig().setDefaults(defaultFlags);
|
|
||||||
await remoteConfig().setConfigSettings({
|
|
||||||
minimumFetchIntervalMillis: __DEV__ ? 0 : 3600000,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await remoteConfig().fetchAndActivate();
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Remote config fetch failed', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFeatureFlag = async <T extends FeatureFlagValue>(
|
|
||||||
flag: string,
|
flag: string,
|
||||||
defaultValue: T,
|
defaultValue: T,
|
||||||
): Promise<T> => {
|
) =>
|
||||||
try {
|
getFeatureFlagShared(
|
||||||
// Check local overrides first
|
mobileRemoteConfigBackend,
|
||||||
const localOverrides = await getLocalOverrides();
|
mobileStorageBackend,
|
||||||
if (Object.prototype.hasOwnProperty.call(localOverrides, flag)) {
|
flag,
|
||||||
return localOverrides[flag] as T;
|
defaultValue,
|
||||||
}
|
);
|
||||||
|
export const getAllFeatureFlags = () =>
|
||||||
|
getAllFeatureFlagsShared(mobileRemoteConfigBackend, mobileStorageBackend);
|
||||||
|
export const refreshRemoteConfig = () =>
|
||||||
|
refreshRemoteConfigShared(mobileRemoteConfigBackend);
|
||||||
|
|
||||||
// Return default value for string flags
|
// Re-export types for convenience
|
||||||
if (typeof defaultValue === 'string') {
|
export type { FeatureFlagValue } from './RemoteConfig.shared';
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to remote config for number and boolean flags
|
|
||||||
return getRemoteConfigValue(flag, defaultValue) as T;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get feature flag:', error);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllFeatureFlags = async (): Promise<
|
|
||||||
Array<{
|
|
||||||
key: string;
|
|
||||||
remoteValue?: FeatureFlagValue;
|
|
||||||
overrideValue?: FeatureFlagValue;
|
|
||||||
value: FeatureFlagValue;
|
|
||||||
source: string;
|
|
||||||
type: 'boolean' | 'string' | 'number';
|
|
||||||
}>
|
|
||||||
> => {
|
|
||||||
try {
|
|
||||||
const keys = remoteConfig().getAll();
|
|
||||||
const localOverrides = await getLocalOverrides();
|
|
||||||
|
|
||||||
// Get all remote/default flags
|
|
||||||
const remoteFlags = Object.keys(keys).map(key => {
|
|
||||||
const configValue = keys[key];
|
|
||||||
|
|
||||||
// Try to determine the type from default flags or infer from value
|
|
||||||
const defaultValue = defaultFlags[key];
|
|
||||||
const remoteVal =
|
|
||||||
defaultValue !== undefined
|
|
||||||
? getRemoteConfigValue(key, defaultValue)
|
|
||||||
: configValue.asString(); // Default to string if no default defined
|
|
||||||
|
|
||||||
const hasLocalOverride = Object.prototype.hasOwnProperty.call(
|
|
||||||
localOverrides,
|
|
||||||
key,
|
|
||||||
);
|
|
||||||
const overrideVal = hasLocalOverride ? localOverrides[key] : undefined;
|
|
||||||
const effectiveVal = hasLocalOverride ? overrideVal! : remoteVal;
|
|
||||||
|
|
||||||
// Determine type
|
|
||||||
const type =
|
|
||||||
typeof effectiveVal === 'boolean'
|
|
||||||
? 'boolean'
|
|
||||||
: typeof effectiveVal === 'number'
|
|
||||||
? 'number'
|
|
||||||
: 'string';
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
remoteValue: remoteVal,
|
|
||||||
overrideValue: overrideVal,
|
|
||||||
value: effectiveVal,
|
|
||||||
type: type as 'boolean' | 'string' | 'number',
|
|
||||||
source: hasLocalOverride
|
|
||||||
? 'Local Override'
|
|
||||||
: configValue.getSource() === 'remote'
|
|
||||||
? 'Remote Config'
|
|
||||||
: configValue.getSource() === 'default'
|
|
||||||
? 'Default'
|
|
||||||
: configValue.getSource() === 'static'
|
|
||||||
? 'Static'
|
|
||||||
: 'Unknown',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add any local overrides that don't exist in remote config
|
|
||||||
const localOnlyFlags = Object.keys(localOverrides)
|
|
||||||
.filter(key => !Object.prototype.hasOwnProperty.call(keys, key))
|
|
||||||
.map(key => {
|
|
||||||
const value = localOverrides[key];
|
|
||||||
const type =
|
|
||||||
typeof value === 'boolean'
|
|
||||||
? 'boolean'
|
|
||||||
: typeof value === 'number'
|
|
||||||
? 'number'
|
|
||||||
: 'string';
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
remoteValue: undefined,
|
|
||||||
overrideValue: value,
|
|
||||||
value: value,
|
|
||||||
type: type as 'boolean' | 'string' | 'number',
|
|
||||||
source: 'Local Override',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...remoteFlags, ...localOnlyFlags].sort((a, b) =>
|
|
||||||
a.key.localeCompare(b.key),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get all feature flags:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const refreshRemoteConfig = async () => {
|
|
||||||
try {
|
|
||||||
await remoteConfig().fetchAndActivate();
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Remote config refresh failed', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
115
app/src/RemoteConfig.web.ts
Normal file
115
app/src/RemoteConfig.web.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
// Web-compatible version using LocalStorage and Firebase Web SDK
|
||||||
|
// This file provides the same API as RemoteConfig.ts but for web environments
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAllLocalOverrides as clearAllLocalOverridesShared,
|
||||||
|
clearLocalOverride as clearLocalOverrideShared,
|
||||||
|
FeatureFlagValue,
|
||||||
|
getAllFeatureFlags as getAllFeatureFlagsShared,
|
||||||
|
getFeatureFlag as getFeatureFlagShared,
|
||||||
|
getLocalOverrides as getLocalOverridesShared,
|
||||||
|
initRemoteConfig as initRemoteConfigShared,
|
||||||
|
refreshRemoteConfig as refreshRemoteConfigShared,
|
||||||
|
RemoteConfigBackend,
|
||||||
|
setLocalOverride as setLocalOverrideShared,
|
||||||
|
StorageBackend,
|
||||||
|
} from './RemoteConfig.shared';
|
||||||
|
|
||||||
|
// Web-specific storage backend using LocalStorage
|
||||||
|
const webStorageBackend: StorageBackend = {
|
||||||
|
getItem: async (key: string): Promise<string | null> => {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
},
|
||||||
|
setItem: async (key: string, value: string): Promise<void> => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
removeItem: async (key: string): Promise<void> => {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock Firebase Remote Config for web (since Firebase Web SDK for Remote Config is not installed)
|
||||||
|
// In a real implementation, you would import and use the Firebase Web SDK
|
||||||
|
class MockFirebaseRemoteConfig implements RemoteConfigBackend {
|
||||||
|
private config: Record<string, any> = {};
|
||||||
|
private settings: any = {};
|
||||||
|
|
||||||
|
setDefaults(defaults: Record<string, any>) {
|
||||||
|
this.config = { ...defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfigSettings(settings: any) {
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAndActivate(): Promise<boolean> {
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue(key: string) {
|
||||||
|
const value = this.config[key] || '';
|
||||||
|
return {
|
||||||
|
asBoolean: () => {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'string') return value === 'true';
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
asNumber: () => {
|
||||||
|
if (typeof value === 'number') return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const num = Number(value);
|
||||||
|
return isNaN(num) ? 0 : num;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
asString: () => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
return String(value);
|
||||||
|
},
|
||||||
|
getSource: () => {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll() {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web-specific remote config backend using mock Firebase
|
||||||
|
const webRemoteConfigBackend: RemoteConfigBackend =
|
||||||
|
new MockFirebaseRemoteConfig();
|
||||||
|
|
||||||
|
// Export the shared functions with web-specific backends
|
||||||
|
export const getLocalOverrides = () =>
|
||||||
|
getLocalOverridesShared(webStorageBackend);
|
||||||
|
export const setLocalOverride = (flag: string, value: FeatureFlagValue) =>
|
||||||
|
setLocalOverrideShared(webStorageBackend, flag, value);
|
||||||
|
export const clearLocalOverride = (flag: string) =>
|
||||||
|
clearLocalOverrideShared(webStorageBackend, flag);
|
||||||
|
export const clearAllLocalOverrides = () =>
|
||||||
|
clearAllLocalOverridesShared(webStorageBackend);
|
||||||
|
export const initRemoteConfig = () =>
|
||||||
|
initRemoteConfigShared(webRemoteConfigBackend);
|
||||||
|
export const getFeatureFlag = <T extends FeatureFlagValue>(
|
||||||
|
flag: string,
|
||||||
|
defaultValue: T,
|
||||||
|
) =>
|
||||||
|
getFeatureFlagShared(
|
||||||
|
webRemoteConfigBackend,
|
||||||
|
webStorageBackend,
|
||||||
|
flag,
|
||||||
|
defaultValue,
|
||||||
|
);
|
||||||
|
export const getAllFeatureFlags = () =>
|
||||||
|
getAllFeatureFlagsShared(webRemoteConfigBackend, webStorageBackend);
|
||||||
|
export const refreshRemoteConfig = () =>
|
||||||
|
refreshRemoteConfigShared(webRemoteConfigBackend);
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type { FeatureFlagValue } from './RemoteConfig.shared';
|
||||||
60
app/src/Sentry.web.ts
Normal file
60
app/src/Sentry.web.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { SENTRY_DSN } from '@env';
|
||||||
|
import * as Sentry from '@sentry/react';
|
||||||
|
|
||||||
|
export const isSentryDisabled = !SENTRY_DSN;
|
||||||
|
|
||||||
|
export const initSentry = () => {
|
||||||
|
if (isSentryDisabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
debug: false,
|
||||||
|
// Performance Monitoring
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
// Session Replay
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
// Disable collection of PII data
|
||||||
|
beforeSend(event) {
|
||||||
|
// Remove PII data
|
||||||
|
if (event.user) {
|
||||||
|
event.user.ip_address = undefined;
|
||||||
|
event.user.id = undefined;
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Sentry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const captureException = (
|
||||||
|
error: Error,
|
||||||
|
context?: Record<string, any>,
|
||||||
|
) => {
|
||||||
|
if (isSentryDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sentry.captureException(error, {
|
||||||
|
extra: context,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const captureMessage = (
|
||||||
|
message: string,
|
||||||
|
context?: Record<string, any>,
|
||||||
|
) => {
|
||||||
|
if (isSentryDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sentry.captureMessage(message, {
|
||||||
|
extra: context,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const wrapWithSentry = (App: React.ComponentType) => {
|
||||||
|
return isSentryDisabled ? App : Sentry.withProfiler(App);
|
||||||
|
};
|
||||||
@@ -28,6 +28,9 @@ class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
componentDidCatch() {
|
componentDidCatch() {
|
||||||
// Flush analytics before the app crashes
|
// Flush analytics before the app crashes
|
||||||
flushAnalytics();
|
flushAnalytics();
|
||||||
|
// TODO Sentry React docs recommend Sentry.captureReactException(error, info);
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/
|
||||||
|
// but ill wait so as to have few changes on native app
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
13
app/src/components/buttons/PrimaryButtonLongHold.shared.ts
Normal file
13
app/src/components/buttons/PrimaryButtonLongHold.shared.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { ButtonProps } from './AbstractButton';
|
||||||
|
|
||||||
|
export type RGBA = `rgba(${number}, ${number}, ${number}, ${number})`;
|
||||||
|
|
||||||
|
export const ACTION_TIMER = 600; // time in ms
|
||||||
|
//slate400 to slate800 but in rgb
|
||||||
|
export const COLORS: RGBA[] = ['rgba(30, 41, 59, 0.3)', 'rgba(30, 41, 59, 1)'];
|
||||||
|
|
||||||
|
export interface HeldPrimaryButtonProps extends ButtonProps {
|
||||||
|
onLongPress: () => void;
|
||||||
|
}
|
||||||
@@ -8,23 +8,24 @@ import {
|
|||||||
useAnimatedValue,
|
useAnimatedValue,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
|
||||||
import { ButtonProps } from './AbstractButton';
|
|
||||||
import { PrimaryButton } from './PrimaryButton';
|
import { PrimaryButton } from './PrimaryButton';
|
||||||
|
import {
|
||||||
|
ACTION_TIMER,
|
||||||
|
COLORS,
|
||||||
|
HeldPrimaryButtonProps,
|
||||||
|
} from './PrimaryButtonLongHold.shared';
|
||||||
|
|
||||||
type RGBA = `rgba(${number}, ${number}, ${number}, ${number})`;
|
|
||||||
|
|
||||||
const ACTION_TIMER = 600; // time in ms
|
|
||||||
//slate400 to slate800 but in rgb
|
|
||||||
const COLORS: RGBA[] = ['rgba(30, 41, 59, 0.3)', 'rgba(30, 41, 59, 1)'];
|
|
||||||
export function HeldPrimaryButton({
|
export function HeldPrimaryButton({
|
||||||
children,
|
children,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps & { onLongPress: () => void }) {
|
}: HeldPrimaryButtonProps) {
|
||||||
const animation = useAnimatedValue(0);
|
|
||||||
const [hasTriggered, setHasTriggered] = useState(false);
|
const [hasTriggered, setHasTriggered] = useState(false);
|
||||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
// React Native animation setup
|
||||||
|
const animation = useAnimatedValue(0);
|
||||||
|
|
||||||
const onPressIn = () => {
|
const onPressIn = () => {
|
||||||
setHasTriggered(false);
|
setHasTriggered(false);
|
||||||
Animated.timing(animation, {
|
Animated.timing(animation, {
|
||||||
@@ -50,23 +51,8 @@ export function HeldPrimaryButton({
|
|||||||
setSize({ width, height });
|
setSize({ width, height });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProgressStyles = () => {
|
|
||||||
const scaleX = animation.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [0, 1],
|
|
||||||
});
|
|
||||||
const bgColor = animation.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: COLORS,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
transform: [{ scaleX }],
|
|
||||||
backgroundColor: bgColor,
|
|
||||||
height: size.height,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Mobile: Use React Native animation listener
|
||||||
animation.addListener(({ value }) => {
|
animation.addListener(({ value }) => {
|
||||||
if (value >= 0.95 && !hasTriggered) {
|
if (value >= 0.95 && !hasTriggered) {
|
||||||
setHasTriggered(true);
|
setHasTriggered(true);
|
||||||
@@ -78,6 +64,32 @@ export function HeldPrimaryButton({
|
|||||||
};
|
};
|
||||||
}, [animation, hasTriggered, onLongPress]);
|
}, [animation, hasTriggered, onLongPress]);
|
||||||
|
|
||||||
|
const renderAnimatedComponent = () => {
|
||||||
|
// Mobile: Use React Native Animated.View
|
||||||
|
const scaleX = animation.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, 1],
|
||||||
|
});
|
||||||
|
const bgColor = animation.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: COLORS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.fill,
|
||||||
|
size,
|
||||||
|
{
|
||||||
|
transform: [{ scaleX }],
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
height: size.height,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
{...props}
|
{...props}
|
||||||
@@ -85,14 +97,13 @@ export function HeldPrimaryButton({
|
|||||||
onPressOut={onPressOut}
|
onPressOut={onPressOut}
|
||||||
// @ts-expect-error actually it is there
|
// @ts-expect-error actually it is there
|
||||||
onLayout={getButtonSize}
|
onLayout={getButtonSize}
|
||||||
animatedComponent={
|
animatedComponent={renderAnimatedComponent()}
|
||||||
<Animated.View style={[styles.fill, size, getProgressStyles()]} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
fill: {
|
fill: {
|
||||||
transformOrigin: 'left',
|
transformOrigin: 'left',
|
||||||
|
|||||||
91
app/src/components/buttons/PrimaryButtonLongHold.web.tsx
Normal file
91
app/src/components/buttons/PrimaryButtonLongHold.web.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { LayoutChangeEvent } from 'react-native';
|
||||||
|
// Tamagui imports for web
|
||||||
|
import { AnimatePresence, YStack } from 'tamagui';
|
||||||
|
|
||||||
|
import { PrimaryButton } from './PrimaryButton';
|
||||||
|
import {
|
||||||
|
ACTION_TIMER,
|
||||||
|
COLORS,
|
||||||
|
HeldPrimaryButtonProps,
|
||||||
|
} from './PrimaryButtonLongHold.shared';
|
||||||
|
|
||||||
|
export function HeldPrimaryButton({
|
||||||
|
children,
|
||||||
|
onLongPress,
|
||||||
|
...props
|
||||||
|
}: HeldPrimaryButtonProps) {
|
||||||
|
const [hasTriggered, setHasTriggered] = useState(false);
|
||||||
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
|
const [isPressed, setIsPressed] = useState(false);
|
||||||
|
|
||||||
|
const onPressIn = () => {
|
||||||
|
setHasTriggered(false);
|
||||||
|
setIsPressed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPressOut = () => {
|
||||||
|
setIsPressed(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonSize = (e: LayoutChangeEvent) => {
|
||||||
|
const width = e.nativeEvent.layout.width - 1;
|
||||||
|
const height = e.nativeEvent.layout.height - 1;
|
||||||
|
setSize({ width, height });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Web: Use setTimeout to trigger onLongPress
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
if (isPressed && !hasTriggered) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
if (isPressed && !hasTriggered) {
|
||||||
|
setHasTriggered(true);
|
||||||
|
onLongPress();
|
||||||
|
}
|
||||||
|
}, ACTION_TIMER);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [hasTriggered, onLongPress, isPressed]);
|
||||||
|
|
||||||
|
const renderAnimatedComponent = () => {
|
||||||
|
// Web: Use Tamagui AnimatePresence with CSS transitions
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isPressed && (
|
||||||
|
<YStack
|
||||||
|
key="fill"
|
||||||
|
position="absolute"
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
bottom={0}
|
||||||
|
borderRadius={4}
|
||||||
|
backgroundColor={COLORS[1]}
|
||||||
|
width="100%"
|
||||||
|
height={size.height}
|
||||||
|
enterStyle={{ width: 0 }}
|
||||||
|
exitStyle={{ width: 0 }}
|
||||||
|
animation="quick"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimaryButton
|
||||||
|
{...props}
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
onPressOut={onPressOut}
|
||||||
|
// @ts-expect-error actually it is there
|
||||||
|
onLayout={getButtonSize}
|
||||||
|
animatedComponent={renderAnimatedComponent()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PrimaryButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
app/src/components/native/PassportCamera.web.tsx
Normal file
65
app/src/components/native/PassportCamera.web.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { extractMRZInfo } from '../../utils/utils';
|
||||||
|
|
||||||
|
// TODO: Web find a lightweight ocr or mrz scanner.
|
||||||
|
|
||||||
|
export interface PassportCameraProps {
|
||||||
|
isMounted: boolean;
|
||||||
|
onPassportRead: (
|
||||||
|
error: Error | null,
|
||||||
|
mrzData?: ReturnType<typeof extractMRZInfo>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PassportCamera: React.FC<PassportCameraProps> = ({
|
||||||
|
onPassportRead,
|
||||||
|
isMounted,
|
||||||
|
}) => {
|
||||||
|
const handleError = useCallback(() => {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const error = new Error('Passport camera not implemented for web yet');
|
||||||
|
onPassportRead(error);
|
||||||
|
}, [onPassportRead, isMounted]);
|
||||||
|
|
||||||
|
// Web stub - no functionality yet
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Simulate that the component is not ready for web
|
||||||
|
if (isMounted) {
|
||||||
|
console.warn('PassportCamera: Web implementation not yet available');
|
||||||
|
// Optionally trigger an error after a short delay to indicate not implemented
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
handleError();
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isMounted, handleError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '16px' }}>📷 Passport Camera</div>
|
||||||
|
<div style={{ fontSize: '14px', opacity: 0.7 }}>
|
||||||
|
Web implementation coming soon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
app/src/components/native/QrCodeScanner.web.tsx
Normal file
40
app/src/components/native/QrCodeScanner.web.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import BarcodeScanner, { BarcodeFormat } from 'react-qr-barcode-scanner';
|
||||||
|
|
||||||
|
export interface QRCodeScannerViewProps {
|
||||||
|
isMounted: boolean;
|
||||||
|
onQRData: (error: Error | null, uri?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QRCodeScannerView({
|
||||||
|
onQRData,
|
||||||
|
isMounted,
|
||||||
|
}: QRCodeScannerViewProps) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<BarcodeScanner
|
||||||
|
width={500}
|
||||||
|
height={500}
|
||||||
|
formats={[BarcodeFormat.QR_CODE]}
|
||||||
|
delay={300}
|
||||||
|
onUpdate={(err, result) => {
|
||||||
|
if (result) {
|
||||||
|
const url = result.getText();
|
||||||
|
console.log('SELF URL', url);
|
||||||
|
onQRData(null, url);
|
||||||
|
} else if (err && err instanceof Error) {
|
||||||
|
// it will give NotFoundException2 every frame until a QR code is found so we ignore it because thats just noisy
|
||||||
|
if (err.name !== 'NotFoundException2') {
|
||||||
|
onQRData(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QRCodeScannerView;
|
||||||
41
app/src/hooks/useAppUpdates.web.ts
Normal file
41
app/src/hooks/useAppUpdates.web.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { AppEvents } from '../consts/analytics';
|
||||||
|
import analytics from '../utils/analytics';
|
||||||
|
import { registerModalCallbacks } from '../utils/modalCallbackRegistry';
|
||||||
|
|
||||||
|
const { trackEvent } = analytics();
|
||||||
|
|
||||||
|
export const useAppUpdates = (): [boolean, () => void, boolean] => {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [isModalDismissed, setIsModalDismissed] = useState(false);
|
||||||
|
|
||||||
|
const showAppUpdateModal = () => {
|
||||||
|
const callbackId = registerModalCallbacks({
|
||||||
|
onButtonPress: async () => {
|
||||||
|
window.location.reload();
|
||||||
|
trackEvent(AppEvents.UPDATE_STARTED);
|
||||||
|
},
|
||||||
|
onModalDismiss: () => {
|
||||||
|
setIsModalDismissed(true);
|
||||||
|
trackEvent(AppEvents.UPDATE_MODAL_CLOSED);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
navigation.navigate('Modal', {
|
||||||
|
titleText: 'New Version Available',
|
||||||
|
bodyText:
|
||||||
|
"We've improved performance, fixed bugs, and added new features. Update now to install the latest version of Self.",
|
||||||
|
buttonText: 'Update and restart',
|
||||||
|
callbackId,
|
||||||
|
});
|
||||||
|
trackEvent(AppEvents.UPDATE_MODAL_OPENED);
|
||||||
|
};
|
||||||
|
|
||||||
|
const newVersionAvailable = false;
|
||||||
|
|
||||||
|
return [newVersionAvailable, showAppUpdateModal, isModalDismissed];
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
import { useNetInfo } from '@react-native-community/netinfo';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Linking, Platform } from 'react-native';
|
import { Linking, Platform } from 'react-native';
|
||||||
|
|
||||||
@@ -9,6 +8,7 @@ import { navigationRef } from '../navigation';
|
|||||||
import { useSettingStore } from '../stores/settingStore';
|
import { useSettingStore } from '../stores/settingStore';
|
||||||
import analytics from '../utils/analytics';
|
import analytics from '../utils/analytics';
|
||||||
import { useModal } from './useModal';
|
import { useModal } from './useModal';
|
||||||
|
import { useNetInfo } from './useNetInfo';
|
||||||
|
|
||||||
const { trackEvent } = analytics();
|
const { trackEvent } = analytics();
|
||||||
|
|
||||||
|
|||||||
3
app/src/hooks/useNetInfo.ts
Normal file
3
app/src/hooks/useNetInfo.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
export { useNetInfo } from '@react-native-community/netinfo';
|
||||||
7
app/src/hooks/useNetInfo.web.ts
Normal file
7
app/src/hooks/useNetInfo.web.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
export function useNetInfo() {
|
||||||
|
// when implementing this for real be ware that Network information API
|
||||||
|
// is not available on webview on ios https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API
|
||||||
|
return { isConnected: true, isInternetReachable: true };
|
||||||
|
}
|
||||||
78
app/src/mocks/react-native-gesture-handler.ts
Normal file
78
app/src/mocks/react-native-gesture-handler.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Web-compatible mock for react-native-gesture-handler
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Mock GestureHandlerRootView as a simple wrapper
|
||||||
|
export const GestureHandlerRootView: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
[key: string]: any;
|
||||||
|
}> = ({ children, ...props }) => {
|
||||||
|
return React.createElement('div', props, children);
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnValue = {
|
||||||
|
numberOfTaps: () => returnValue,
|
||||||
|
onStart: () => returnValue,
|
||||||
|
onEnd: () => returnValue,
|
||||||
|
onCancel: () => returnValue,
|
||||||
|
onFail: () => returnValue,
|
||||||
|
onUpdate: () => returnValue,
|
||||||
|
onFinalize: () => returnValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock Gesture and GestureDetector for web
|
||||||
|
export const Gesture = {
|
||||||
|
Pan: () => returnValue,
|
||||||
|
Tap: () => returnValue,
|
||||||
|
LongPress: () => returnValue,
|
||||||
|
Pinch: () => returnValue,
|
||||||
|
Rotation: () => returnValue,
|
||||||
|
Fling: () => returnValue,
|
||||||
|
Force: () => returnValue,
|
||||||
|
Native: () => returnValue,
|
||||||
|
Race: () => returnValue,
|
||||||
|
Simultaneous: () => returnValue,
|
||||||
|
Exclusive: () => returnValue,
|
||||||
|
Composed: () => returnValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GestureDetector: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
gesture?: any;
|
||||||
|
}> = ({ children, gesture: _gesture }) => {
|
||||||
|
return React.createElement('div', {}, children);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock other commonly used exports
|
||||||
|
export const State = {
|
||||||
|
UNDETERMINED: 0,
|
||||||
|
FAILED: 1,
|
||||||
|
BEGAN: 2,
|
||||||
|
CANCELLED: 3,
|
||||||
|
ACTIVE: 4,
|
||||||
|
END: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Directions = {
|
||||||
|
RIGHT: 1,
|
||||||
|
LEFT: 2,
|
||||||
|
UP: 4,
|
||||||
|
DOWN: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the jest setup
|
||||||
|
export const jestSetup = () => {};
|
||||||
|
|
||||||
|
// Default export for the main import
|
||||||
|
export default {
|
||||||
|
GestureHandlerRootView,
|
||||||
|
Gesture,
|
||||||
|
GestureDetector,
|
||||||
|
State,
|
||||||
|
Directions,
|
||||||
|
jestSetup,
|
||||||
|
};
|
||||||
44
app/src/mocks/react-native-safe-area-context.js
vendored
Normal file
44
app/src/mocks/react-native-safe-area-context.js
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
// On web we dont need safe context area since we will be inside another app. (and it doesnt work)
|
||||||
|
|
||||||
|
export function SafeAreaProvider({ children }) {
|
||||||
|
return React.createElement(React.Fragment, null, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSafeAreaInsets() {
|
||||||
|
return { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSafeAreaFrame() {
|
||||||
|
return { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SafeAreaView(props) {
|
||||||
|
return React.createElement('div', props, props.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialWindowMetrics = {
|
||||||
|
insets: {
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
|
frame: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SafeAreaContext = React.createContext(initialWindowMetrics);
|
||||||
|
|
||||||
|
export const SafeAreaInsetsContext = React.createContext({
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
});
|
||||||
@@ -8,7 +8,6 @@ import HomeScreen from '../screens/home/HomeScreen';
|
|||||||
import ProofHistoryDetailScreen from '../screens/home/ProofHistoryDetailScreen';
|
import ProofHistoryDetailScreen from '../screens/home/ProofHistoryDetailScreen';
|
||||||
import ProofHistoryScreen from '../screens/home/ProofHistoryScreen';
|
import ProofHistoryScreen from '../screens/home/ProofHistoryScreen';
|
||||||
import { black } from '../utils/colors';
|
import { black } from '../utils/colors';
|
||||||
|
|
||||||
const homeScreens = {
|
const homeScreens = {
|
||||||
Disclaimer: {
|
Disclaimer: {
|
||||||
screen: DisclaimerScreen,
|
screen: DisclaimerScreen,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@react-navigation/native';
|
} from '@react-navigation/native';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
|
||||||
import { DefaultNavBar } from '../components/NavBar';
|
import { DefaultNavBar } from '../components/NavBar';
|
||||||
@@ -38,7 +39,8 @@ export const navigationScreens = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AppNavigation = createNativeStackNavigator({
|
const AppNavigation = createNativeStackNavigator({
|
||||||
initialRouteName: 'Splash',
|
id: undefined,
|
||||||
|
initialRouteName: Platform.OS === 'web' ? 'Home' : 'Splash',
|
||||||
screenOptions: {
|
screenOptions: {
|
||||||
header: DefaultNavBar,
|
header: DefaultNavBar,
|
||||||
navigationBarColor: white,
|
navigationBarColor: white,
|
||||||
|
|||||||
19
app/src/navigation/recovery.web.ts
Normal file
19
app/src/navigation/recovery.web.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||||
|
|
||||||
|
import PassportDataNotFound from '../screens/recovery/PassportDataNotFoundScreen';
|
||||||
|
|
||||||
|
const recoveryScreens = {
|
||||||
|
PassportDataNotFound: {
|
||||||
|
screen: PassportDataNotFound,
|
||||||
|
options: {
|
||||||
|
headerShown: false,
|
||||||
|
gestureEnabled: false,
|
||||||
|
animation: 'slide_from_bottom',
|
||||||
|
// presentation: 'modal',
|
||||||
|
} as NativeStackNavigationOptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default recoveryScreens;
|
||||||
51
app/src/navigation/settings.web.ts
Normal file
51
app/src/navigation/settings.web.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
|
||||||
|
|
||||||
|
import ManageDocumentsScreen from '../screens/settings/ManageDocumentsScreen';
|
||||||
|
import PassportDataInfoScreen from '../screens/settings/PassportDataInfoScreen';
|
||||||
|
import SettingsScreen from '../screens/settings/SettingsScreen';
|
||||||
|
import { black, white } from '../utils/colors';
|
||||||
|
|
||||||
|
const settingsScreens = {
|
||||||
|
ManageDocuments: {
|
||||||
|
screen: ManageDocumentsScreen,
|
||||||
|
options: {
|
||||||
|
title: 'Manage Documents',
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: white,
|
||||||
|
},
|
||||||
|
headerTitleStyle: {
|
||||||
|
color: black,
|
||||||
|
},
|
||||||
|
} as NativeStackNavigationOptions,
|
||||||
|
},
|
||||||
|
PassportDataInfo: {
|
||||||
|
screen: PassportDataInfoScreen,
|
||||||
|
options: {
|
||||||
|
title: 'Passport Data Info',
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: white,
|
||||||
|
},
|
||||||
|
} as NativeStackNavigationOptions,
|
||||||
|
},
|
||||||
|
Settings: {
|
||||||
|
screen: SettingsScreen,
|
||||||
|
options: {
|
||||||
|
animation: 'slide_from_bottom',
|
||||||
|
title: 'Settings',
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: white,
|
||||||
|
},
|
||||||
|
headerTitleStyle: {
|
||||||
|
color: black,
|
||||||
|
},
|
||||||
|
navigationBarColor: black,
|
||||||
|
} as NativeStackNavigationOptions,
|
||||||
|
config: {
|
||||||
|
screens: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default settingsScreens;
|
||||||
284
app/src/providers/authProvider.web.tsx
Normal file
284
app/src/providers/authProvider.web.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This entire file is a stub and MUST be replaced
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { AuthEvents } from '../consts/analytics';
|
||||||
|
import { Mnemonic } from '../types/mnemonic';
|
||||||
|
import analytics from '../utils/analytics';
|
||||||
|
|
||||||
|
const { trackEvent } = analytics();
|
||||||
|
|
||||||
|
type SignedPayload<T> = { signature: string; data: T };
|
||||||
|
|
||||||
|
// Check if Android bridge is available
|
||||||
|
interface AndroidBridge {
|
||||||
|
getPrivateKey(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
// TODO ios Bridge
|
||||||
|
Android?: AndroidBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAndroidBridgeAvailable = (): boolean => {
|
||||||
|
return typeof window !== 'undefined' && 'Android' in window;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get private key from Android bridge or prompt user
|
||||||
|
const getPrivateKeyFromAndroidBridge = async (): Promise<string | null> => {
|
||||||
|
if (!isAndroidBridgeAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const privateKey = await window.Android!.getPrivateKey();
|
||||||
|
|
||||||
|
// Validate the returned private key
|
||||||
|
if (typeof privateKey !== 'string' || privateKey.length === 0) {
|
||||||
|
throw new Error('Invalid private key received from Android bridge');
|
||||||
|
}
|
||||||
|
|
||||||
|
return privateKey;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get private key from Android bridge:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prompt user for private key input
|
||||||
|
const promptUserForPrivateKey = async (): Promise<string | null> => {
|
||||||
|
// TODO: Implement secure key input mechanism
|
||||||
|
throw new Error('Secure key input not yet implemented for web');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get private key from Android bridge or prompt user
|
||||||
|
const getPrivateKey = async (): Promise<string | null> => {
|
||||||
|
// Try Android bridge first
|
||||||
|
const key = await getPrivateKeyFromAndroidBridge();
|
||||||
|
if (key) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return promptUserForPrivateKey();
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This function is not implemented yet
|
||||||
|
* and is only a placeholder for the web implementation.
|
||||||
|
* it doesnt do anything
|
||||||
|
*/
|
||||||
|
const _getSecurely = async function <T>(
|
||||||
|
fn: () => Promise<string | false>,
|
||||||
|
formatter: (dataString: string) => T,
|
||||||
|
): Promise<SignedPayload<T> | null> {
|
||||||
|
console.log('Starting _getSecurely (web)');
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
'This is a stub for _getSecurely on web. It does not implement secure storage or biometric authentication.',
|
||||||
|
);
|
||||||
|
const dataString = await fn();
|
||||||
|
console.log('Got data string:', dataString ? 'exists' : 'not found');
|
||||||
|
|
||||||
|
if (dataString === false) {
|
||||||
|
console.log('No data string available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For web, we need to figure out exactly how this will interact with the
|
||||||
|
// Android bridge or any other secure storage mechanism.
|
||||||
|
trackEvent(AuthEvents.BIOMETRIC_AUTH_SUCCESS);
|
||||||
|
return {
|
||||||
|
signature: 'authenticated',
|
||||||
|
data: formatter(dataString),
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error in _getSecurely:', error);
|
||||||
|
trackEvent(AuthEvents.BIOMETRIC_AUTH_FAILED, {
|
||||||
|
reason: 'unknown_error',
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function checkBiometricsAvailable(): Promise<boolean> {
|
||||||
|
// On web, biometrics are not available in the same way as mobile
|
||||||
|
// We'll return false to indicate biometrics are not available
|
||||||
|
trackEvent(AuthEvents.BIOMETRIC_CHECK, { available: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreFromMnemonic(_mnemonic: string): Promise<string | false> {
|
||||||
|
// No-op on web since we don't have access to mnemonics
|
||||||
|
console.log('restoreFromMnemonic: No-op on web');
|
||||||
|
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
|
||||||
|
reason: 'not_supported_on_web',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrCreateMnemonic(): Promise<string | false> {
|
||||||
|
// No-op on web since we don't have access to mnemonics
|
||||||
|
console.log('loadOrCreateMnemonic: No-op on web');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthProviderProps extends PropsWithChildren {
|
||||||
|
authenticationTimeoutinMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IAuthContext {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isAuthenticating: boolean;
|
||||||
|
loginWithBiometrics: () => Promise<void>;
|
||||||
|
_getSecurely: typeof _getSecurely;
|
||||||
|
getOrCreateMnemonic: () => Promise<SignedPayload<Mnemonic> | null>;
|
||||||
|
restoreAccountFromMnemonic: (
|
||||||
|
mnemonic: string,
|
||||||
|
) => Promise<SignedPayload<boolean> | null>;
|
||||||
|
checkBiometricsAvailable: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<IAuthContext>({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isAuthenticating: false,
|
||||||
|
loginWithBiometrics: () => Promise.resolve(),
|
||||||
|
_getSecurely,
|
||||||
|
getOrCreateMnemonic: () => Promise.resolve(null),
|
||||||
|
restoreAccountFromMnemonic: () => Promise.resolve(null),
|
||||||
|
checkBiometricsAvailable: () => Promise.resolve(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthProvider = ({
|
||||||
|
children,
|
||||||
|
authenticationTimeoutinMs = 15 * 60 * 1000,
|
||||||
|
}: AuthProviderProps) => {
|
||||||
|
const [_, setAuthenticatedTimeout] =
|
||||||
|
useState<ReturnType<typeof setTimeout>>();
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [isAuthenticatingPromise, setIsAuthenticatingPromise] =
|
||||||
|
useState<Promise<{ success: boolean; error?: string }> | null>(null);
|
||||||
|
|
||||||
|
const loginWithBiometrics = useCallback(async () => {
|
||||||
|
if (isAuthenticatingPromise) {
|
||||||
|
await isAuthenticatingPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackEvent(AuthEvents.BIOMETRIC_LOGIN_ATTEMPT);
|
||||||
|
|
||||||
|
// On web, we'll simulate biometric authentication by checking if we can get the private key
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const privateKey = await getPrivateKey();
|
||||||
|
if (privateKey) {
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
return { success: false, error: 'No private key provided' };
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
setIsAuthenticatingPromise(promise);
|
||||||
|
const { success, error } = await promise;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setIsAuthenticatingPromise(null);
|
||||||
|
trackEvent(AuthEvents.BIOMETRIC_LOGIN_FAILED, { error });
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
if (!success) {
|
||||||
|
setIsAuthenticatingPromise(null);
|
||||||
|
trackEvent(AuthEvents.BIOMETRIC_LOGIN_CANCELLED);
|
||||||
|
throw new Error('Canceled by user');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAuthenticatingPromise(null);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
trackEvent(AuthEvents.BIOMETRIC_LOGIN_SUCCESS);
|
||||||
|
setAuthenticatedTimeout(previousTimeout => {
|
||||||
|
if (previousTimeout) {
|
||||||
|
clearTimeout(previousTimeout);
|
||||||
|
}
|
||||||
|
return setTimeout(() => {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
trackEvent(AuthEvents.AUTHENTICATION_TIMEOUT);
|
||||||
|
}, authenticationTimeoutinMs);
|
||||||
|
});
|
||||||
|
}, [isAuthenticatingPromise, authenticationTimeoutinMs]);
|
||||||
|
|
||||||
|
const getOrCreateMnemonic = useCallback(
|
||||||
|
() => _getSecurely<Mnemonic>(loadOrCreateMnemonic, str => JSON.parse(str)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const restoreAccountFromMnemonic = useCallback(
|
||||||
|
(mnemonic: string) =>
|
||||||
|
_getSecurely<boolean>(
|
||||||
|
() => restoreFromMnemonic(mnemonic),
|
||||||
|
str => !!str,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const state: IAuthContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
isAuthenticated,
|
||||||
|
isAuthenticating: !!isAuthenticatingPromise,
|
||||||
|
loginWithBiometrics,
|
||||||
|
getOrCreateMnemonic,
|
||||||
|
restoreAccountFromMnemonic,
|
||||||
|
checkBiometricsAvailable,
|
||||||
|
_getSecurely,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
isAuthenticated,
|
||||||
|
isAuthenticatingPromise,
|
||||||
|
loginWithBiometrics,
|
||||||
|
getOrCreateMnemonic,
|
||||||
|
restoreAccountFromMnemonic,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function hasSecretStored() {
|
||||||
|
// TODO implement a way to check if the private key is stored
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only reason this is exported without being locked behind user biometrics is to allow `loadPassportDataAndSecret`
|
||||||
|
* to access both the privatekey and the passport data with the user only authenticating once
|
||||||
|
*/
|
||||||
|
export async function unsafe_getPrivateKey() {
|
||||||
|
return getPrivateKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsafe_clearSecrets() {
|
||||||
|
if (__DEV__) {
|
||||||
|
console.warn('unsafe_clearSecrets is not implemented for web');
|
||||||
|
// In a real implementation, you would clear any stored secrets here
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/providers/notificationTrackingProvider.web.tsx
Normal file
10
app/src/providers/notificationTrackingProvider.web.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import React, { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
//TODO:WEB Stubbed out for now on web
|
||||||
|
export const NotificationTrackingProvider: React.FC<PropsWithChildren> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@ import React, { useMemo } from 'react';
|
|||||||
import { ScrollView, StyleSheet } from 'react-native';
|
import { ScrollView, StyleSheet } from 'react-native';
|
||||||
import { Card, Image, Text, XStack, YStack } from 'tamagui';
|
import { Card, Image, Text, XStack, YStack } from 'tamagui';
|
||||||
|
|
||||||
import { ProofHistory, ProofStatus } from '../../stores/proofHistoryStore';
|
import { ProofHistory, ProofStatus } from '../../stores/proof-types';
|
||||||
import {
|
import {
|
||||||
black,
|
black,
|
||||||
blue100,
|
blue100,
|
||||||
|
|||||||
@@ -13,11 +13,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
import { Card, Image, Text, View, XStack, YStack } from 'tamagui';
|
import { Card, Image, Text, View, XStack, YStack } from 'tamagui';
|
||||||
|
|
||||||
import { BodyText } from '../../components/typography/BodyText';
|
import { BodyText } from '../../components/typography/BodyText';
|
||||||
import {
|
import { ProofHistory, ProofStatus } from '../../stores/proof-types';
|
||||||
ProofHistory,
|
import { useProofHistoryStore } from '../../stores/proofHistoryStore';
|
||||||
ProofStatus,
|
|
||||||
useProofHistoryStore,
|
|
||||||
} from '../../stores/proofHistoryStore';
|
|
||||||
import {
|
import {
|
||||||
black,
|
black,
|
||||||
blue100,
|
blue100,
|
||||||
|
|||||||
59
app/src/screens/passport/PassportNFCScanScreen.web.tsx
Normal file
59
app/src/screens/passport/PassportNFCScanScreen.web.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Image } from 'tamagui';
|
||||||
|
|
||||||
|
import { SecondaryButton } from '../../components/buttons/SecondaryButton';
|
||||||
|
import ButtonsContainer from '../../components/ButtonsContainer';
|
||||||
|
import TextsContainer from '../../components/TextsContainer';
|
||||||
|
import { BodyText } from '../../components/typography/BodyText';
|
||||||
|
import { Title } from '../../components/typography/Title';
|
||||||
|
import { PassportEvents } from '../../consts/analytics';
|
||||||
|
import useHapticNavigation from '../../hooks/useHapticNavigation';
|
||||||
|
import NFC_IMAGE from '../../images/nfc.png';
|
||||||
|
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
|
||||||
|
import { black, slate100, white } from '../../utils/colors';
|
||||||
|
|
||||||
|
interface PassportNFCScanScreenProps {}
|
||||||
|
|
||||||
|
const PassportNFCScanScreen: React.FC<PassportNFCScanScreenProps> = ({}) => {
|
||||||
|
const onCancelPress = useHapticNavigation('Launch', {
|
||||||
|
action: 'cancel',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpandableBottomLayout.Layout backgroundColor={black}>
|
||||||
|
<ExpandableBottomLayout.TopSection roundTop backgroundColor={slate100}>
|
||||||
|
<>Animation Goes Here</>
|
||||||
|
</ExpandableBottomLayout.TopSection>
|
||||||
|
<ExpandableBottomLayout.BottomSection backgroundColor={white}>
|
||||||
|
<>
|
||||||
|
<TextsContainer>
|
||||||
|
<Title children="Ready to scan" />
|
||||||
|
<BodyText textAlign="center">TODO implement</BodyText>
|
||||||
|
</TextsContainer>
|
||||||
|
<Image
|
||||||
|
h="$8"
|
||||||
|
w="$8"
|
||||||
|
alignSelf="center"
|
||||||
|
borderRadius={1000}
|
||||||
|
source={{
|
||||||
|
uri: NFC_IMAGE,
|
||||||
|
}}
|
||||||
|
margin={20}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
<ButtonsContainer>
|
||||||
|
<SecondaryButton
|
||||||
|
trackEvent={PassportEvents.CANCEL_PASSPORT_NFC}
|
||||||
|
onPress={onCancelPress}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</SecondaryButton>
|
||||||
|
</ButtonsContainer>
|
||||||
|
</ExpandableBottomLayout.BottomSection>
|
||||||
|
</ExpandableBottomLayout.Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PassportNFCScanScreen;
|
||||||
@@ -17,10 +17,8 @@ import { Title } from '../../components/typography/Title';
|
|||||||
import { ProofEvents } from '../../consts/analytics';
|
import { ProofEvents } from '../../consts/analytics';
|
||||||
import useHapticNavigation from '../../hooks/useHapticNavigation';
|
import useHapticNavigation from '../../hooks/useHapticNavigation';
|
||||||
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
|
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
|
||||||
import {
|
import { ProofStatus } from '../../stores/proof-types';
|
||||||
ProofStatus,
|
import { useProofHistoryStore } from '../../stores/proofHistoryStore';
|
||||||
useProofHistoryStore,
|
|
||||||
} from '../../stores/proofHistoryStore';
|
|
||||||
import { useSelfAppStore } from '../../stores/selfAppStore';
|
import { useSelfAppStore } from '../../stores/selfAppStore';
|
||||||
import analytics from '../../utils/analytics';
|
import analytics from '../../utils/analytics';
|
||||||
import { black, white } from '../../utils/colors';
|
import { black, white } from '../../utils/colors';
|
||||||
|
|||||||
@@ -28,10 +28,8 @@ import { Caption } from '../../components/typography/Caption';
|
|||||||
import { ProofEvents } from '../../consts/analytics';
|
import { ProofEvents } from '../../consts/analytics';
|
||||||
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
|
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
|
||||||
import { setDefaultDocumentTypeIfNeeded } from '../../providers/passportDataProvider';
|
import { setDefaultDocumentTypeIfNeeded } from '../../providers/passportDataProvider';
|
||||||
import {
|
import { ProofStatus } from '../../stores/proof-types';
|
||||||
ProofStatus,
|
import { useProofHistoryStore } from '../../stores/proofHistoryStore';
|
||||||
useProofHistoryStore,
|
|
||||||
} from '../../stores/proofHistoryStore';
|
|
||||||
import { useSelfAppStore } from '../../stores/selfAppStore';
|
import { useSelfAppStore } from '../../stores/selfAppStore';
|
||||||
import analytics from '../../utils/analytics';
|
import analytics from '../../utils/analytics';
|
||||||
import { black, slate300, white } from '../../utils/colors';
|
import { black, slate300, white } from '../../utils/colors';
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { FileText } from '@tamagui/lucide-icons';
|
|||||||
import React, { PropsWithChildren, useCallback, useMemo } from 'react';
|
import React, { PropsWithChildren, useCallback, useMemo } from 'react';
|
||||||
import { Linking, Platform, Share } from 'react-native';
|
import { Linking, Platform, Share } from 'react-native';
|
||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import { getCountry, getLocales, getTimeZone } from 'react-native-localize';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { SvgProps } from 'react-native-svg';
|
import { SvgProps } from 'react-native-svg';
|
||||||
import { Button, ScrollView, View, XStack, YStack } from 'tamagui';
|
import { Button, ScrollView, View, XStack, YStack } from 'tamagui';
|
||||||
@@ -41,6 +40,7 @@ import {
|
|||||||
} from '../../utils/colors';
|
} from '../../utils/colors';
|
||||||
import { extraYPadding } from '../../utils/constants';
|
import { extraYPadding } from '../../utils/constants';
|
||||||
import { impactLight } from '../../utils/haptic';
|
import { impactLight } from '../../utils/haptic';
|
||||||
|
import { getCountry, getLocales, getTimeZone } from '../../utils/locale';
|
||||||
|
|
||||||
interface SettingsScreenProps {}
|
interface SettingsScreenProps {}
|
||||||
interface MenuButtonProps extends PropsWithChildren {
|
interface MenuButtonProps extends PropsWithChildren {
|
||||||
@@ -70,14 +70,29 @@ const goToStore = () => {
|
|||||||
Linking.openURL(storeURL);
|
Linking.openURL(storeURL);
|
||||||
};
|
};
|
||||||
|
|
||||||
const routes = [
|
const routes =
|
||||||
[Data, 'View passport info', 'PassportDataInfo'],
|
Platform.OS !== 'web'
|
||||||
[Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'],
|
? ([
|
||||||
[Cloud, 'Cloud backup', 'CloudBackupSettings'],
|
[Data, 'View passport info', 'PassportDataInfo'],
|
||||||
[Feedback, 'Send feeback', 'email_feedback'],
|
[Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'],
|
||||||
[ShareIcon, 'Share Self app', 'share'],
|
[Cloud, 'Cloud backup', 'CloudBackupSettings'],
|
||||||
[FileText as React.FC<SvgProps>, 'Manage ID documents', 'ManageDocuments'],
|
[Feedback, 'Send feedback', 'email_feedback'],
|
||||||
] satisfies [React.FC<SvgProps>, string, RouteOption][];
|
[ShareIcon, 'Share Self app', 'share'],
|
||||||
|
[
|
||||||
|
FileText as React.FC<SvgProps>,
|
||||||
|
'Manage ID documents',
|
||||||
|
'ManageDocuments',
|
||||||
|
],
|
||||||
|
] satisfies [React.FC<SvgProps>, string, RouteOption][])
|
||||||
|
: ([
|
||||||
|
[Data, 'View passport info', 'PassportDataInfo'],
|
||||||
|
[Feedback, 'Send feeback', 'email_feedback'],
|
||||||
|
[
|
||||||
|
FileText as React.FC<SvgProps>,
|
||||||
|
'Manage ID documents',
|
||||||
|
'ManageDocuments',
|
||||||
|
],
|
||||||
|
] satisfies [React.FC<SvgProps>, string, RouteOption][]);
|
||||||
|
|
||||||
// get the actual type of the routes so we can use in the onMenuPress function so it
|
// get the actual type of the routes so we can use in the onMenuPress function so it
|
||||||
// doesnt worry about us linking to screens with required props which we dont want to go to anyway
|
// doesnt worry about us linking to screens with required props which we dont want to go to anyway
|
||||||
|
|||||||
157
app/src/stores/database.ts
Normal file
157
app/src/stores/database.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import SQLite from 'react-native-sqlite-storage';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ProofDB,
|
||||||
|
ProofDBResult,
|
||||||
|
ProofHistory,
|
||||||
|
ProofStatus,
|
||||||
|
} from './proof-types';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const DB_NAME = 'proof_history.db';
|
||||||
|
const TABLE_NAME = 'proof_history';
|
||||||
|
const STALE_PROOF_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
SQLite.enablePromise(true);
|
||||||
|
|
||||||
|
async function openDatabase() {
|
||||||
|
return SQLite.openDatabase({
|
||||||
|
name: DB_NAME,
|
||||||
|
location: 'default',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const database: ProofDB = {
|
||||||
|
updateStaleProofs: async (
|
||||||
|
setProofStatus: (id: string, status: ProofStatus) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
const db = await openDatabase();
|
||||||
|
const staleTimestamp = Date.now() - STALE_PROOF_TIMEOUT_MS;
|
||||||
|
const [stalePending] = await db.executeSql(
|
||||||
|
`SELECT sessionId FROM ${TABLE_NAME} WHERE status = ? AND timestamp <= ?`,
|
||||||
|
[ProofStatus.PENDING, staleTimestamp],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Improved error handling - wrap each setProofStatus call in try-catch
|
||||||
|
let successfulUpdates = 0;
|
||||||
|
let failedUpdates = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < stalePending.rows.length; i++) {
|
||||||
|
const { sessionId } = stalePending.rows.item(i);
|
||||||
|
try {
|
||||||
|
await setProofStatus(sessionId, ProofStatus.FAILURE);
|
||||||
|
successfulUpdates++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to update proof status for session ${sessionId}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
failedUpdates++;
|
||||||
|
// Continue with the next iteration instead of stopping the entire loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stalePending.rows.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`Stale proof cleanup: ${successfulUpdates} successful, ${failedUpdates} failed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getPendingProofs: async (): Promise<ProofDBResult> => {
|
||||||
|
const db = await openDatabase();
|
||||||
|
|
||||||
|
const [pendingProofs] = await db.executeSql(`
|
||||||
|
SELECT * FROM ${TABLE_NAME} WHERE status = '${ProofStatus.PENDING}'
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: pendingProofs.rows.raw(),
|
||||||
|
total_count: pendingProofs.rows.item(0)?.total_count,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getHistory: async (page: number = 1): Promise<ProofDBResult> => {
|
||||||
|
const db = await openDatabase();
|
||||||
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
|
const [results] = await db.executeSql(
|
||||||
|
`WITH data AS (
|
||||||
|
SELECT *, COUNT(*) OVER() as total_count
|
||||||
|
FROM ${TABLE_NAME}
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
)
|
||||||
|
SELECT * FROM data`,
|
||||||
|
[PAGE_SIZE, offset],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
rows: results.rows.raw(),
|
||||||
|
total_count: results.rows.item(0)?.total_count,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
init: async () => {
|
||||||
|
const db = await openDatabase();
|
||||||
|
await db.executeSql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
appName TEXT NOT NULL,
|
||||||
|
sessionId TEXT NOT NULL UNIQUE,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
userIdType TEXT NOT NULL,
|
||||||
|
endpointType TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
errorCode TEXT,
|
||||||
|
errorReason TEXT,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
disclosures TEXT NOT NULL,
|
||||||
|
logoBase64 TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.executeSql(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_proof_history_timestamp ON ${TABLE_NAME} (timestamp)
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
async insertProof(proof: Omit<ProofHistory, 'id' | 'timestamp'>) {
|
||||||
|
const db = await openDatabase();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
const [insertResult] = await db.executeSql(
|
||||||
|
`INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
proof.appName,
|
||||||
|
proof.endpointType,
|
||||||
|
proof.status,
|
||||||
|
proof.errorCode || null,
|
||||||
|
proof.errorReason || null,
|
||||||
|
timestamp,
|
||||||
|
proof.disclosures,
|
||||||
|
proof.logoBase64 || null,
|
||||||
|
proof.userId,
|
||||||
|
proof.userIdType,
|
||||||
|
proof.sessionId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: insertResult.insertId.toString(),
|
||||||
|
timestamp,
|
||||||
|
rowsAffected: insertResult.rowsAffected,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async updateProofStatus(
|
||||||
|
status: ProofStatus,
|
||||||
|
errorCode: string | undefined,
|
||||||
|
errorReason: string | undefined,
|
||||||
|
sessionId: string,
|
||||||
|
) {
|
||||||
|
const db = await openDatabase();
|
||||||
|
await db.executeSql(
|
||||||
|
`
|
||||||
|
UPDATE ${TABLE_NAME} SET status = ?, errorCode = ?, errorReason = ? WHERE sessionId = ?
|
||||||
|
`,
|
||||||
|
[status, errorCode, errorReason, sessionId],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
256
app/src/stores/database.web.ts
Normal file
256
app/src/stores/database.web.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import {
|
||||||
|
ProofDB,
|
||||||
|
ProofDBResult,
|
||||||
|
ProofHistory,
|
||||||
|
ProofStatus,
|
||||||
|
} from './proof-types';
|
||||||
|
|
||||||
|
export const DB_NAME = 'proof_history_db';
|
||||||
|
const STORE_NAME = 'proof_history';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
class IndexedDBDatabase implements ProofDB {
|
||||||
|
private db: IDBDatabase | null = null;
|
||||||
|
|
||||||
|
private async openDatabase(): Promise<IDBDatabase> {
|
||||||
|
if (this.db) {
|
||||||
|
return this.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result;
|
||||||
|
resolve(request.result);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = event => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
// Create the object store
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
const store = db.createObjectStore(STORE_NAME, {
|
||||||
|
keyPath: 'id',
|
||||||
|
autoIncrement: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
store.createIndex('sessionId', 'sessionId', { unique: true });
|
||||||
|
store.createIndex('status', 'status', { unique: false });
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStaleProofs(
|
||||||
|
setProofStatus: (id: string, status: ProofStatus) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
|
||||||
|
const staleTimestamp = Date.now() - 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const statusIndex = store.index('status');
|
||||||
|
const request = statusIndex.getAll(ProofStatus.PENDING);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = async () => {
|
||||||
|
const staleProofs = request.result.filter(
|
||||||
|
proof => proof.timestamp <= staleTimestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const proof of staleProofs) {
|
||||||
|
try {
|
||||||
|
await setProofStatus(proof.sessionId, ProofStatus.FAILURE);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to update proof status for session ${proof.sessionId}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingProofs(): Promise<ProofDBResult> {
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const statusIndex = store.index('status');
|
||||||
|
const request = statusIndex.getAll(ProofStatus.PENDING);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
resolve({ rows: request.result });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(page: number = 1): Promise<ProofDBResult> {
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const timestampIndex = store.index('timestamp');
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countRequest = store.count();
|
||||||
|
countRequest.onerror = () => reject(countRequest.error);
|
||||||
|
|
||||||
|
countRequest.onsuccess = () => {
|
||||||
|
const totalCount = countRequest.result;
|
||||||
|
|
||||||
|
// Get paginated results (IndexedDB doesn't have OFFSET, so we need to handle pagination manually)
|
||||||
|
const request = timestampIndex.openCursor(null, 'prev');
|
||||||
|
const results: ProofHistory[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
let returned = 0;
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = event => {
|
||||||
|
const cursor = (event.target as IDBRequest).result;
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
if (skipped < offset) {
|
||||||
|
skipped++;
|
||||||
|
cursor.continue();
|
||||||
|
} else if (returned < PAGE_SIZE) {
|
||||||
|
results.push(cursor.value);
|
||||||
|
returned++;
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
// Add total count to the first result for compatibility
|
||||||
|
const resultWithCount = results.map((item, index) =>
|
||||||
|
index === 0 ? { ...item, total_count: totalCount } : item,
|
||||||
|
);
|
||||||
|
resolve({ rows: resultWithCount, total_count: totalCount });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add total count to the first result for compatibility
|
||||||
|
const resultWithCount = results.map((item, index) =>
|
||||||
|
index === 0 ? { ...item, total_count: totalCount } : item,
|
||||||
|
);
|
||||||
|
resolve({ rows: resultWithCount, total_count: totalCount });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
// Database initialization is handled in openDatabase
|
||||||
|
await this.openDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertProof(
|
||||||
|
proof: Omit<ProofHistory, 'id' | 'timestamp'>,
|
||||||
|
): Promise<{ id: string; timestamp: number; rowsAffected: number }> {
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
|
||||||
|
const proofWithId = {
|
||||||
|
...proof,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = store.add(proofWithId);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
// Handle unique constraint violation for sessionId
|
||||||
|
if (request.error?.name === 'ConstraintError') {
|
||||||
|
// Find existing record by sessionId and update it
|
||||||
|
const sessionIdIndex = store.index('sessionId');
|
||||||
|
const getRequest = sessionIdIndex.get(proof.sessionId);
|
||||||
|
getRequest.onsuccess = () => {
|
||||||
|
const existing = getRequest.result;
|
||||||
|
if (existing) {
|
||||||
|
const updateRequest = store.put({
|
||||||
|
...existing,
|
||||||
|
...proofWithId,
|
||||||
|
id: existing.id, // Preserve the original ID
|
||||||
|
});
|
||||||
|
updateRequest.onerror = () => reject(updateRequest.error);
|
||||||
|
updateRequest.onsuccess = () => {
|
||||||
|
resolve({
|
||||||
|
id: existing.id.toString(),
|
||||||
|
timestamp,
|
||||||
|
rowsAffected: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
reject(new Error('Constraint error but record not found'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
getRequest.onerror = () => reject(getRequest.error);
|
||||||
|
} else {
|
||||||
|
reject(request.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
resolve({
|
||||||
|
id: request.result?.toString() || '',
|
||||||
|
timestamp,
|
||||||
|
rowsAffected: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProofStatus(
|
||||||
|
status: ProofStatus,
|
||||||
|
errorCode: string | undefined,
|
||||||
|
errorReason: string | undefined,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await this.openDatabase();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
const sessionIdIndex = store.index('sessionId');
|
||||||
|
|
||||||
|
// First find the record by sessionId
|
||||||
|
const getRequest = sessionIdIndex.get(sessionId);
|
||||||
|
|
||||||
|
getRequest.onerror = () => reject(getRequest.error);
|
||||||
|
getRequest.onsuccess = () => {
|
||||||
|
const existingProof = getRequest.result;
|
||||||
|
if (existingProof) {
|
||||||
|
const updatedProof = {
|
||||||
|
...existingProof,
|
||||||
|
status,
|
||||||
|
errorCode,
|
||||||
|
errorReason,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRequest = store.put(updatedProof);
|
||||||
|
updateRequest.onerror = () => reject(updateRequest.error);
|
||||||
|
updateRequest.onsuccess = () => resolve();
|
||||||
|
} else {
|
||||||
|
resolve(); // No record found, nothing to update
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const database: ProofDB = new IndexedDBDatabase();
|
||||||
49
app/src/stores/proof-types.ts
Normal file
49
app/src/stores/proof-types.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { type EndpointType, UserIdType } from '@selfxyz/common';
|
||||||
|
|
||||||
|
export interface ProofHistory {
|
||||||
|
id: string;
|
||||||
|
appName: string;
|
||||||
|
sessionId: string;
|
||||||
|
userId: string;
|
||||||
|
userIdType: UserIdType;
|
||||||
|
endpointType: EndpointType;
|
||||||
|
status: ProofStatus;
|
||||||
|
errorCode?: string;
|
||||||
|
errorReason?: string;
|
||||||
|
timestamp: number;
|
||||||
|
disclosures: string;
|
||||||
|
logoBase64?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ProofStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
SUCCESS = 'success',
|
||||||
|
FAILURE = 'failure',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProofDBResult {
|
||||||
|
rows: ProofHistory[];
|
||||||
|
rowsAffected?: number;
|
||||||
|
insertId?: string;
|
||||||
|
total_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProofDB {
|
||||||
|
updateStaleProofs: (
|
||||||
|
updateProofStatus: (id: string, status: ProofStatus) => Promise<void>,
|
||||||
|
) => Promise<void>;
|
||||||
|
getPendingProofs: () => Promise<ProofDBResult>;
|
||||||
|
getHistory: (page?: number) => Promise<ProofDBResult>;
|
||||||
|
init: () => Promise<void>;
|
||||||
|
insertProof: (
|
||||||
|
proof: Omit<ProofHistory, 'id' | 'timestamp'>,
|
||||||
|
) => Promise<{ id: string; timestamp: number; rowsAffected: number }>;
|
||||||
|
updateProofStatus: (
|
||||||
|
status: ProofStatus,
|
||||||
|
errorCode: string | undefined,
|
||||||
|
errorReason: string | undefined,
|
||||||
|
sessionId: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
@@ -1,35 +1,11 @@
|
|||||||
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
import type { EndpointType } from '@selfxyz/common';
|
|
||||||
import { WS_DB_RELAYER } from '@selfxyz/common';
|
import { WS_DB_RELAYER } from '@selfxyz/common';
|
||||||
import { UserIdType } from '@selfxyz/common';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
import SQLite from 'react-native-sqlite-storage';
|
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
SQLite.enablePromise(true);
|
import { database } from './database';
|
||||||
|
import { ProofHistory, ProofStatus } from './proof-types';
|
||||||
export interface ProofHistory {
|
|
||||||
id: string;
|
|
||||||
appName: string;
|
|
||||||
sessionId: string;
|
|
||||||
userId: string;
|
|
||||||
userIdType: UserIdType;
|
|
||||||
endpointType: EndpointType;
|
|
||||||
status: ProofStatus;
|
|
||||||
errorCode?: string;
|
|
||||||
errorReason?: string;
|
|
||||||
timestamp: number;
|
|
||||||
disclosures: string;
|
|
||||||
logoBase64?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ProofStatus {
|
|
||||||
PENDING = 'pending',
|
|
||||||
SUCCESS = 'success',
|
|
||||||
FAILURE = 'failure',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProofHistoryState {
|
interface ProofHistoryState {
|
||||||
proofHistory: ProofHistory[];
|
proofHistory: ProofHistory[];
|
||||||
@@ -50,10 +26,6 @@ interface ProofHistoryState {
|
|||||||
resetHistory: () => void;
|
resetHistory: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
const DB_NAME = Platform.OS === 'ios' ? 'proof_history.db' : 'proof_history.db';
|
|
||||||
const TABLE_NAME = 'proof_history';
|
|
||||||
const STALE_PROOF_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
||||||
const SYNC_THROTTLE_MS = 30 * 1000; // 30 seconds throttle for sync calls
|
const SYNC_THROTTLE_MS = 30 * 1000; // 30 seconds throttle for sync calls
|
||||||
|
|
||||||
export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
|
export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
|
||||||
@@ -70,45 +42,10 @@ export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
|
|||||||
lastSyncTime = now;
|
lastSyncTime = now;
|
||||||
|
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
const db = await SQLite.openDatabase({
|
|
||||||
name: DB_NAME,
|
|
||||||
location: 'default',
|
|
||||||
});
|
|
||||||
|
|
||||||
const tenMinutesAgo = Date.now() - STALE_PROOF_TIMEOUT_MS;
|
await database.updateStaleProofs(get().updateProofStatus);
|
||||||
const [stalePending] = await db.executeSql(
|
|
||||||
`SELECT sessionId FROM ${TABLE_NAME} WHERE status = ? AND timestamp <= ?`,
|
|
||||||
[ProofStatus.PENDING, tenMinutesAgo],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Improved error handling - wrap each updateProofStatus call in try-catch
|
const pendingProofs = await database.getPendingProofs();
|
||||||
let successfulUpdates = 0;
|
|
||||||
let failedUpdates = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < stalePending.rows.length; i++) {
|
|
||||||
const { sessionId } = stalePending.rows.item(i);
|
|
||||||
try {
|
|
||||||
await get().updateProofStatus(sessionId, ProofStatus.FAILURE);
|
|
||||||
successfulUpdates++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Failed to update proof status for session ${sessionId}:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
failedUpdates++;
|
|
||||||
// Continue with the next iteration instead of stopping the entire loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stalePending.rows.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`Stale proof cleanup: ${successfulUpdates} successful, ${failedUpdates} failed`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [pendingProofs] = await db.executeSql(`
|
|
||||||
SELECT * FROM ${TABLE_NAME} WHERE status = '${ProofStatus.PENDING}'
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (pendingProofs.rows.length === 0) {
|
if (pendingProofs.rows.length === 0) {
|
||||||
console.log('No pending proofs to sync');
|
console.log('No pending proofs to sync');
|
||||||
@@ -119,13 +56,18 @@ export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
|
|||||||
path: '/',
|
path: '/',
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
});
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
websocket.connected && websocket.disconnect();
|
||||||
|
console.log('WebSocket disconnected after timeout');
|
||||||
|
// disconnect after 2 minutes
|
||||||
|
}, SYNC_THROTTLE_MS * 4);
|
||||||
|
|
||||||
for (let i = 0; i < pendingProofs.rows.length; i++) {
|
for (let i = 0; i < pendingProofs.rows.length; i++) {
|
||||||
const proof = pendingProofs.rows.item(i);
|
const proof = pendingProofs.rows[i];
|
||||||
websocket.emit('subscribe', proof.sessionId);
|
websocket.emit('subscribe', proof.sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
websocket.on('status', message => {
|
websocket.timeout(SYNC_THROTTLE_MS * 3).on('status', message => {
|
||||||
const data =
|
const data =
|
||||||
typeof message === 'string' ? JSON.parse(message) : message;
|
typeof message === 'string' ? JSON.parse(message) : message;
|
||||||
|
|
||||||
@@ -139,6 +81,7 @@ export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
|
|||||||
console.log('Failed to verify proof');
|
console.log('Failed to verify proof');
|
||||||
get().updateProofStatus(data.request_id, ProofStatus.FAILURE);
|
get().updateProofStatus(data.request_id, ProofStatus.FAILURE);
|
||||||
}
|
}
|
||||||
|
websocket.emit('unsubscribe', data.request_id);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error syncing proof status', error);
|
console.error('Error syncing proof status', error);
|
||||||
@@ -155,31 +98,7 @@ export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
|
|||||||
|
|
||||||
initDatabase: async () => {
|
initDatabase: async () => {
|
||||||
try {
|
try {
|
||||||
const db = await SQLite.openDatabase({
|
await database.init();
|
||||||
name: DB_NAME,
|
|
||||||
location: 'default',
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.executeSql(`
|
|
||||||
CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
appName TEXT NOT NULL,
|
|
||||||
sessionId TEXT NOT NULL UNIQUE,
|
|
||||||
userId TEXT NOT NULL,
|
|
||||||
userIdType TEXT NOT NULL,
|
|
||||||
endpointType TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
errorCode TEXT,
|
|
||||||
errorReason TEXT,
|
|
||||||
timestamp INTEGER NOT NULL,
|
|
||||||
disclosures TEXT NOT NULL,
|
|
||||||
logoBase64 TEXT
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
await db.executeSql(`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_proof_history_timestamp ON ${TABLE_NAME} (timestamp)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
const state = get();
|
const state = get();
|
||||||
@@ -196,33 +115,10 @@ export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
|
|||||||
|
|
||||||
addProofHistory: async proof => {
|
addProofHistory: async proof => {
|
||||||
try {
|
try {
|
||||||
const db = await SQLite.openDatabase({
|
const insertResult = await database.insertProof(proof);
|
||||||
name: DB_NAME,
|
|
||||||
location: 'default',
|
|
||||||
});
|
|
||||||
|
|
||||||
const timestamp = Date.now();
|
if (insertResult.rowsAffected > 0 && insertResult.id) {
|
||||||
|
const { id, timestamp } = insertResult;
|
||||||
const [insertResult] = await db.executeSql(
|
|
||||||
`INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[
|
|
||||||
proof.appName,
|
|
||||||
proof.endpointType,
|
|
||||||
proof.status,
|
|
||||||
proof.errorCode || null,
|
|
||||||
proof.errorReason || null,
|
|
||||||
timestamp,
|
|
||||||
proof.disclosures,
|
|
||||||
proof.logoBase64 || null,
|
|
||||||
proof.userId,
|
|
||||||
proof.userIdType,
|
|
||||||
proof.sessionId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (insertResult.rowsAffected > 0 && insertResult.insertId) {
|
|
||||||
const id = insertResult.insertId.toString();
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
proofHistory: [
|
proofHistory: [
|
||||||
{
|
{
|
||||||
@@ -242,17 +138,12 @@ export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
|
|||||||
|
|
||||||
updateProofStatus: async (sessionId, status, errorCode, errorReason) => {
|
updateProofStatus: async (sessionId, status, errorCode, errorReason) => {
|
||||||
try {
|
try {
|
||||||
const db = await SQLite.openDatabase({
|
await database.updateProofStatus(
|
||||||
name: DB_NAME,
|
status,
|
||||||
location: 'default',
|
errorCode,
|
||||||
});
|
errorReason,
|
||||||
await db.executeSql(
|
sessionId,
|
||||||
`
|
|
||||||
UPDATE ${TABLE_NAME} SET status = ?, errorCode = ?, errorReason = ? WHERE sessionId = ?
|
|
||||||
`,
|
|
||||||
[status, errorCode, errorReason, sessionId],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the status in the state
|
// Update the status in the state
|
||||||
set(state => ({
|
set(state => ({
|
||||||
proofHistory: state.proofHistory.map(proof =>
|
proofHistory: state.proofHistory.map(proof =>
|
||||||
@@ -273,29 +164,12 @@ export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
|
|||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = await SQLite.openDatabase({
|
const results = await database.getHistory(state.currentPage);
|
||||||
name: DB_NAME,
|
|
||||||
location: 'default',
|
|
||||||
});
|
|
||||||
const offset = (state.currentPage - 1) * PAGE_SIZE;
|
|
||||||
|
|
||||||
const [results] = await db.executeSql(
|
|
||||||
`WITH data AS (
|
|
||||||
SELECT *, COUNT(*) OVER() as total_count
|
|
||||||
FROM ${TABLE_NAME}
|
|
||||||
ORDER BY timestamp DESC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
)
|
|
||||||
SELECT * FROM data`,
|
|
||||||
[PAGE_SIZE, offset],
|
|
||||||
);
|
|
||||||
|
|
||||||
const proofs: ProofHistory[] = [];
|
const proofs: ProofHistory[] = [];
|
||||||
let totalCount = 0;
|
let totalCount = results.total_count || 0;
|
||||||
|
|
||||||
for (let i = 0; i < results.rows.length; i++) {
|
for (let i = 0; i < results.rows.length; i++) {
|
||||||
const row = results.rows.item(i);
|
const row = results.rows[i];
|
||||||
totalCount = row.total_count; // same for all rows
|
|
||||||
proofs.push({
|
proofs.push({
|
||||||
id: row.id.toString(),
|
id: row.id.toString(),
|
||||||
sessionId: row.sessionId,
|
sessionId: row.sessionId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
import { Linking } from 'react-native';
|
import { Linking, Platform } from 'react-native';
|
||||||
|
|
||||||
import { navigationRef } from '../navigation';
|
import { navigationRef } from '../navigation';
|
||||||
import { useSelfAppStore } from '../stores/selfAppStore';
|
import { useSelfAppStore } from '../stores/selfAppStore';
|
||||||
@@ -75,6 +75,9 @@ export const handleUrl = (uri: string) => {
|
|||||||
}
|
}
|
||||||
navigationRef.navigate('QRCodeTrouble');
|
navigationRef.navigate('QRCodeTrouble');
|
||||||
}
|
}
|
||||||
|
} else if (Platform.OS === 'web') {
|
||||||
|
// TODO: web handle links if we need to idk if we do
|
||||||
|
// For web, we can handle the URL some other way if we dont do this loading app in web always navigates to QRCodeTrouble
|
||||||
} else {
|
} else {
|
||||||
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
||||||
console.error('No sessionId or selfApp found in the data');
|
console.error('No sessionId or selfApp found in the data');
|
||||||
|
|||||||
@@ -1,30 +1,8 @@
|
|||||||
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
import { Platform, Vibration } from 'react-native';
|
import { Platform, Vibration } from 'react-native';
|
||||||
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
|
|
||||||
|
|
||||||
export type HapticType =
|
import { triggerFeedback } from './trigger';
|
||||||
| 'selection'
|
|
||||||
| 'impactLight'
|
|
||||||
| 'impactMedium'
|
|
||||||
| 'impactHeavy'
|
|
||||||
| 'notificationSuccess'
|
|
||||||
| 'notificationWarning'
|
|
||||||
| 'notificationError';
|
|
||||||
|
|
||||||
export type HapticOptions = {
|
|
||||||
enableVibrateFallback?: boolean;
|
|
||||||
ignoreAndroidSystemSettings?: boolean;
|
|
||||||
pattern?: number[];
|
|
||||||
increaseIosIntensity?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultOptions: HapticOptions = {
|
|
||||||
enableVibrateFallback: true,
|
|
||||||
ignoreAndroidSystemSettings: false,
|
|
||||||
pattern: [50, 100, 50],
|
|
||||||
increaseIosIntensity: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keep track of the loading screen interval
|
// Keep track of the loading screen interval
|
||||||
let loadingScreenInterval: NodeJS.Timeout | null = null;
|
let loadingScreenInterval: NodeJS.Timeout | null = null;
|
||||||
@@ -181,34 +159,4 @@ export const feedbackUnsuccessful = () => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export { triggerFeedback } from './trigger';
|
||||||
* Triggers haptic feedback or vibration based on platform.
|
|
||||||
* @param type - The haptic feedback type.
|
|
||||||
* @param options - Custom options (optional).
|
|
||||||
*/
|
|
||||||
export const triggerFeedback = (
|
|
||||||
type: HapticType | 'custom',
|
|
||||||
options: HapticOptions = {},
|
|
||||||
) => {
|
|
||||||
const mergedOptions = { ...defaultOptions, ...options };
|
|
||||||
if (Platform.OS === 'ios' && type !== 'custom') {
|
|
||||||
if (mergedOptions.increaseIosIntensity) {
|
|
||||||
if (type === 'impactLight') {
|
|
||||||
type = 'impactMedium';
|
|
||||||
} else if (type === 'impactMedium') {
|
|
||||||
type = 'impactHeavy';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactNativeHapticFeedback.trigger(type, {
|
|
||||||
enableVibrateFallback: mergedOptions.enableVibrateFallback,
|
|
||||||
ignoreAndroidSystemSettings: mergedOptions.ignoreAndroidSystemSettings,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (mergedOptions.pattern) {
|
|
||||||
Vibration.vibrate(mergedOptions.pattern, false);
|
|
||||||
} else {
|
|
||||||
Vibration.vibrate(100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
24
app/src/utils/haptic/shared.ts
Normal file
24
app/src/utils/haptic/shared.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
export type HapticType =
|
||||||
|
| 'selection'
|
||||||
|
| 'impactLight'
|
||||||
|
| 'impactMedium'
|
||||||
|
| 'impactHeavy'
|
||||||
|
| 'notificationSuccess'
|
||||||
|
| 'notificationWarning'
|
||||||
|
| 'notificationError';
|
||||||
|
|
||||||
|
export type HapticOptions = {
|
||||||
|
enableVibrateFallback?: boolean;
|
||||||
|
ignoreAndroidSystemSettings?: boolean;
|
||||||
|
pattern?: number[];
|
||||||
|
increaseIosIntensity?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultOptions: HapticOptions = {
|
||||||
|
enableVibrateFallback: true,
|
||||||
|
ignoreAndroidSystemSettings: false,
|
||||||
|
pattern: [50, 100, 50],
|
||||||
|
increaseIosIntensity: true,
|
||||||
|
};
|
||||||
37
app/src/utils/haptic/trigger.ts
Normal file
37
app/src/utils/haptic/trigger.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { Platform, Vibration } from 'react-native';
|
||||||
|
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
|
||||||
|
|
||||||
|
import { defaultOptions, HapticOptions, HapticType } from './shared';
|
||||||
|
/**
|
||||||
|
* Triggers haptic feedback or vibration based on platform.
|
||||||
|
* @param type - The haptic feedback type.
|
||||||
|
* @param options - Custom options (optional).
|
||||||
|
*/
|
||||||
|
export const triggerFeedback = (
|
||||||
|
type: HapticType | 'custom',
|
||||||
|
options: HapticOptions = {},
|
||||||
|
) => {
|
||||||
|
const mergedOptions = { ...defaultOptions, ...options };
|
||||||
|
if (Platform.OS === 'ios' && type !== 'custom') {
|
||||||
|
if (mergedOptions.increaseIosIntensity) {
|
||||||
|
if (type === 'impactLight') {
|
||||||
|
type = 'impactMedium';
|
||||||
|
} else if (type === 'impactMedium') {
|
||||||
|
type = 'impactHeavy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactNativeHapticFeedback.trigger(type, {
|
||||||
|
enableVibrateFallback: mergedOptions.enableVibrateFallback,
|
||||||
|
ignoreAndroidSystemSettings: mergedOptions.ignoreAndroidSystemSettings,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (mergedOptions.pattern) {
|
||||||
|
Vibration.vibrate(mergedOptions.pattern, false);
|
||||||
|
} else {
|
||||||
|
Vibration.vibrate(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
27
app/src/utils/haptic/trigger.web.ts
Normal file
27
app/src/utils/haptic/trigger.web.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { defaultOptions, HapticOptions, HapticType } from './shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers haptic feedback or vibration based on platform.
|
||||||
|
* @param type - The haptic feedback type. (only here for compatibility, not used in web)
|
||||||
|
* @param options - Custom options (optional).
|
||||||
|
*/
|
||||||
|
export const triggerFeedback = (
|
||||||
|
_type: HapticType | 'custom',
|
||||||
|
options: HapticOptions = {},
|
||||||
|
) => {
|
||||||
|
const mergedOptions = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
// Check if Vibration API is available
|
||||||
|
if (!navigator.vibrate) {
|
||||||
|
console.warn('Vibration API not supported in this browser');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergedOptions.pattern) {
|
||||||
|
navigator.vibrate(mergedOptions.pattern);
|
||||||
|
} else {
|
||||||
|
navigator.vibrate(100);
|
||||||
|
}
|
||||||
|
};
|
||||||
3
app/src/utils/locale.ts
Normal file
3
app/src/utils/locale.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
export { getCountry, getLocales, getTimeZone } from 'react-native-localize';
|
||||||
32
app/src/utils/locale.web.ts
Normal file
32
app/src/utils/locale.web.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
export function getCountry() {
|
||||||
|
const locale = new Intl.Locale(navigator.language);
|
||||||
|
return locale.region ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
type Locale = {
|
||||||
|
languageCode: string;
|
||||||
|
scriptCode?: string;
|
||||||
|
countryCode: string;
|
||||||
|
languageTag: string;
|
||||||
|
isRTL: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLocales(): Locale[] {
|
||||||
|
return navigator.languages.map(lang => {
|
||||||
|
const locale = new Intl.Locale(lang);
|
||||||
|
return {
|
||||||
|
languageCode: locale.language,
|
||||||
|
countryCode: locale.region ?? '',
|
||||||
|
scriptCode: locale.script,
|
||||||
|
languageTag: lang,
|
||||||
|
// @ts-expect-error this not in type but appears to be in browsers
|
||||||
|
isRTL: locale.textInfo?.direction === 'rtl',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTimeZone(): string {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
53
app/src/utils/notifications/notificationService.shared.ts
Normal file
53
app/src/utils/notifications/notificationService.shared.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
export const getStateMessage = (state: string): string => {
|
||||||
|
switch (state) {
|
||||||
|
case 'idle':
|
||||||
|
return 'Getting ready...';
|
||||||
|
case 'fetching_data':
|
||||||
|
return 'Fetching data...';
|
||||||
|
case 'validating_document':
|
||||||
|
return 'Validating document...';
|
||||||
|
case 'init_tee_connexion':
|
||||||
|
return 'Preparing secure environment...';
|
||||||
|
case 'ready_to_prove':
|
||||||
|
return 'Ready to prove...';
|
||||||
|
case 'proving':
|
||||||
|
return 'Generating proof...';
|
||||||
|
case 'post_proving':
|
||||||
|
return 'Finalizing...';
|
||||||
|
case 'completed':
|
||||||
|
return 'Verification completed!';
|
||||||
|
case 'error':
|
||||||
|
return 'Error occurred';
|
||||||
|
case 'passport_not_supported':
|
||||||
|
return 'Passport not supported';
|
||||||
|
case 'account_recovery_choice':
|
||||||
|
return 'Account recovery needed';
|
||||||
|
case 'passport_data_not_found':
|
||||||
|
return 'Passport data not found';
|
||||||
|
case 'failure':
|
||||||
|
return 'Verification failed';
|
||||||
|
default:
|
||||||
|
return 'Processing...';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RemoteMessage {
|
||||||
|
messageId?: string;
|
||||||
|
data?: { [key: string]: string | object };
|
||||||
|
notification?: {
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTokenRegistration {
|
||||||
|
session_id: string;
|
||||||
|
device_token: string;
|
||||||
|
platform: 'ios' | 'android' | 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_URL = 'https://notification.self.xyz';
|
||||||
|
export const API_URL_STAGING = 'https://notification.staging.self.xyz';
|
||||||
@@ -3,9 +3,15 @@
|
|||||||
import messaging from '@react-native-firebase/messaging';
|
import messaging from '@react-native-firebase/messaging';
|
||||||
import { PermissionsAndroid, Platform } from 'react-native';
|
import { PermissionsAndroid, Platform } from 'react-native';
|
||||||
|
|
||||||
const API_URL = 'https://notification.self.xyz';
|
import {
|
||||||
const API_URL_STAGING = 'https://notification.staging.self.xyz';
|
API_URL,
|
||||||
|
API_URL_STAGING,
|
||||||
|
DeviceTokenRegistration,
|
||||||
|
getStateMessage,
|
||||||
|
RemoteMessage,
|
||||||
|
} from './notificationService.shared';
|
||||||
|
|
||||||
|
export { getStateMessage };
|
||||||
// Determine if running in test environment
|
// Determine if running in test environment
|
||||||
const isTestEnv = process.env.NODE_ENV === 'test';
|
const isTestEnv = process.env.NODE_ENV === 'test';
|
||||||
const log = (...args: any[]) => {
|
const log = (...args: any[]) => {
|
||||||
@@ -15,39 +21,6 @@ const error = (...args: any[]) => {
|
|||||||
if (!isTestEnv) console.error(...args);
|
if (!isTestEnv) console.error(...args);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStateMessage = (state: string): string => {
|
|
||||||
switch (state) {
|
|
||||||
case 'idle':
|
|
||||||
return 'Getting ready...';
|
|
||||||
case 'fetching_data':
|
|
||||||
return 'Fetching data...';
|
|
||||||
case 'validating_document':
|
|
||||||
return 'Validating document...';
|
|
||||||
case 'init_tee_connexion':
|
|
||||||
return 'Preparing secure environment...';
|
|
||||||
case 'ready_to_prove':
|
|
||||||
return 'Ready to prove...';
|
|
||||||
case 'proving':
|
|
||||||
return 'Generating proof...';
|
|
||||||
case 'post_proving':
|
|
||||||
return 'Finalizing...';
|
|
||||||
case 'completed':
|
|
||||||
return 'Verification completed!';
|
|
||||||
case 'error':
|
|
||||||
return 'Error occurred';
|
|
||||||
case 'passport_not_supported':
|
|
||||||
return 'Passport not supported';
|
|
||||||
case 'account_recovery_choice':
|
|
||||||
return 'Account recovery needed';
|
|
||||||
case 'passport_data_not_found':
|
|
||||||
return 'Passport data not found';
|
|
||||||
case 'failure':
|
|
||||||
return 'Verification failed';
|
|
||||||
default:
|
|
||||||
return 'Processing...';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function requestNotificationPermission(): Promise<boolean> {
|
export async function requestNotificationPermission(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
@@ -61,7 +34,6 @@ export async function requestNotificationPermission(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authStatus = await messaging().requestPermission();
|
const authStatus = await messaging().requestPermission();
|
||||||
const enabled =
|
const enabled =
|
||||||
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
|
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
|
||||||
@@ -108,7 +80,7 @@ export async function registerDeviceToken(
|
|||||||
const cleanedToken = token.trim();
|
const cleanedToken = token.trim();
|
||||||
const baseUrl = isMockPassport ? API_URL_STAGING : API_URL;
|
const baseUrl = isMockPassport ? API_URL_STAGING : API_URL;
|
||||||
|
|
||||||
const deviceTokenRegistration = {
|
const deviceTokenRegistration: DeviceTokenRegistration = {
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
device_token: cleanedToken,
|
device_token: cleanedToken,
|
||||||
platform: Platform.OS === 'ios' ? 'ios' : 'android',
|
platform: Platform.OS === 'ios' ? 'ios' : 'android',
|
||||||
@@ -143,16 +115,6 @@ export async function registerDeviceToken(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteMessage {
|
|
||||||
messageId?: string;
|
|
||||||
data?: { [key: string]: string | object };
|
|
||||||
notification?: {
|
|
||||||
title?: string;
|
|
||||||
body?: string;
|
|
||||||
};
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupNotifications(): () => void {
|
export function setupNotifications(): () => void {
|
||||||
messaging().setBackgroundMessageHandler(
|
messaging().setBackgroundMessageHandler(
|
||||||
async (remoteMessage: RemoteMessage) => {
|
async (remoteMessage: RemoteMessage) => {
|
||||||
|
|||||||
145
app/src/utils/notifications/notificationService.web.ts
Normal file
145
app/src/utils/notifications/notificationService.web.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import {
|
||||||
|
API_URL,
|
||||||
|
API_URL_STAGING,
|
||||||
|
DeviceTokenRegistration,
|
||||||
|
getStateMessage,
|
||||||
|
} from './notificationService.shared';
|
||||||
|
|
||||||
|
// TODO: web handle notifications better. this file is more or less a fancy placeholder
|
||||||
|
|
||||||
|
export { getStateMessage };
|
||||||
|
|
||||||
|
export async function requestNotificationPermission(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
console.log('This browser does not support notifications');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
console.log('Notification permission already granted');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
console.log('Notification permission denied');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
const enabled = permission === 'granted';
|
||||||
|
|
||||||
|
console.log('Notification permission status:', enabled);
|
||||||
|
return enabled;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to request notification permission:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFCMToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// For web, we'll generate a simple token or use a service worker registration
|
||||||
|
// In a real implementation, you might want to use Firebase Web SDK or a custom solution
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registration = await navigator.serviceWorker
|
||||||
|
.register('/sw.js')
|
||||||
|
.catch(() => null);
|
||||||
|
if (registration) {
|
||||||
|
// Generate a simple token based on registration
|
||||||
|
const token = `web_${registration.active?.scriptURL || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
console.log('Web FCM Token generated');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: generate a simple token
|
||||||
|
const token = `web_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
console.log('Web FCM Token generated (fallback)');
|
||||||
|
return token;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get FCM token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerDeviceToken(
|
||||||
|
sessionId: string,
|
||||||
|
deviceToken?: string,
|
||||||
|
isMockPassport?: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
let token = deviceToken;
|
||||||
|
if (!token) {
|
||||||
|
const fcmToken = await getFCMToken();
|
||||||
|
if (!fcmToken) {
|
||||||
|
console.log('No FCM token available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
token = fcmToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedToken = token.trim();
|
||||||
|
const baseUrl = isMockPassport ? API_URL_STAGING : API_URL;
|
||||||
|
|
||||||
|
const deviceTokenRegistration: DeviceTokenRegistration = {
|
||||||
|
session_id: sessionId,
|
||||||
|
device_token: cleanedToken,
|
||||||
|
platform: 'web',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cleanedToken.length > 10) {
|
||||||
|
console.log(
|
||||||
|
'Registering device token:',
|
||||||
|
`${cleanedToken.substring(0, 5)}...${cleanedToken.substring(
|
||||||
|
cleanedToken.length - 5,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/register-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(deviceTokenRegistration),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(
|
||||||
|
'Failed to register device token:',
|
||||||
|
response.status,
|
||||||
|
errorText,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'Device token registered successfully with session_id:',
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error registering device token:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupNotifications(): () => void {
|
||||||
|
// For web, we'll set up service worker for background notifications
|
||||||
|
// and handle foreground notifications directly
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(error => {
|
||||||
|
console.error('Service Worker registration failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For web, we don't have a direct equivalent to Firebase messaging
|
||||||
|
// You might want to implement WebSocket or Server-Sent Events for real-time notifications
|
||||||
|
// For now, we'll return a no-op unsubscribe function
|
||||||
|
return () => {
|
||||||
|
console.log('Web notification service cleanup');
|
||||||
|
};
|
||||||
|
}
|
||||||
9
app/tests/__setup__/databaseMocks.ts
Normal file
9
app/tests/__setup__/databaseMocks.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
/* global jest */
|
||||||
|
|
||||||
|
// Mock for react-native-sqlite-storage
|
||||||
|
export const SQLite = {
|
||||||
|
enablePromise: jest.fn(),
|
||||||
|
openDatabase: jest.fn(),
|
||||||
|
};
|
||||||
443
app/tests/src/stores/database.test.ts
Normal file
443
app/tests/src/stores/database.test.ts
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import SQLite from 'react-native-sqlite-storage';
|
||||||
|
|
||||||
|
import { database } from '../../../src/stores/database';
|
||||||
|
import { ProofStatus } from '../../../src/stores/proof-types';
|
||||||
|
|
||||||
|
// Mock react-native-sqlite-storage
|
||||||
|
jest.mock('react-native-sqlite-storage', () => ({
|
||||||
|
enablePromise: jest.fn(),
|
||||||
|
openDatabase: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSQLite = SQLite as any;
|
||||||
|
|
||||||
|
describe('database (SQLite)', () => {
|
||||||
|
let mockDb: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Create mock database
|
||||||
|
mockDb = {
|
||||||
|
executeSql: jest.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockSQLite.openDatabase.mockResolvedValue(mockDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
it('creates table and index if they do not exist', async () => {
|
||||||
|
await database.init();
|
||||||
|
|
||||||
|
expect(mockSQLite.openDatabase).toHaveBeenCalledWith({
|
||||||
|
name: 'proof_history.db',
|
||||||
|
location: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Check table creation
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(`CREATE TABLE IF NOT EXISTS proof_history`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check index creation
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_proof_history_timestamp',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles initialization errors gracefully', async () => {
|
||||||
|
const initError = new Error('Table creation failed');
|
||||||
|
mockDb.executeSql.mockRejectedValueOnce(initError);
|
||||||
|
|
||||||
|
await expect(database.init()).rejects.toThrow('Table creation failed');
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('CREATE TABLE IF NOT EXISTS proof_history'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertProof', () => {
|
||||||
|
it('inserts a new proof successfully', async () => {
|
||||||
|
const mockProof = {
|
||||||
|
appName: 'TestApp',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'uuid' as const,
|
||||||
|
endpointType: 'https' as const,
|
||||||
|
status: ProofStatus.PENDING,
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
logoBase64: 'base64-logo',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockInsertResult = {
|
||||||
|
insertId: 1,
|
||||||
|
rowsAffected: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockInsertResult]);
|
||||||
|
|
||||||
|
const result = await database.insertProof(mockProof);
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('INSERT OR IGNORE INTO proof_history'),
|
||||||
|
[
|
||||||
|
mockProof.appName,
|
||||||
|
mockProof.endpointType,
|
||||||
|
mockProof.status,
|
||||||
|
null, // errorCode
|
||||||
|
null, // errorReason
|
||||||
|
expect.any(Number), // timestamp
|
||||||
|
mockProof.disclosures,
|
||||||
|
mockProof.logoBase64,
|
||||||
|
mockProof.userId,
|
||||||
|
mockProof.userIdType,
|
||||||
|
mockProof.sessionId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: '1',
|
||||||
|
timestamp: expect.any(Number),
|
||||||
|
rowsAffected: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles proof with error information', async () => {
|
||||||
|
const mockProof = {
|
||||||
|
appName: 'TestApp',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'uuid' as const,
|
||||||
|
endpointType: 'https' as const,
|
||||||
|
status: ProofStatus.FAILURE,
|
||||||
|
errorCode: 'ERROR_001',
|
||||||
|
errorReason: 'Test error',
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockInsertResult = {
|
||||||
|
insertId: 2,
|
||||||
|
rowsAffected: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockInsertResult]);
|
||||||
|
|
||||||
|
const result = await database.insertProof(mockProof);
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('INSERT OR IGNORE INTO proof_history'),
|
||||||
|
[
|
||||||
|
mockProof.appName,
|
||||||
|
mockProof.endpointType,
|
||||||
|
mockProof.status,
|
||||||
|
mockProof.errorCode,
|
||||||
|
mockProof.errorReason,
|
||||||
|
expect.any(Number),
|
||||||
|
mockProof.disclosures,
|
||||||
|
null, // logoBase64
|
||||||
|
mockProof.userId,
|
||||||
|
mockProof.userIdType,
|
||||||
|
mockProof.sessionId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: '2',
|
||||||
|
timestamp: expect.any(Number),
|
||||||
|
rowsAffected: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateProofStatus', () => {
|
||||||
|
it('updates proof status successfully', async () => {
|
||||||
|
const sessionId = 'session-123';
|
||||||
|
const status = ProofStatus.SUCCESS;
|
||||||
|
const errorCode = 'SUCCESS_001';
|
||||||
|
const errorReason = 'Operation completed';
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([{}]);
|
||||||
|
|
||||||
|
await database.updateProofStatus(
|
||||||
|
status,
|
||||||
|
errorCode,
|
||||||
|
errorReason,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'UPDATE proof_history SET status = ?, errorCode = ?, errorReason = ? WHERE sessionId = ?',
|
||||||
|
),
|
||||||
|
[status, errorCode, errorReason, sessionId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates proof status with undefined error information', async () => {
|
||||||
|
const sessionId = 'session-123';
|
||||||
|
const status = ProofStatus.SUCCESS;
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([{}]);
|
||||||
|
|
||||||
|
await database.updateProofStatus(status, undefined, undefined, sessionId);
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'UPDATE proof_history SET status = ?, errorCode = ?, errorReason = ? WHERE sessionId = ?',
|
||||||
|
),
|
||||||
|
[status, undefined, undefined, sessionId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPendingProofs', () => {
|
||||||
|
it('returns pending proofs successfully', async () => {
|
||||||
|
const mockRows = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
appName: 'TestApp',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'email',
|
||||||
|
endpointType: 'register',
|
||||||
|
status: ProofStatus.PENDING,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
rows: {
|
||||||
|
raw: jest.fn().mockReturnValue(mockRows),
|
||||||
|
item: jest.fn().mockReturnValue({ total_count: 1 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockResult]);
|
||||||
|
|
||||||
|
const result = await database.getPendingProofs();
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
"SELECT * FROM proof_history WHERE status = 'pending'",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
rows: mockRows,
|
||||||
|
total_count: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no pending proofs exist', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
rows: {
|
||||||
|
raw: jest.fn().mockReturnValue([]),
|
||||||
|
item: jest.fn().mockReturnValue(undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockResult]);
|
||||||
|
|
||||||
|
const result = await database.getPendingProofs();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
rows: [],
|
||||||
|
total_count: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getHistory', () => {
|
||||||
|
it('returns paginated history successfully', async () => {
|
||||||
|
const mockRows = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
appName: 'TestApp',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'email',
|
||||||
|
endpointType: 'register',
|
||||||
|
status: ProofStatus.SUCCESS,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
total_count: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
rows: {
|
||||||
|
raw: jest.fn().mockReturnValue(mockRows),
|
||||||
|
item: jest.fn().mockReturnValue({ total_count: 5 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockResult]);
|
||||||
|
|
||||||
|
const result = await database.getHistory(1);
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('WITH data AS'),
|
||||||
|
[20, 0], // PAGE_SIZE, offset
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
rows: mockRows,
|
||||||
|
total_count: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles second page correctly', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
rows: {
|
||||||
|
raw: jest.fn().mockReturnValue([]),
|
||||||
|
item: jest.fn().mockReturnValue({ total_count: 5 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockResult]);
|
||||||
|
|
||||||
|
await database.getHistory(2);
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('WITH data AS'),
|
||||||
|
[20, 20], // PAGE_SIZE, offset for page 2
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to page 1 when no page is provided', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
rows: {
|
||||||
|
raw: jest.fn().mockReturnValue([]),
|
||||||
|
item: jest.fn().mockReturnValue({ total_count: 0 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockResult]);
|
||||||
|
|
||||||
|
await database.getHistory();
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('WITH data AS'),
|
||||||
|
[20, 0], // PAGE_SIZE, offset for page 1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateStaleProofs', () => {
|
||||||
|
it('updates stale pending proofs to failure', async () => {
|
||||||
|
const mockSetProofStatus = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockStaleRows = [
|
||||||
|
{ sessionId: 'session-123' },
|
||||||
|
{ sessionId: 'session-456' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
rows: {
|
||||||
|
length: 2,
|
||||||
|
item: jest.fn((index: number) => mockStaleRows[index]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockResult]);
|
||||||
|
|
||||||
|
await database.updateStaleProofs(mockSetProofStatus);
|
||||||
|
|
||||||
|
expect(mockDb.executeSql).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'SELECT sessionId FROM proof_history WHERE status = ? AND timestamp <= ?',
|
||||||
|
),
|
||||||
|
[ProofStatus.PENDING, expect.any(Number)],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockSetProofStatus).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockSetProofStatus).toHaveBeenCalledWith(
|
||||||
|
'session-123',
|
||||||
|
ProofStatus.FAILURE,
|
||||||
|
);
|
||||||
|
expect(mockSetProofStatus).toHaveBeenCalledWith(
|
||||||
|
'session-456',
|
||||||
|
ProofStatus.FAILURE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles errors during status updates gracefully', async () => {
|
||||||
|
const mockSetProofStatus = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(undefined) // First call succeeds
|
||||||
|
.mockRejectedValueOnce(new Error('Update failed')); // Second call fails
|
||||||
|
|
||||||
|
const mockStaleRows = [
|
||||||
|
{ sessionId: 'session-123' },
|
||||||
|
{ sessionId: 'session-456' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
rows: {
|
||||||
|
length: 2,
|
||||||
|
item: jest.fn((index: number) => mockStaleRows[index]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockResult]);
|
||||||
|
|
||||||
|
// Should not throw error
|
||||||
|
await database.updateStaleProofs(mockSetProofStatus);
|
||||||
|
|
||||||
|
expect(mockSetProofStatus).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockSetProofStatus).toHaveBeenCalledWith(
|
||||||
|
'session-123',
|
||||||
|
ProofStatus.FAILURE,
|
||||||
|
);
|
||||||
|
expect(mockSetProofStatus).toHaveBeenCalledWith(
|
||||||
|
'session-456',
|
||||||
|
ProofStatus.FAILURE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles no stale proofs gracefully', async () => {
|
||||||
|
const mockSetProofStatus = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
rows: {
|
||||||
|
length: 0,
|
||||||
|
item: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb.executeSql.mockResolvedValueOnce([mockResult]);
|
||||||
|
|
||||||
|
await database.updateStaleProofs(mockSetProofStatus);
|
||||||
|
|
||||||
|
expect(mockSetProofStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('handles database errors gracefully', async () => {
|
||||||
|
const dbError = new Error('Database connection failed');
|
||||||
|
mockSQLite.openDatabase.mockRejectedValueOnce(dbError);
|
||||||
|
|
||||||
|
await expect(database.init()).rejects.toThrow(
|
||||||
|
'Database connection failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles SQL execution errors', async () => {
|
||||||
|
const sqlError = new Error('SQL execution failed');
|
||||||
|
mockDb.executeSql.mockRejectedValueOnce(sqlError);
|
||||||
|
|
||||||
|
await expect(database.getPendingProofs()).rejects.toThrow(
|
||||||
|
'SQL execution failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
313
app/tests/src/stores/proofHistoryStore.test.ts
Normal file
313
app/tests/src/stores/proofHistoryStore.test.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { act } from '@testing-library/react-native';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
import { database } from '../../../src/stores/database';
|
||||||
|
import { ProofStatus } from '../../../src/stores/proof-types';
|
||||||
|
import { useProofHistoryStore } from '../../../src/stores/proofHistoryStore';
|
||||||
|
|
||||||
|
// Mock socket.io-client
|
||||||
|
jest.mock('socket.io-client', () => ({
|
||||||
|
io: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock database
|
||||||
|
jest.mock('../../../src/stores/database', () => ({
|
||||||
|
database: {
|
||||||
|
init: jest.fn(),
|
||||||
|
insertProof: jest.fn(),
|
||||||
|
updateProofStatus: jest.fn(),
|
||||||
|
getHistory: jest.fn(),
|
||||||
|
getPendingProofs: jest.fn(),
|
||||||
|
updateStaleProofs: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockDatabase = database as any;
|
||||||
|
const mockIo = io as any;
|
||||||
|
|
||||||
|
describe('proofHistoryStore', () => {
|
||||||
|
let mockSocket: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useProofHistoryStore.setState({
|
||||||
|
proofHistory: [],
|
||||||
|
isLoading: false,
|
||||||
|
hasMore: true,
|
||||||
|
currentPage: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSocket = {
|
||||||
|
emit: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
};
|
||||||
|
mockIo.mockReturnValue(mockSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initDatabase', () => {
|
||||||
|
it('initializes database and loads initial data', async () => {
|
||||||
|
const mockHistoryResult = {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
appName: 'TestApp',
|
||||||
|
endpointType: 'celo',
|
||||||
|
status: ProofStatus.SUCCESS,
|
||||||
|
errorCode: null,
|
||||||
|
errorReason: null,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
logoBase64: 'base64-logo',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'uuid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_count: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDatabase.init.mockResolvedValue();
|
||||||
|
mockDatabase.getHistory.mockResolvedValue(mockHistoryResult);
|
||||||
|
mockDatabase.getPendingProofs.mockResolvedValue({ rows: [] });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore.getState().initDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDatabase.init).toHaveBeenCalled();
|
||||||
|
expect(mockDatabase.getHistory).toHaveBeenCalledWith(1);
|
||||||
|
expect(useProofHistoryStore.getState().proofHistory).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles initialization errors gracefully', async () => {
|
||||||
|
mockDatabase.init.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore.getState().initDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDatabase.init).toHaveBeenCalled();
|
||||||
|
expect(useProofHistoryStore.getState().proofHistory).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addProofHistory', () => {
|
||||||
|
it('adds a new proof to the store', async () => {
|
||||||
|
const mockProof = {
|
||||||
|
appName: 'TestApp',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'uuid',
|
||||||
|
endpointType: 'celo',
|
||||||
|
status: ProofStatus.PENDING,
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
logoBase64: 'base64-logo',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const mockInsertResult = {
|
||||||
|
id: '1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
rowsAffected: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDatabase.insertProof.mockResolvedValue(mockInsertResult);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore.getState().addProofHistory(mockProof);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDatabase.insertProof).toHaveBeenCalledWith(mockProof);
|
||||||
|
expect(useProofHistoryStore.getState().proofHistory).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles insertion errors gracefully', async () => {
|
||||||
|
const mockProof = {
|
||||||
|
appName: 'TestApp',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'uuid',
|
||||||
|
endpointType: 'celo',
|
||||||
|
status: ProofStatus.PENDING,
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
mockDatabase.insertProof.mockRejectedValue(new Error('Insert error'));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore.getState().addProofHistory(mockProof);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDatabase.insertProof).toHaveBeenCalledWith(mockProof);
|
||||||
|
expect(useProofHistoryStore.getState().proofHistory).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateProofStatus', () => {
|
||||||
|
it('updates proof status in database and store', async () => {
|
||||||
|
const mockProof = {
|
||||||
|
appName: 'TestApp',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'uuid',
|
||||||
|
endpointType: 'celo',
|
||||||
|
status: ProofStatus.PENDING,
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
mockDatabase.insertProof.mockResolvedValue({
|
||||||
|
id: '1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
rowsAffected: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore.getState().addProofHistory(mockProof);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore
|
||||||
|
.getState()
|
||||||
|
.updateProofStatus(
|
||||||
|
'session-123',
|
||||||
|
ProofStatus.SUCCESS,
|
||||||
|
'SUCCESS_001',
|
||||||
|
'Operation completed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDatabase.updateProofStatus).toHaveBeenCalledWith(
|
||||||
|
ProofStatus.SUCCESS,
|
||||||
|
'SUCCESS_001',
|
||||||
|
'Operation completed',
|
||||||
|
'session-123',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles status update errors gracefully', async () => {
|
||||||
|
const mockProof = {
|
||||||
|
appName: 'TestApp',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'uuid',
|
||||||
|
endpointType: 'https',
|
||||||
|
status: ProofStatus.PENDING,
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
mockDatabase.insertProof.mockResolvedValue({
|
||||||
|
id: '1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
rowsAffected: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore.getState().addProofHistory(mockProof);
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDatabase.updateProofStatus.mockRejectedValue(
|
||||||
|
new Error('Update failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore
|
||||||
|
.getState()
|
||||||
|
.updateProofStatus(
|
||||||
|
'session-123',
|
||||||
|
ProofStatus.SUCCESS,
|
||||||
|
'SUCCESS_001',
|
||||||
|
'Operation completed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDatabase.updateProofStatus).toHaveBeenCalled();
|
||||||
|
// Store should handle the error gracefully without crashing
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadMoreHistory', () => {
|
||||||
|
it('loads more history successfully', async () => {
|
||||||
|
const mockHistoryResult = {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
appName: 'TestApp1',
|
||||||
|
endpointType: 'celo',
|
||||||
|
status: ProofStatus.SUCCESS,
|
||||||
|
errorCode: null,
|
||||||
|
errorReason: null,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
disclosures: '{"test": "data1"}',
|
||||||
|
logoBase64: 'base64-logo1',
|
||||||
|
userId: 'user-1',
|
||||||
|
userIdType: 'uuid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_count: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDatabase.getHistory.mockResolvedValue(mockHistoryResult);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore.getState().loadMoreHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDatabase.getHistory).toHaveBeenCalledWith(1);
|
||||||
|
expect(useProofHistoryStore.getState().proofHistory).toHaveLength(1);
|
||||||
|
expect(useProofHistoryStore.getState().currentPage).toBe(2);
|
||||||
|
expect(useProofHistoryStore.getState().hasMore).toBe(true);
|
||||||
|
expect(useProofHistoryStore.getState().isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents loading when already loading', async () => {
|
||||||
|
act(() => {
|
||||||
|
useProofHistoryStore.setState({ isLoading: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore.getState().loadMoreHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDatabase.getHistory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetHistory', () => {
|
||||||
|
it('resets history state to initial values', async () => {
|
||||||
|
const mockProof = {
|
||||||
|
appName: 'TestApp',
|
||||||
|
sessionId: 'session-123',
|
||||||
|
userId: 'user-456',
|
||||||
|
userIdType: 'uuid',
|
||||||
|
endpointType: 'celo',
|
||||||
|
status: ProofStatus.PENDING,
|
||||||
|
disclosures: '{"test": "data"}',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
mockDatabase.insertProof.mockResolvedValue({
|
||||||
|
id: '1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
rowsAffected: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProofHistoryStore.getState().addProofHistory(mockProof);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useProofHistoryStore.getState().proofHistory).toHaveLength(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useProofHistoryStore.getState().resetHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useProofHistoryStore.getState().proofHistory).toHaveLength(0);
|
||||||
|
expect(useProofHistoryStore.getState().currentPage).toBe(1);
|
||||||
|
expect(useProofHistoryStore.getState().hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,5 +4,6 @@
|
|||||||
"lib": ["dom", "esnext"],
|
"lib": ["dom", "esnext"],
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"esModuleInterop": true
|
"esModuleInterop": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["node_modules", "vite.config.ts", ".tamagui/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
63
app/vite.config.ts
Normal file
63
app/vite.config.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import { tamaguiPlugin } from '@tamagui/vite-plugin';
|
||||||
|
import react from '@vitejs/plugin-react-swc';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import svgr from 'vite-plugin-svgr';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'web',
|
||||||
|
resolve: {
|
||||||
|
extensions: [
|
||||||
|
'.web.tsx',
|
||||||
|
'.web.js',
|
||||||
|
'.web.jsx',
|
||||||
|
'.web.ts',
|
||||||
|
'.tsx',
|
||||||
|
'.ts',
|
||||||
|
'.jsx',
|
||||||
|
'.js',
|
||||||
|
],
|
||||||
|
alias: {
|
||||||
|
'@env': path.resolve(__dirname, 'env.ts'),
|
||||||
|
'/src': path.resolve(__dirname, 'src'),
|
||||||
|
'react-native-svg': 'react-native-svg-web',
|
||||||
|
'lottie-react-native': 'lottie-react',
|
||||||
|
'react-native-safe-area-context': path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'src/mocks/react-native-safe-area-context.js',
|
||||||
|
),
|
||||||
|
'react-native-gesture-handler': path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'src/mocks/react-native-gesture-handler.ts',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
svgr({
|
||||||
|
include: '**/*.svg',
|
||||||
|
}),
|
||||||
|
tamaguiPlugin({
|
||||||
|
config: path.resolve(__dirname, 'tamagui.config.ts'),
|
||||||
|
components: ['tamagui'],
|
||||||
|
excludeReactNativeWebExports: [
|
||||||
|
'Switch',
|
||||||
|
'ProgressBar',
|
||||||
|
'Picker',
|
||||||
|
'CheckBox',
|
||||||
|
'Touchable',
|
||||||
|
],
|
||||||
|
platform: 'web',
|
||||||
|
optimize: true,
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
define: {
|
||||||
|
global: 'globalThis',
|
||||||
|
},
|
||||||
|
});
|
||||||
13
app/web/index.html
Normal file
13
app/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Self App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
app/web/main.tsx
Normal file
26
app/web/main.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
|
||||||
|
|
||||||
|
import 'react-native-get-random-values';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { TamaguiProvider, View } from 'tamagui';
|
||||||
|
|
||||||
|
import App from '../App';
|
||||||
|
import { black } from '../src/utils/colors';
|
||||||
|
import tamaguiConfig from '../tamagui.config.ts';
|
||||||
|
|
||||||
|
const Root = () => (
|
||||||
|
<TamaguiProvider config={tamaguiConfig}>
|
||||||
|
<View backgroundColor={black} flex={1} height="100vh" width="100%">
|
||||||
|
<App />
|
||||||
|
</View>
|
||||||
|
</TamaguiProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create root element and render the app
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<Root />);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user