SELF-1951: prep for sumsub release (#1680)

* enable sumsub in mobile sdk

* refactor dev settings screen

* combine sections

* agent feedback

* gate kyc button on troubel screens

* inline simple sections
This commit is contained in:
Justin Hernandez
2026-02-03 13:29:16 -08:00
committed by GitHub
parent 2ebf7918c7
commit b3d40d791a
20 changed files with 1441 additions and 997 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useRef, useState } from 'react';
import { ScrollView, TouchableOpacity } from 'react-native';
import { Button, Sheet, Text, XStack, YStack } from 'tamagui';
import { Check, ChevronDown } from '@tamagui/lucide-icons';
import {
red500,
slate200,
slate500,
slate600,
slate800,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import type { InjectedErrorType } from '@/stores/errorInjectionStore';
import {
ERROR_GROUPS,
ERROR_LABELS,
useErrorInjectionStore,
} from '@/stores/errorInjectionStore';
import {
registerModalCallbacks,
unregisterModalCallbacks,
} from '@/utils/modalCallbackRegistry';
export const ErrorInjectionSelector = () => {
const injectedErrors = useErrorInjectionStore(state => state.injectedErrors);
const setInjectedErrors = useErrorInjectionStore(
state => state.setInjectedErrors,
);
const clearAllErrors = useErrorInjectionStore(state => state.clearAllErrors);
const [open, setOpen] = useState(false);
const callbackIdRef = useRef<number>();
const handleModalDismiss = useCallback(() => {
setOpen(false);
if (callbackIdRef.current !== undefined) {
unregisterModalCallbacks(callbackIdRef.current);
callbackIdRef.current = undefined;
}
}, []);
const openSheet = useCallback(() => {
setOpen(true);
const id = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: handleModalDismiss,
});
callbackIdRef.current = id;
}, [handleModalDismiss]);
const closeSheet = useCallback(() => {
handleModalDismiss();
}, [handleModalDismiss]);
const handleSheetOpenChange = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
handleModalDismiss();
} else {
setOpen(isOpen);
}
},
[handleModalDismiss],
);
// Single error selection - replace instead of toggle
const selectError = (errorType: InjectedErrorType) => {
// If clicking the same error, clear it; otherwise set the new one
if (injectedErrors.length === 1 && injectedErrors[0] === errorType) {
clearAllErrors();
} else {
setInjectedErrors([errorType]);
}
// Close the sheet after selection
closeSheet();
};
const currentError = injectedErrors.length > 0 ? injectedErrors[0] : null;
const currentErrorLabel = currentError ? ERROR_LABELS[currentError] : null;
return (
<YStack gap="$2">
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={openSheet}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
{currentErrorLabel || 'Select onboarding error to test'}
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
{currentError && (
<Button
backgroundColor={red500}
borderRadius="$2"
height="$5"
onPress={clearAllErrors}
pressStyle={{
opacity: 0.8,
scale: 0.98,
}}
>
<Text color={white} fontSize="$5" fontFamily={dinot}>
Clear
</Text>
</Button>
)}
<Sheet
modal
open={open}
onOpenChange={handleSheetOpenChange}
snapPoints={[85]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Onboarding Error Testing
</Text>
<Button
onPress={closeSheet}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
>
{Object.entries(ERROR_GROUPS).map(([groupName, errors]) => (
<YStack key={groupName} marginBottom="$4">
<Text
fontSize="$6"
fontFamily={dinot}
fontWeight="600"
color={slate800}
marginBottom="$2"
>
{groupName}
</Text>
{errors.map((errorType: InjectedErrorType) => (
<TouchableOpacity
key={errorType}
onPress={() => selectError(errorType)}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{ERROR_LABELS[errorType]}
</Text>
{currentError === errorType && (
<Check color={slate600} size={20} />
)}
</XStack>
</TouchableOpacity>
))}
</YStack>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</YStack>
);
};

View File

@@ -0,0 +1,175 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ScrollView, TouchableOpacity } from 'react-native';
import { Button, Sheet, Text, XStack, YStack } from 'tamagui';
import { Check, ChevronDown } from '@tamagui/lucide-icons';
import {
slate200,
slate500,
slate600,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import {
registerModalCallbacks,
unregisterModalCallbacks,
} from '@/utils/modalCallbackRegistry';
interface LogLevelSelectorProps {
currentLevel: string;
onSelect: (level: 'debug' | 'info' | 'warn' | 'error') => void;
}
export const LogLevelSelector: React.FC<LogLevelSelectorProps> = ({
currentLevel,
onSelect,
}) => {
const [open, setOpen] = useState(false);
const callbackIdRef = useRef<number>();
const logLevels = ['debug', 'info', 'warn', 'error'] as const;
// Cleanup effect to unregister callbacks on unmount
useEffect(() => {
return () => {
if (callbackIdRef.current !== undefined) {
unregisterModalCallbacks(callbackIdRef.current);
callbackIdRef.current = undefined;
}
};
}, []);
const handleModalDismiss = useCallback(() => {
setOpen(false);
if (callbackIdRef.current !== undefined) {
unregisterModalCallbacks(callbackIdRef.current);
callbackIdRef.current = undefined;
}
}, []);
const openSheet = useCallback(() => {
setOpen(true);
const id = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: handleModalDismiss,
});
callbackIdRef.current = id;
}, [handleModalDismiss]);
const closeSheet = useCallback(() => {
handleModalDismiss();
}, [handleModalDismiss]);
const handleSheetOpenChange = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
handleModalDismiss();
} else {
setOpen(isOpen);
}
},
[handleModalDismiss],
);
const handleLevelSelect = useCallback(
(level: 'debug' | 'info' | 'warn' | 'error') => {
closeSheet();
onSelect(level);
},
[closeSheet, onSelect],
);
return (
<>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={openSheet}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
{currentLevel.toUpperCase()}
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Sheet
modal
open={open}
onOpenChange={handleSheetOpenChange}
snapPoints={[50]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Select log level
</Text>
<Button
onPress={closeSheet}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView showsVerticalScrollIndicator={false}>
{logLevels.map(level => (
<TouchableOpacity
key={level}
onPress={() => handleLevelSelect(level)}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{level.toUpperCase()}
</Text>
{currentLevel === level && (
<Check color={slate600} size={20} />
)}
</XStack>
</TouchableOpacity>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
};

View File

@@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { PropsWithChildren } from 'react';
import React, { cloneElement, isValidElement } from 'react';
import { Text, XStack, YStack } from 'tamagui';
import {
slate100,
slate200,
slate400,
slate600,
slate800,
slate900,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
interface ParameterSectionProps extends PropsWithChildren {
icon: React.ReactNode;
title: string;
description: string;
darkMode?: boolean;
}
export function ParameterSection({
icon,
title,
description,
darkMode,
children,
}: ParameterSectionProps) {
const renderIcon = () => {
const iconElement =
typeof icon === 'function'
? (icon as () => React.ReactNode)()
: isValidElement(icon)
? icon
: null;
return iconElement
? cloneElement(iconElement as React.ReactElement, {
width: '100%',
height: '100%',
})
: null;
};
return (
<YStack
width="100%"
backgroundColor={darkMode ? slate900 : slate100}
borderRadius="$4"
borderWidth={1}
borderColor={darkMode ? slate800 : slate200}
padding="$4"
flexDirection="column"
gap="$3"
>
<XStack
width="100%"
flexDirection="row"
justifyContent="flex-start"
gap="$4"
>
<YStack
backgroundColor="gray"
borderRadius={5}
width={46}
height={46}
justifyContent="center"
alignItems="center"
padding="$2"
>
{renderIcon()}
</YStack>
<YStack flexDirection="column" gap="$1">
<Text
fontSize="$5"
color={darkMode ? white : slate600}
fontFamily={dinot}
>
{title}
</Text>
<Text fontSize="$3" color={slate400} fontFamily={dinot}>
{description}
</Text>
</YStack>
</XStack>
{children}
</YStack>
);
}

View File

@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useMemo, useState } from 'react';
import { ScrollView, TouchableOpacity } from 'react-native';
import { Button, Sheet, Text, XStack, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import { ChevronDown } from '@tamagui/lucide-icons';
import {
slate200,
slate500,
slate600,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { navigationScreens } from '@/navigation';
export const ScreenSelector = () => {
const navigation = useNavigation();
const [open, setOpen] = useState(false);
const screenList = useMemo(
() =>
(
Object.keys(navigationScreens) as (keyof typeof navigationScreens)[]
).sort(),
[],
);
return (
<>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => setOpen(true)}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Select screen
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Sheet
modal
open={open}
onOpenChange={setOpen}
snapPoints={[85]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Select screen
</Text>
<Button
onPress={() => setOpen(false)}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
>
{screenList.map(item => (
<TouchableOpacity
key={item}
onPress={() => {
setOpen(false);
navigation.navigate(item as never);
}}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{item}
</Text>
</XStack>
</TouchableOpacity>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
};

View File

@@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Button, Text } from 'tamagui';
import {
slate200,
slate400,
slate600,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
export interface TopicToggleButtonProps {
label: string;
isSubscribed: boolean;
onToggle: () => void;
}
export const TopicToggleButton: React.FC<TopicToggleButtonProps> = ({
label,
isSubscribed,
onToggle,
}) => {
return (
<Button
backgroundColor={isSubscribed ? '$green9' : slate200}
borderRadius="$2"
height="$5"
onPress={onToggle}
flexDirection="row"
justifyContent="space-between"
paddingHorizontal="$4"
pressStyle={{
opacity: 0.8,
scale: 0.98,
}}
>
<Text
color={isSubscribed ? white : slate600}
fontSize="$5"
fontFamily={dinot}
fontWeight="600"
>
{label}
</Text>
<Text
color={isSubscribed ? white : slate400}
fontSize="$3"
fontFamily={dinot}
>
{isSubscribed ? 'Enabled' : 'Disabled'}
</Text>
</Button>
);
};

View File

@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export type { TopicToggleButtonProps } from '@/screens/dev/components/TopicToggleButton';
export { ErrorInjectionSelector } from '@/screens/dev/components/ErrorInjectionSelector';
export { LogLevelSelector } from '@/screens/dev/components/LogLevelSelector';
export { ParameterSection } from '@/screens/dev/components/ParameterSection';
export { ScreenSelector } from '@/screens/dev/components/ScreenSelector';
export { TopicToggleButton } from '@/screens/dev/components/TopicToggleButton';

View File

@@ -0,0 +1,197 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Alert } from 'react-native';
import { unsafe_clearSecrets } from '@/providers/authProvider';
import { usePassport } from '@/providers/passportDataProvider';
import { usePointEventStore } from '@/stores/pointEventStore';
import { useSettingStore } from '@/stores/settingStore';
export const useDangerZoneActions = () => {
const { clearDocumentCatalogForMigrationTesting } = usePassport();
const clearPointEvents = usePointEventStore(state => state.clearEvents);
const { resetBackupForPoints } = useSettingStore();
const handleClearSecretsPress = () => {
Alert.alert(
'Delete Keychain Secrets',
"Are you sure you want to remove your keychain secrets?\n\nIf this secret is not backed up, your account will be lost and the ID documents attached to it won't be usable.",
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await unsafe_clearSecrets();
Alert.alert('Success', 'Keychain secrets cleared successfully.', [
{ text: 'OK' },
]);
} catch (error) {
console.error(
'Failed to clear keychain secrets:',
error instanceof Error ? error.message : String(error),
);
Alert.alert(
'Error',
'Failed to clear keychain secrets. Please try again.',
[{ text: 'OK' }],
);
}
},
},
],
);
};
const handleClearDocumentCatalogPress = () => {
Alert.alert(
'Clear Document Catalog',
'Are you sure you want to clear the document catalog?\n\nThis will remove all documents from the new storage system but preserve legacy storage for migration testing. You will need to restart the app to test migration.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
await clearDocumentCatalogForMigrationTesting();
Alert.alert(
'Success',
'Document catalog cleared successfully. Please restart the app to test migration.',
[{ text: 'OK' }],
);
} catch (error) {
console.error(
'Failed to clear document catalog:',
error instanceof Error ? error.message : String(error),
);
Alert.alert(
'Error',
'Failed to clear document catalog. Please try again.',
[{ text: 'OK' }],
);
}
},
},
],
);
};
const handleClearPointEventsPress = () => {
Alert.alert(
'Clear Point Events',
'Are you sure you want to clear all point events from local storage?\n\nThis will reset your point history but not affect your actual points on the blockchain.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
await clearPointEvents();
Alert.alert('Success', 'Point events cleared successfully.', [
{ text: 'OK' },
]);
} catch (error) {
console.error(
'Failed to clear point events:',
error instanceof Error ? error.message : String(error),
);
Alert.alert(
'Error',
'Failed to clear point events. Please try again.',
[{ text: 'OK' }],
);
}
},
},
],
);
};
const handleResetBackupStatePress = () => {
Alert.alert(
'Reset Backup State',
'Are you sure you want to reset the backup state?\n\nThis will allow you to see and trigger the backup points flow again.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Reset',
style: 'destructive',
onPress: () => {
resetBackupForPoints();
Alert.alert('Success', 'Backup state reset successfully.', [
{ text: 'OK' },
]);
},
},
],
);
};
const handleClearBackupEventsPress = () => {
Alert.alert(
'Clear Backup Events',
'Are you sure you want to clear all backup point events from local storage?\n\nThis will remove backup events from your point history.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
const events = usePointEventStore.getState().events;
const backupEvents = events.filter(
event => event.type === 'backup',
);
await Promise.all(
backupEvents.map(event =>
usePointEventStore.getState().removeEvent(event.id),
),
);
Alert.alert('Success', 'Backup events cleared successfully.', [
{ text: 'OK' },
]);
} catch (error) {
console.error(
'Failed to clear backup events:',
error instanceof Error ? error.message : String(error),
);
Alert.alert(
'Error',
'Failed to clear backup events. Please try again.',
[{ text: 'OK' }],
);
}
},
},
],
);
};
return {
handleClearSecretsPress,
handleClearDocumentCatalogPress,
handleClearPointEventsPress,
handleResetBackupStatePress,
handleClearBackupEventsPress,
};
};

View File

@@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useEffect, useState } from 'react';
import { Alert, AppState } from 'react-native';
import {
isNotificationSystemReady,
requestNotificationPermission,
subscribeToTopics,
unsubscribeFromTopics,
} from '@/services/notifications/notificationService';
import { useSettingStore } from '@/stores/settingStore';
export const useNotificationHandlers = () => {
const subscribedTopics = useSettingStore(state => state.subscribedTopics);
const [hasNotificationPermission, setHasNotificationPermission] =
useState(false);
const checkPermissions = useCallback(async () => {
const readiness = await isNotificationSystemReady();
setHasNotificationPermission(readiness.ready);
}, []);
// Check notification permissions on mount and when app regains focus
useEffect(() => {
checkPermissions();
const subscription = AppState.addEventListener('change', nextAppState => {
if (nextAppState === 'active') {
checkPermissions();
}
});
return () => subscription.remove();
}, [checkPermissions]);
const handleTopicToggle = async (topics: string[], topicLabel: string) => {
// Check permissions first
if (!hasNotificationPermission) {
Alert.alert(
'Permissions Required',
'Push notifications are not enabled. Would you like to enable them?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Enable',
onPress: async () => {
try {
const granted = await requestNotificationPermission();
if (granted) {
// Update permission state
setHasNotificationPermission(true);
Alert.alert(
'Success',
'Permissions granted! You can now subscribe to topics.',
[{ text: 'OK' }],
);
} else {
Alert.alert(
'Failed',
'Could not enable notifications. Please enable them in your device Settings.',
[{ text: 'OK' }],
);
}
} catch (error) {
Alert.alert(
'Error',
error instanceof Error
? error.message
: 'Failed to request permissions',
[{ text: 'OK' }],
);
}
},
},
],
);
return;
}
const isCurrentlySubscribed = topics.every(topic =>
subscribedTopics.includes(topic),
);
if (isCurrentlySubscribed) {
// Show confirmation dialog for unsubscribe
Alert.alert(
'Disable Notifications',
`Are you sure you want to disable push notifications for ${topicLabel}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disable',
style: 'destructive',
onPress: async () => {
try {
const result = await unsubscribeFromTopics(topics);
if (result.successes.length > 0) {
Alert.alert(
'Success',
`Disabled notifications for ${topicLabel}`,
[{ text: 'OK' }],
);
} else {
Alert.alert(
'Error',
`Failed to disable: ${result.failures.map(f => f.error).join(', ')}`,
[{ text: 'OK' }],
);
}
} catch (error) {
Alert.alert(
'Error',
error instanceof Error
? error.message
: 'Failed to unsubscribe',
[{ text: 'OK' }],
);
}
},
},
],
);
} else {
// Subscribe without confirmation
try {
const result = await subscribeToTopics(topics);
if (result.successes.length > 0) {
Alert.alert('✅ Success', `Enabled notifications for ${topicLabel}`, [
{ text: 'OK' },
]);
} else {
Alert.alert(
'Error',
`Failed to enable: ${result.failures.map(f => f.error).join(', ')}`,
[{ text: 'OK' }],
);
}
} catch (error) {
Alert.alert(
'Error',
error instanceof Error ? error.message : 'Failed to subscribe',
[{ text: 'OK' }],
);
}
}
};
return {
hasNotificationPermission,
subscribedTopics,
handleTopicToggle,
};
};

View File

@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Button, Text } from 'tamagui';
import {
red500,
slate500,
white,
yellow500,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import WarningIcon from '@/assets/icons/warning.svg';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
interface DangerZoneSectionProps {
onClearSecrets: () => void;
onClearDocumentCatalog: () => void;
onClearPointEvents: () => void;
onResetBackupState: () => void;
onClearBackupEvents: () => void;
}
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
onClearSecrets,
onClearDocumentCatalog,
onClearPointEvents,
onResetBackupState,
onClearBackupEvents,
}) => {
const dangerActions = [
{
label: 'Delete your private key',
onPress: onClearSecrets,
dangerTheme: true,
},
{
label: 'Clear document catalog',
onPress: onClearDocumentCatalog,
dangerTheme: true,
},
{
label: 'Clear point events',
onPress: onClearPointEvents,
dangerTheme: true,
},
{
label: 'Reset backup state',
onPress: onResetBackupState,
dangerTheme: true,
},
{
label: 'Clear backup events',
onPress: onClearBackupEvents,
dangerTheme: true,
},
];
return (
<ParameterSection
icon={<WarningIcon color={yellow500} />}
title="Danger Zone"
description="These actions are sensitive"
darkMode={true}
>
{dangerActions.map(({ label, onPress, dangerTheme }) => (
<Button
key={label}
style={{ backgroundColor: dangerTheme ? red500 : white }}
borderRadius="$2"
height="$5"
onPress={onPress}
flexDirection="row"
justifyContent="flex-start"
>
<Text
color={dangerTheme ? white : slate500}
fontSize="$5"
fontFamily={dinot}
>
{label}
</Text>
</Button>
))}
</ParameterSection>
);
};

View File

@@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Button, Text, XStack, YStack } from 'tamagui';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ChevronRight } from '@tamagui/lucide-icons';
import { slate200, slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import BugIcon from '@/assets/icons/bug_icon.svg';
import type { RootStackParamList } from '@/navigation';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
import { ScreenSelector } from '@/screens/dev/components/ScreenSelector';
import { IS_DEV_MODE } from '@/utils/devUtils';
interface DebugShortcutsSectionProps {
navigation: NativeStackNavigationProp<RootStackParamList>;
}
export const DebugShortcutsSection: React.FC<DebugShortcutsSectionProps> = ({
navigation,
}) => {
return (
<ParameterSection
icon={<BugIcon />}
title="Debug Shortcuts"
description="Jump directly to any screen for testing"
>
<YStack gap="$2">
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('DevPrivateKey');
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
View Private Key
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('SumsubTest');
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Sumsub Test Flow
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
{IS_DEV_MODE && (
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('Home', { testReferralFlow: true });
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Test Referral Flow
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
)}
<ScreenSelector />
</YStack>
</ParameterSection>
);
};

View File

@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Alert, Platform } from 'react-native';
import BugIcon from '@/assets/icons/bug_icon.svg';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
import { TopicToggleButton } from '@/screens/dev/components/TopicToggleButton';
interface DevTogglesSectionProps {
kycEnabled: boolean;
setKycEnabled: (enabled: boolean) => void;
useStrongBox: boolean;
setUseStrongBox: (useStrongBox: boolean) => void;
}
export const DevTogglesSection: React.FC<DevTogglesSectionProps> = ({
kycEnabled,
setKycEnabled,
useStrongBox,
setUseStrongBox,
}) => {
const handleToggleStrongBox = () => {
Alert.alert(
useStrongBox ? 'Disable StrongBox' : 'Enable StrongBox',
useStrongBox
? 'New keys will be generated without StrongBox hardware backing. Existing keys will continue to work.'
: 'New keys will attempt to use StrongBox hardware backing for enhanced security.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: useStrongBox ? 'Disable' : 'Enable',
onPress: () => setUseStrongBox(!useStrongBox),
},
],
);
};
return (
<ParameterSection
icon={<BugIcon />}
title="Options"
description="Development and security options"
>
<TopicToggleButton
label="KYC Flow"
isSubscribed={kycEnabled}
onToggle={() => setKycEnabled(!kycEnabled)}
/>
{Platform.OS === 'android' && (
<TopicToggleButton
label="Use StrongBox"
isSubscribed={useStrongBox}
onToggle={handleToggleStrongBox}
/>
)}
</ParameterSection>
);
};

View File

@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { YStack } from 'tamagui';
import BugIcon from '@/assets/icons/bug_icon.svg';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
import { TopicToggleButton } from '@/screens/dev/components/TopicToggleButton';
interface PushNotificationsSectionProps {
hasNotificationPermission: boolean;
subscribedTopics: string[];
onTopicToggle: (topics: string[], topicLabel: string) => void;
}
export const PushNotificationsSection: React.FC<
PushNotificationsSectionProps
> = ({ hasNotificationPermission, subscribedTopics, onTopicToggle }) => {
return (
<ParameterSection
icon={<BugIcon />}
title="Push Notifications"
description="Manage topic subscriptions"
>
<YStack gap="$2">
<TopicToggleButton
label="Starfall"
isSubscribed={
hasNotificationPermission && subscribedTopics.includes('nova')
}
onToggle={() => onTopicToggle(['nova'], 'Starfall')}
/>
<TopicToggleButton
label="General"
isSubscribed={
hasNotificationPermission && subscribedTopics.includes('general')
}
onToggle={() => onTopicToggle(['general'], 'General')}
/>
<TopicToggleButton
label="Both (Starfall + General)"
isSubscribed={
hasNotificationPermission &&
subscribedTopics.includes('nova') &&
subscribedTopics.includes('general')
}
onToggle={() => onTopicToggle(['nova', 'general'], 'both topics')}
/>
</YStack>
</ParameterSection>
);
};

View File

@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export { DangerZoneSection } from '@/screens/dev/sections/DangerZoneSection';
export { DebugShortcutsSection } from '@/screens/dev/sections/DebugShortcutsSection';
export { DevTogglesSection } from '@/screens/dev/sections/DevTogglesSection';
export { PushNotificationsSection } from '@/screens/dev/sections/PushNotificationsSection';

View File

@@ -20,6 +20,7 @@ import useHapticNavigation from '@/hooks/useHapticNavigation';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import { flush as flushAnalytics } from '@/services/analytics';
import { useSettingStore } from '@/stores/settingStore';
const tips: TipProps[] = [
{
@@ -54,6 +55,7 @@ const DocumentCameraTroubleScreen: React.FC = () => {
const selfClient = useSelfClient();
const { useMRZStore } = selfClient;
const { countryCode } = useMRZStore();
const kycEnabled = useSettingStore(state => state.kycEnabled);
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
countryCode,
errorSource: 'sumsub_initialization',
@@ -80,21 +82,25 @@ const DocumentCameraTroubleScreen: React.FC = () => {
page quickly and clearly!
</Caption>
<Caption
size="large"
style={{ color: slate500, marginTop: 12, marginBottom: 8 }}
>
Or try an alternative verification method:
</Caption>
{kycEnabled && (
<>
<Caption
size="large"
style={{ color: slate500, marginTop: 12, marginBottom: 8 }}
>
Or try an alternative verification method:
</Caption>
<SecondaryButton
onPress={launchSumsubVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}
>
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
</SecondaryButton>
<SecondaryButton
onPress={launchSumsubVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}
>
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
</SecondaryButton>
</>
)}
</YStack>
}
>

View File

@@ -19,6 +19,7 @@ import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import { flushAllAnalytics } from '@/services/analytics';
import { openSupportForm, SUPPORT_FORM_BUTTON_TEXT } from '@/services/support';
import { useSettingStore } from '@/stores/settingStore';
const tips: TipProps[] = [
{
@@ -55,6 +56,7 @@ const DocumentNFCTroubleScreen: React.FC = () => {
const selfClient = useSelfClient();
const { useMRZStore } = selfClient;
const { countryCode } = useMRZStore();
const kycEnabled = useSettingStore(state => state.kycEnabled);
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
countryCode,
errorSource: 'sumsub_initialization',
@@ -89,14 +91,16 @@ const DocumentNFCTroubleScreen: React.FC = () => {
{SUPPORT_FORM_BUTTON_TEXT}
</SecondaryButton>
<SecondaryButton
onPress={launchSumsubVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}
>
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
</SecondaryButton>
{kycEnabled && (
<SecondaryButton
onPress={launchSumsubVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}
>
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
</SecondaryButton>
)}
</YStack>
}
>

View File

@@ -13,6 +13,7 @@ import IDSelection from '@selfxyz/mobile-sdk-alpha/onboarding/id-selection-scree
import { DocumentFlowNavBar } from '@/components/navbar/DocumentFlowNavBar';
import type { RootStackParamList } from '@/navigation';
import { useSettingStore } from '@/stores/settingStore';
import { extraYPadding } from '@/utils/styleUtils';
type IDPickerScreenRouteProp = RouteProp<RootStackParamList, 'IDPicker'>;
@@ -21,6 +22,7 @@ const IDPickerScreen: React.FC = () => {
const route = useRoute<IDPickerScreenRouteProp>();
const { countryCode = '', documentTypes = [] } = route.params || {};
const bottom = useSafeAreaInsets().bottom;
const kycEnabled = useSettingStore(state => state.kycEnabled);
return (
<YStack
@@ -29,7 +31,11 @@ const IDPickerScreen: React.FC = () => {
paddingBottom={bottom + extraYPadding + 24}
>
<DocumentFlowNavBar title="GETTING STARTED" />
<IDSelection countryCode={countryCode} documentTypes={documentTypes} />
<IDSelection
countryCode={countryCode}
documentTypes={documentTypes}
showKyc={kycEnabled}
/>
</YStack>
);
};

View File

@@ -21,6 +21,7 @@ interface PersistedSettingsState {
homeScreenViewCount: number;
incrementHomeScreenViewCount: () => void;
isDevMode: boolean;
kycEnabled: boolean;
loggingSeverity: LoggingSeverity;
pointsAddress: string | null;
removeSubscribedTopic: (topic: string) => void;
@@ -32,6 +33,7 @@ interface PersistedSettingsState {
setFcmToken: (token: string | null) => void;
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
setKeychainMigrationCompleted: () => void;
setKycEnabled: (enabled: boolean) => void;
setLoggingSeverity: (severity: LoggingSeverity) => void;
setPointsAddress: (address: string | null) => void;
setSkipDocumentSelector: (value: boolean) => void;
@@ -148,6 +150,10 @@ export const useSettingStore = create<SettingsState>()(
useStrongBox: false,
setUseStrongBox: (useStrongBox: boolean) => set({ useStrongBox }),
// KYC flow toggle (default: false, dev-only feature)
kycEnabled: false,
setKycEnabled: (enabled: boolean) => set({ kycEnabled: enabled }),
// Non-persisted state (will not be saved to storage)
hideNetworkModal: false,
setHideNetworkModal: (hideNetworkModal: boolean) => {