Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
EXPO_PUBLIC_CHATWOOT_WEBSITE_TOKEN=
EXPO_PUBLIC_CHATWOOT_BASE_URL=https://app.chatwoot.com
EXPO_PUBLIC_JUNE_SDK_KEY=
EXPO_PUBLIC_MINIMUM_CHATWOOT_VERSION=3.13.0
EXPO_PUBLIC_MINIMUM_CHATWOOT_VERSION=4.1.0
EXPO_PUBLIC_SENTRY_DSN=
EXPO_PUBLIC_PROJECT_ID=
EXPO_PUBLIC_APP_SLUG=
Expand Down
2 changes: 1 addition & 1 deletion app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
return {
name: 'Chatwoot',
slug: process.env.EXPO_PUBLIC_APP_SLUG || 'chatwoot-mobile',
version: '4.2.0',
version: '4.2.3',
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'light',
Expand Down
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ module.exports = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
transformIgnorePatterns: [
'node_modules/(?!(jest-)?@?react-native|@react-native-community|@react-navigation|@reduxjs|immer)',
],
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/mobile-app",
"version": "4.2.0",
"version": "4.2.3",
"scripts": {
"start": "expo start --dev-client",
"start:production": "expo start --no-dev --minify",
Expand Down
1 change: 1 addition & 0 deletions src/components-next/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './sheet-components';
export * from './spinner';
export * from './action-tabs';
export * from './no-network';
export * from './verification-code';
76 changes: 76 additions & 0 deletions src/components-next/verification-code/animated-code-number.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { StyleSheet } from 'react-native';
import type { SharedValue } from 'react-native-reanimated';
import Animated, {
Easing,
FadeIn,
FadeOut,
FlipInXDown,
FlipOutXDown,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';

export type StatusType = 'inProgress' | 'correct' | 'wrong';

export type AnimatedCodeNumberProps = {
code?: string;
highlighted: boolean;
status: SharedValue<StatusType>;
};

export const AnimatedCodeNumber: React.FC<AnimatedCodeNumberProps> = ({
code,
highlighted,
status,
}) => {
const correctColor = 'hsl(151, 40.2%, 54.1%)'; // green-600
const defaultColor = 'hsl(0, 0%, 89.5%)'; // gray-300

const rBoxStyle = useAnimatedStyle(() => {
return {
// Only show green border for correct status, default border for all other states
borderColor: withTiming(status.value === 'correct' ? correctColor : defaultColor),
};
}, [correctColor, defaultColor]);

return (
<Animated.View style={[styles.container, rBoxStyle]}>
{code != null && (
<Animated.View entering={FadeIn.duration(250)} exiting={FadeOut.duration(250)}>
<Animated.Text
entering={FlipInXDown.duration(500)
// Go to this website and you'll see the curve I used:
// https://cubic-bezier.com/#0,0.75,0.5,0.9
// Basically, I want the animation to start slow, then accelerate at the end
// Do we really need to use a curve? Every detail matters :)
.easing(Easing.bezier(0, 0.75, 0.5, 0.9).factory())
.build()}
exiting={FlipOutXDown.duration(500)
// https://cubic-bezier.com/#0.6,0.1,0.4,0.8
// I want the animation to start fast, then decelerate at the end (opposite of the previous one)
.easing(Easing.bezier(0.6, 0.1, 0.4, 0.8).factory())
.build()}
style={[styles.text, { color: 'hsl(0, 0%, 39.3%)' }]}>
{code}
</Animated.Text>
</Animated.View>
)}
</Animated.View>
);
};

const styles = StyleSheet.create({
container: {
height: '90%',
width: '80%',
borderWidth: 2,
borderRadius: 12,
borderCurve: 'continuous',
justifyContent: 'center',
alignItems: 'center',
},
text: {
fontSize: 40,
fontFamily: 'Inter-500-24',
},
});
47 changes: 47 additions & 0 deletions src/components-next/verification-code/hooks/use-animated-shake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';

// Custom hook for creating an animated shaking effect
const useAnimatedShake = () => {
// Shared value to track the horizontal translation for shaking
const shakeTranslateX = useSharedValue(0);

// Callback function to trigger the shake animation
const shake = useCallback(() => {
// Cancel any ongoing animations on shakeTranslateX
cancelAnimation(shakeTranslateX);

// Reset the translation value to 0 before starting the new animation
shakeTranslateX.value = 0;

// Apply a repeating animation to create a shaking effect
shakeTranslateX.value = withRepeat(
withTiming(10, {
duration: 120,
easing: Easing.bezier(0.35, 0.7, 0.5, 0.7),
}),
6, // Repeat the animation 6 times
true, // Infinite loop (true indicates indefinite repetition)
);
}, [shakeTranslateX]); // Dependency array to ensure proper re-rendering

// Define the animated style based on the current translation value
const rShakeStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: shakeTranslateX.value }],
};
}, []);

