refactor(mobile): submit button & react-native-otp-entry (#2870)

This commit is contained in:
Stephen Zhou 2025-02-25 10:47:43 +08:00 committed by GitHub
parent f324fc05dc
commit e40f8a9bbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 138 additions and 107 deletions

View File

@ -83,6 +83,7 @@
"react-native-ios-context-menu": "3.1.0",
"react-native-ios-utilities": "5.1.1",
"react-native-keyboard-controller": "1.16.3",
"react-native-otp-entry": "1.8.2",
"react-native-pager-view": "6.7.0",
"react-native-reanimated": "3.16.7",
"react-native-root-siblings": "5.0.1",

View File

@ -0,0 +1,49 @@
import { cn } from "@follow/utils"
import { useEffect } from "react"
import type { PressableProps } from "react-native"
import { ActivityIndicator, Text } from "react-native"
import {
interpolate,
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated"
import { useColor } from "react-native-uikit-colors"
import { accentColor } from "@/src/theme/colors"
import { ReAnimatedPressable } from "./AnimatedComponents"
export function SubmitButton({
isLoading,
title,
...props
}: PressableProps & { isLoading?: boolean; title: string }) {
const disableColor = useColor("gray3")
const disabledValue = useSharedValue(1)
useEffect(() => {
disabledValue.value = withTiming(props.disabled ? 1 : 0)
}, [props.disabled])
const buttonStyle = useAnimatedStyle(() => ({
opacity: interpolate(disabledValue.value, [0, 1], [1, 0.5]),
backgroundColor: interpolateColor(disabledValue.value, [1, 0], [disableColor, accentColor]),
}))
return (
<ReAnimatedPressable
{...props}
disabled={props.disabled || isLoading}
style={[buttonStyle, props.style]}
className={cn("h-10 flex-row items-center justify-center rounded-3xl", props.className)}
>
{isLoading ? (
<ActivityIndicator className="text-white" />
) : (
<Text className="text-center font-semibold text-white">{title}</Text>
)}
</ReAnimatedPressable>
)
}

View File

@ -1,26 +1,19 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import { router } from "expo-router"
import { useContext, useEffect } from "react"
import { useContext } from "react"
import type { Control } from "react-hook-form"
import { useController, useForm } from "react-hook-form"
import type { TextInputProps } from "react-native"
import { ActivityIndicator, Text, TextInput, View } from "react-native"
import { Text, TextInput, View } from "react-native"
import { KeyboardController } from "react-native-keyboard-controller"
import {
interpolate,
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated"
import { z } from "zod"
import { ReAnimatedPressable } from "@/src/components/common/AnimatedComponents"
import { SubmitButton } from "@/src/components/common/SubmitButton"
import { LoginTermsCheckGuardContext } from "@/src/contexts/LoginTermsContext"
import { signIn } from "@/src/lib/auth"
import { toast } from "@/src/lib/toast"
import { accentColor, useColor } from "@/src/theme/colors"
import { accentColor } from "@/src/theme/colors"
const formSchema = z.object({
email: z.string().email(),
@ -89,17 +82,6 @@ export function EmailLogin() {
termsCheckGuard?.(() => submitMutation.mutate(values))
})
const disableColor = useColor("gray3")
const canLogin = useSharedValue(1)
useEffect(() => {
canLogin.value = withTiming(submitMutation.isPending || !formState.isValid ? 1 : 0)
}, [submitMutation.isPending, formState.isValid, canLogin])
const buttonStyle = useAnimatedStyle(() => ({
opacity: interpolate(canLogin.value, [0, 1], [1, 0.5]),
backgroundColor: interpolateColor(canLogin.value, [1, 0], [disableColor, accentColor]),
}))
return (
<View className="mx-auto flex w-full max-w-sm gap-6">
<View className="gap-4">
@ -139,18 +121,13 @@ export function EmailLogin() {
/>
</View>
</View>
<ReAnimatedPressable
<SubmitButton
disabled={submitMutation.isPending || !formState.isValid}
isLoading={submitMutation.isPending}
onPress={login}
className="mt-8 h-10 flex-row items-center justify-center rounded-3xl"
style={buttonStyle}
>
{submitMutation.isPending ? (
<ActivityIndicator className="text-white" />
) : (
<Text className="text-center font-semibold text-white">Continue</Text>
)}
</ReAnimatedPressable>
title="Continue"
className="mt-8"
/>
</View>
)
}

View File

@ -1,16 +1,16 @@
import { useMutation } from "@tanstack/react-query"
import { router } from "expo-router"
import { useMemo, useState } from "react"
import { useMemo, useRef } from "react"
import {
ActivityIndicator,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
useAnimatedValue,
View,
} from "react-native"
import { KeyboardAvoidingView, KeyboardController } from "react-native-keyboard-controller"
import { KeyboardController } from "react-native-keyboard-controller"
import type { OtpInputRef } from "react-native-otp-entry"
import { OtpInput } from "react-native-otp-entry"
import { useColor } from "react-native-uikit-colors"
import {
@ -22,6 +22,7 @@ import { twoFactor } from "@/src/lib/auth"
import { queryClient } from "@/src/lib/query-client"
import { toast } from "@/src/lib/toast"
import { whoamiQueryKey } from "@/src/store/user/hooks"
import { accentColor } from "@/src/theme/colors"
function isAuthCodeValid(authCode: string) {
return (
@ -32,7 +33,8 @@ function isAuthCodeValid(authCode: string) {
export default function TwoFactorAuthScreen() {
const scrollY = useAnimatedValue(0)
const label = useColor("label")
const [authCode, setAuthCode] = useState("")
const otpInputRef = useRef<OtpInputRef>(null)
const submitMutation = useMutation({
mutationFn: async (value: string) => {
@ -43,8 +45,8 @@ export default function TwoFactorAuthScreen() {
await queryClient.invalidateQueries({ queryKey: whoamiQueryKey })
},
onError(error) {
otpInputRef.current?.clear()
toast.error(`Failed to verify: ${error.message}`)
setAuthCode("")
},
onSuccess() {
router.replace("/")
@ -54,64 +56,49 @@ export default function TwoFactorAuthScreen() {
return (
<NavigationContext.Provider value={useMemo(() => ({ scrollY }), [scrollY])}>
<View className="flex-1 p-safe">
<KeyboardAvoidingView behavior={"padding"} className="flex-1">
<NavigationBlurEffectHeader
headerShown
headerTitle=""
title="2FA"
headerLeft={() => {
return (
<TouchableOpacity onPress={() => router.back()}>
<MingcuteLeftLineIcon color={label} />
</TouchableOpacity>
)
}}
/>
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()
}}
accessible={false}
>
<View className="mt-20 flex-1 pb-10">
<View className="flex-row items-center justify-center">
<Text className="text-label w-72 text-center text-3xl font-bold" numberOfLines={2}>
Verify with your authenticator app
</Text>
</View>
<View className="mx-5 mt-10">
<Text className="text-label">Enter Follow Auth Code</Text>
<View className="bg-secondary-system-background mt-2 rounded-lg p-4">
<TextInput
placeholder="6-digit authentication code"
autoComplete="one-time-code"
keyboardType="numeric"
className="text-text"
value={authCode}
onChangeText={setAuthCode}
/>
</View>
</View>
<View className="flex-1" />
<TouchableOpacity
className="disabled:bg-gray-3 mx-5 rounded-lg bg-accent py-3"
disabled={submitMutation.isPending || !isAuthCodeValid(authCode)}
onPress={() => {
submitMutation.mutate(authCode)
}}
>
{submitMutation.isPending ? (
<ActivityIndicator className="text-white" />
) : (
<Text className="text-center font-semibold text-white">Submit</Text>
)}
<NavigationBlurEffectHeader
headerShown
headerTitle=""
title="2FA"
headerLeft={() => {
return (
<TouchableOpacity onPress={() => router.back()}>
<MingcuteLeftLineIcon color={label} />
</TouchableOpacity>
)
}}
/>
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()
}}
accessible={false}
>
<View className="mt-20 flex-1 pb-10">
<View className="mb-10 flex-row items-center justify-center">
<Text className="text-label w-72 text-center text-3xl font-bold" numberOfLines={2}>
Verify with your authenticator app
</Text>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
<OtpInput
disabled={submitMutation.isPending}
ref={otpInputRef}
numberOfDigits={6}
autoFocus
focusColor={accentColor}
theme={{
containerStyle: { paddingHorizontal: 20 },
pinCodeTextStyle: { color: label },
}}
onFilled={(code) => {
if (isAuthCodeValid(code)) {
submitMutation.mutate(code)
}
}}
/>
</View>
</TouchableWithoutFeedback>
</View>
</NavigationContext.Provider>
)

View File

@ -626,6 +626,9 @@ importers:
react-native-keyboard-controller:
specifier: 1.16.3
version: 1.16.3(react-native-reanimated@3.16.7(@babel/core@7.26.9)(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)
react-native-otp-entry:
specifier: 1.8.2
version: 1.8.2(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)
react-native-pager-view:
specifier: 6.7.0
version: 6.7.0(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)
@ -720,7 +723,7 @@ importers:
devDependencies:
expo-module-scripts:
specifier: 4.0.4
version: 4.0.4(v6ktukj2nf4iuhjuuz6cntfrdu)
version: 4.0.4(3ecsnzozibljdasytj3d6ieu7e)
apps/mobile/web-app: {}
@ -12507,6 +12510,12 @@ packages:
react-native: '*'
react-native-reanimated: '>=3.0.0'
react-native-otp-entry@1.8.2:
resolution: {integrity: sha512-Ergs1fNpyp69oSrQ3k/PoolBpuutXruwhQDnEmX4xT3RnaZyo9lvcRsj2FafHanbFvghn/v3WcFRGU/GIhIMqQ==}
peerDependencies:
react: '*'
react-native: '*'
react-native-pager-view@6.7.0:
resolution: {integrity: sha512-sutxKiMqBuQrEyt4mLaLNzy8taIC7IuYpxfcwQBXfSYBSSpAa0qE9G1FXlP/iXqTSlFgBXyK7BESsl9umOjECQ==}
peerDependencies:
@ -24450,7 +24459,7 @@ snapshots:
expo: 52.0.35(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@expo/metro-runtime@4.0.1(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.2(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5)
react-native: 0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)
expo-module-scripts@4.0.4(v6ktukj2nf4iuhjuuz6cntfrdu):
expo-module-scripts@4.0.4(3ecsnzozibljdasytj3d6ieu7e):
dependencies:
'@babel/cli': 7.26.4(@babel/core@7.26.9)
'@babel/plugin-transform-export-namespace-from': 7.25.9(@babel/core@7.26.9)
@ -24465,11 +24474,11 @@ snapshots:
commander: 12.1.0
eslint-config-universe: 14.0.0(@types/eslint@9.6.1)(eslint@9.20.1(jiti@2.4.2))(prettier@3.5.1)(typescript@5.7.3)
glob: 10.4.5
jest-expo: 52.0.4(2en54dacn5jatfqxv77ym5375i)
jest-expo: 52.0.4(jneefgvk25es77gurrpifo7wzy)
jest-snapshot-prettier: prettier@2.8.8
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@22.13.4)(ts-node@10.9.2(@types/node@22.13.4)(typescript@5.7.3)))
resolve-workspace-root: 2.0.0
ts-jest: 29.0.5(@babel/core@7.26.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.9))(jest@29.7.0(@types/node@22.13.4)(ts-node@10.9.2(@types/node@22.13.4)(typescript@5.7.3)))(typescript@5.7.3)
ts-jest: 29.0.5(@babel/core@7.26.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.13.4)(ts-node@10.9.2(@types/node@22.13.4)(typescript@5.7.3)))(typescript@5.7.3)
typescript: 5.7.3
transitivePeerDependencies:
- '@babel/core'
@ -26305,7 +26314,7 @@ snapshots:
jest-mock: 29.7.0
jest-util: 29.7.0
jest-expo@52.0.4(2en54dacn5jatfqxv77ym5375i):
jest-expo@52.0.4(jneefgvk25es77gurrpifo7wzy):
dependencies:
'@expo/config': 10.0.10
'@expo/json-file': 9.0.2
@ -26322,7 +26331,7 @@ snapshots:
json5: 2.2.3
lodash: 4.17.21
react-native: 0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)
react-server-dom-webpack: 19.0.0-rc-6230622a1a-20240610(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.98.0)
react-server-dom-webpack: 19.0.0-rc-6230622a1a-20240610(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.98.0(esbuild@0.24.2))
react-test-renderer: 18.3.1(react@18.3.1)
server-only: 0.0.1
stacktrace-js: 2.0.2
@ -29331,6 +29340,11 @@ snapshots:
react-native-is-edge-to-edge: 1.1.6(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)
react-native-reanimated: 3.16.7(@babel/core@7.26.9)(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)
react-native-otp-entry@1.8.2(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1):
dependencies:
react: 18.3.1
react-native: 0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)
react-native-pager-view@6.7.0(react-native@0.77.0(@babel/core@7.26.9)(@babel/preset-env@7.26.9(@babel/core@7.26.9))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1):
dependencies:
react: 18.3.1
@ -29549,13 +29563,13 @@ snapshots:
dependencies:
selecto: 1.26.3
react-server-dom-webpack@19.0.0-rc-6230622a1a-20240610(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.98.0):
react-server-dom-webpack@19.0.0-rc-6230622a1a-20240610(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.98.0(esbuild@0.24.2)):
dependencies:
acorn-loose: 8.4.0
neo-async: 2.6.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
webpack: 5.98.0
webpack: 5.98.0(esbuild@0.24.2)
react-shadow@20.6.0(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
@ -30979,14 +30993,16 @@ snapshots:
ansi-escapes: 4.3.2
supports-hyperlinks: 2.3.0
terser-webpack-plugin@5.3.11(webpack@5.98.0):
terser-webpack-plugin@5.3.11(esbuild@0.24.2)(webpack@5.98.0(esbuild@0.24.2)):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 4.3.0
serialize-javascript: 6.0.2
terser: 5.39.0
webpack: 5.98.0
webpack: 5.98.0(esbuild@0.24.2)
optionalDependencies:
esbuild: 0.24.2
terser@5.39.0:
dependencies:
@ -31143,7 +31159,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
ts-jest@29.0.5(@babel/core@7.26.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.9))(jest@29.7.0(@types/node@22.13.4)(ts-node@10.9.2(@types/node@22.13.4)(typescript@5.7.3)))(typescript@5.7.3):
ts-jest@29.0.5(@babel/core@7.26.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.13.4)(ts-node@10.9.2(@types/node@22.13.4)(typescript@5.7.3)))(typescript@5.7.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
@ -31159,6 +31175,7 @@ snapshots:
'@babel/core': 7.26.9
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.26.9)
esbuild: 0.24.2
ts-morph@12.0.0:
dependencies:
@ -31878,7 +31895,7 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
webpack@5.98.0:
webpack@5.98.0(esbuild@0.24.2):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.6
@ -31900,7 +31917,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.0
tapable: 2.2.1
terser-webpack-plugin: 5.3.11(webpack@5.98.0)
terser-webpack-plugin: 5.3.11(esbuild@0.24.2)(webpack@5.98.0(esbuild@0.24.2))
watchpack: 2.4.2
webpack-sources: 3.2.3
transitivePeerDependencies: