Start of Web App (#689)

This commit is contained in:
Aaron DeRuvo
2025-07-11 14:07:40 +02:00
committed by GitHub
parent 19f167297a
commit 252f1ba1ef
56 changed files with 4424 additions and 551 deletions

View File

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

View File

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

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

View File

@@ -6,4 +6,6 @@ src/assets/animations/
witnesscalc/ witnesscalc/
vendor/ vendor/
android/ android/
.tamagui/
web/dist/
*.md *.md

16
app/env.ts Normal file
View 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;

View File

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

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

View File

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

View File

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

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

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

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

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

View File

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

1327
yarn.lock

File diff suppressed because it is too large Load Diff