// Return the shake callback function and the animated style for external use
return { shake, rShakeStyle };
};

// Export the custom hook for use in other components
export { useAnimatedShake };
50 changes: 50 additions & 0 deletions src/components-next/verification-code/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { StyleSheet, View } from 'react-native';
import { useSharedValue } from 'react-native-reanimated';

import type { AnimatedCodeNumberProps } from './animated-code-number';
import { AnimatedCodeNumber } from './animated-code-number';

export type StatusType = 'inProgress' | 'correct' | 'wrong';

type VerificationCodeProps = {
code: string[];
maxLength?: number;
isCodeWrong: boolean;
} & Pick<AnimatedCodeNumberProps, 'status'>;

export const VerificationCode: React.FC<VerificationCodeProps> = ({
code,
maxLength = 5,
status,
isCodeWrong,
}) => {
const wrongStatus = useSharedValue<StatusType>('wrong');

return (
<View style={styles.container}>
{new Array(maxLength).fill(0).map((_, index) => (
<View key={index} style={styles.codeContainer}>
<AnimatedCodeNumber
code={code[index]}
highlighted={index === code.length}
status={isCodeWrong ? wrongStatus : status}
/>
</View>
))}
</View>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
flexDirection: 'row',
},
codeContainer: {
aspectRatio: 0.95,
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
18 changes: 18 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@
"SUB_TITLE": "Don't worry! We got your back",
"API_SUCCESS": "Password reset link has been sent to your email address"
},
"MFA": {
"TITLE": "Two-Factor Authentication",
"TABS": {
"AUTHENTICATOR_APP": "Authenticator App",
"BACKUP_CODE": "Backup Code"
},
"INSTRUCTIONS": {
"AUTHENTICATOR": "Enter 6-digit code from your authenticator app",
"BACKUP": "Enter your one of your backup code"
},
"PLACEHOLDERS": {
"BACKUP_CODE": "Enter backup code"
},
"BUTTONS": {
"VERIFY": "Verify",
"VERIFYING": "Verifying..."
}
},
"CONVERSATION": {
"HEADER": {
"TITLE": "Conversations",
Expand Down
13 changes: 13 additions & 0 deletions src/navigation/stack/AuthStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import ConfigInstallationURL from '@/screens/auth/ConfigURLScreen';
import Login from '@/screens/auth/LoginScreen';
import ForgotPassword from '@/screens/auth/ForgotPassword';
import MFAScreen from '@/screens/auth/MFAScreen';

export type AuthStackParamList = {
Login: undefined;
ResetPassword: undefined;
ConfigureURL: undefined;
MFAScreen: undefined;
};

const Stack = createNativeStackNavigator<AuthStackParamList>();
Expand Down Expand Up @@ -45,6 +47,17 @@ export const AuthStack = () => {
name="ConfigureURL"
component={ConfigInstallationURL}
/>
<Stack.Screen
options={{
headerShown: true,
headerBackTitle: 'Back',
headerBackVisible: true,
headerShadowVisible: false,
title: '',
}}
name="MFAScreen"
component={MFAScreen}
/>
</Stack.Navigator>
);
};
17 changes: 16 additions & 1 deletion src/screens/auth/LoginScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,22 @@ const LoginScreen = () => {

const onSubmit = async (data: FormData) => {
const { email, password } = data;
dispatch(authActions.login({ email, password }));
// Clear any existing auth state before login
dispatch(resetAuth());

try {
const result = await dispatch(authActions.login({ email, password })).unwrap();

// Check if MFA is required in the response
if ('mfa_required' in result && result.mfa_required) {
// Navigate directly to MFA screen with the token
navigation.navigate('MFAScreen' as never);
}
// If MFA not required, the auth state will be updated and
// the app will automatically navigate to the dashboard
} catch {
// Login error is handled by Redux and displayed in the UI
}
};

const openResetPassword = () => {
Expand Down
Loading