diff --git a/apps/mobile/app/components/auth/header.tsx b/apps/mobile/app/components/auth/header.tsx
index 37357fe0c..c73dd7f5b 100644
--- a/apps/mobile/app/components/auth/header.tsx
+++ b/apps/mobile/app/components/auth/header.tsx
@@ -24,6 +24,9 @@ import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import { hideAuth } from "./common";
import { AuthParams } from "../../stores/use-navigation-store";
+import { ProgressPills } from "../intro/progress-pills";
+import { FontSizes } from "../../common/design/font";
+import { Spacing } from "../../common/design/spacing";
export const AuthHeader = (props: { welcome?: boolean }) => {
const { colors } = useThemeColors();
const route = useRoute();
@@ -37,19 +40,25 @@ export const AuthHeader = (props: { welcome?: boolean }) => {
- {props.welcome ? null : (
+ {props.welcome ? (
+
+ ) : (
{
hideAuth((route.params as AuthParams)?.context);
}}
+ style={{
+ width: 30,
+ height: 30
+ }}
+ size={26}
color={colors.primary.paragraph}
/>
)}
@@ -60,16 +69,16 @@ export const AuthHeader = (props: { welcome?: boolean }) => {
onPress={() => {
hideAuth();
}}
- iconSize={16}
+ iconSize={24}
+ fontSize={FontSizes.SM}
type="plain"
+ fontFamily="REGULAR"
iconPosition="right"
icon="chevron-right"
- height={25}
- iconStyle={{
- marginTop: 2
- }}
style={{
- paddingHorizontal: 6
+ paddingRight: 0,
+ paddingHorizontal: 0,
+ paddingVertical: 0
}}
/>
)}
diff --git a/apps/mobile/app/components/auth/login.tsx b/apps/mobile/app/components/auth/login.tsx
index fd1b3c80d..4dd390bcf 100644
--- a/apps/mobile/app/components/auth/login.tsx
+++ b/apps/mobile/app/components/auth/login.tsx
@@ -21,8 +21,10 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import { RouteProp, useRoute } from "@react-navigation/native";
import React, { useEffect, useState } from "react";
-import { TouchableOpacity, View, useWindowDimensions } from "react-native";
+import { TouchableOpacity, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
+import { db } from "../../common/database";
+import { Spacing } from "../../common/design/spacing";
import { DDS } from "../../services/device-detection";
import { eSendEvent, presentSheet } from "../../services/event-manager";
import Navigation from "../../services/navigation";
@@ -33,24 +35,23 @@ import { RouteParams } from "../../stores/use-navigation-store";
import { useUserStore } from "../../stores/use-user-store";
import { eUserLoggedIn } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
-import { DefaultAppStyles } from "../../utils/styles";
import { sleep } from "../../utils/time";
-import { Dialog } from "../dialog";
+import { ProgressPills } from "../intro/progress-pills";
import { Progress } from "../sheets/progress";
-import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
-import FormInput, { validators } from "../ui/input/form-input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { hideAuth } from "./common";
import { ForgotPassword } from "./forgot-password";
import { AuthHeader } from "./header";
+import TwoFactorVerification from "./two-factor";
import { useLogin } from "./use-login";
+import FormInput, { validators } from "../ui/input/form-input";
+import AppIcon from "../ui/AppIcon";
const LoginSteps = {
emailAuth: 1,
- mfaAuth: 2,
- passwordAuth: 3
+ mfaAuth: 2
};
export const Login = ({
@@ -68,8 +69,9 @@ export const Login = ({
passwordInputRef,
loading,
setLoading,
- login,
error,
+ login,
+ mfaData,
formRef
} = useLogin(async () => {
eSendEvent(eUserLoggedIn, true);
@@ -91,8 +93,6 @@ export const Login = ({
Progress.present();
}
});
- const { width, height } = useWindowDimensions();
- const isTablet = width > 600;
const onContinue = () => {
login();
@@ -114,249 +114,249 @@ export const Login = ({
return (
<>
-
+
+
+
+
+
-
+ {step === LoginSteps.emailAuth ? (
-
-
-
-
-
{strings.loginToYourAccount()}
-
-
-
- {
- if (step === LoginSteps.emailAuth) {
- onContinue();
- } else {
- passwordInputRef.current?.focus();
- }
+
-
- {step === LoginSteps.passwordAuth && (
- <>
- {
- onContinue();
- }}
- />
-
-
+ ) : (
+
+ void
+ ) => {
+ try {
+ const success = await db.user.authenticateMultiFactorCode(
+ mfa.code,
+ mfa.method
+ );
+
+ await login();
+
+ if (success) {
+ setLoading(false);
+ callback && callback(true);
+ }
+ callback && callback(false);
+ } catch (e) {
+ callback && callback(false);
+ if ((e as Error).message === "invalid_grant") {
+ setLoading(false);
+ setStep(LoginSteps.emailAuth);
+ }
+ }
+ }}
+ mfaInfo={
+ mfaData.current || {
+ primaryMethod: "email",
+ secondaryMethod: "sms",
+ token: ""
+ }
+ }
+ onCancel={() => {
+ setLoading(false);
+ setStep(LoginSteps.emailAuth);
+ }}
+ />
+
+ )}
>
);
diff --git a/apps/mobile/app/components/auth/signup.tsx b/apps/mobile/app/components/auth/signup.tsx
index 26ba89d11..7ab3e1390 100644
--- a/apps/mobile/app/components/auth/signup.tsx
+++ b/apps/mobile/app/components/auth/signup.tsx
@@ -46,6 +46,10 @@ import { SignupContext } from "./signup-context";
import { RouteParams } from "../../stores/use-navigation-store";
import SettingsService from "../../services/settings";
import AppIcon from "../ui/AppIcon";
+import { Spacing } from "../../common/design/spacing";
+import { SvgView } from "../ui/svg";
+import { SETTING_ACCOUNT_SVG } from "../../assets/images/assets";
+import { ProgressPills } from "../intro/progress-pills";
const SignupSteps = {
signup: 0,
@@ -133,7 +137,7 @@ export const Signup = ({
width: "100%"
}}
contentContainerStyle={{
- minHeight: "90%"
+ minHeight: "99%"
}}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
@@ -145,68 +149,24 @@ export const Signup = ({
zIndex: 10,
width: "100%",
alignSelf: "center",
- height: "100%"
+ height: "100%",
+ paddingHorizontal: Spacing.LEVEL_3,
+ paddingTop: Spacing.LEVEL_6
}}
>
-
-
-
-
-
-
-
- {strings.createAccount()}
-
-
+ {strings.createAccount()}
+
{
+ if (formRef.current.validateField("password")) return;
confirmPasswordInputRef.current?.focus();
}}
/>
@@ -265,8 +229,8 @@ export const Signup = ({
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
- placeholder={strings.confirmPassword()}
- marginBottom={12}
+ label={strings.confirmPassword()}
+ placeholder="•••••••••"
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField(
@@ -278,7 +242,17 @@ export const Signup = ({
signup();
}}
/>
+
+
-
+
{strings.alreadyHaveAccount()}{" "}
{strings.login()}
@@ -347,12 +317,12 @@ export const Signup = ({
marginBottom: 25,
textAlign: "center"
}}
- size={AppFontSize.xxs}
- color={colors.secondary.paragraph}
+ fontSize="XS"
+ color={colors.primary.paragraph}
>
{strings.signupAgreement[0]()}
{
openLinkInBrowser("https://notesnook.com/tos");
}}
@@ -366,7 +336,7 @@ export const Signup = ({
{" "}
{strings.signupAgreement[2]()}
{
openLinkInBrowser("https://notesnook.com/privacy");
}}
@@ -385,12 +355,22 @@ export const Signup = ({
>
) : (
- <>
+
+
- >
+
)}
);
diff --git a/apps/mobile/app/components/auth/two-factor.tsx b/apps/mobile/app/components/auth/two-factor.tsx
index 8cfffa116..1106abf4e 100644
--- a/apps/mobile/app/components/auth/two-factor.tsx
+++ b/apps/mobile/app/components/auth/two-factor.tsx
@@ -21,7 +21,6 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { TextInput, View } from "react-native";
-import { ScrollView } from "react-native-actions-sheet";
import { db } from "../../common/database/index";
import useTimer from "../../hooks/use-timer";
import { eSendEvent, ToastManager } from "../../services/event-manager";
@@ -32,10 +31,11 @@ import { presentDialog } from "../dialog/functions";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
-import Input from "../ui/input";
+import PinInput from "../ui/pin-input/index";
import { Pressable } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
+import { Radius, Spacing } from "../../common/design/spacing";
type MFAInfo = {
primaryMethod: string;
@@ -73,6 +73,10 @@ const TwoFactorVerification = ({
const inputRef = useRef(null);
const [sending, setSending] = useState(false);
const [error, setError] = useState(undefined);
+ const [codeInput, setCodeInput] = useState("");
+
+ const isRecoveryCode = currentMethod.method === "recoveryCode";
+ const codeLength = isRecoveryCode ? 10 : 6;
const onNext = async () => {
if (!code.current || code.current.length < 6) {
@@ -94,10 +98,7 @@ const TwoFactorVerification = ({
method: currentMethod.method,
code: code.current
},
- (result) => {
- if (result) {
- eSendEvent(eCloseSimpleDialog, "two_factor_verify");
- }
+ () => {
setLoading(false);
},
(e) => {
@@ -168,112 +169,86 @@ const TwoFactorVerification = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMethod.method]);
+ useEffect(() => {
+ setCodeInput("");
+ code.current = "";
+ }, [currentMethod.method]);
+
return (
- {
setTimeout(() => {
inputRef.current?.focus();
}, 500);
}}
+ style={{
+ alignItems: "center"
+ }}
>
-
+
-
-
- {currentMethod.method ? strings["2fa"]() : strings.select2faMethod()}
-
-
- {currentMethod.method
- ? strings["2faCodeHelpText"][
- currentMethod.method as keyof (typeof strings)["2faCodeHelpText"]
- ]?.() || strings.select2faCodeHelpText()
- : strings.select2faCodeHelpText()}
-
+ {currentMethod.method ? strings["2fa"]() : strings.select2faMethod()}
+
+
+ {currentMethod.method
+ ? strings["2faCodeHelpText"][
+ currentMethod.method as keyof (typeof strings)["2faCodeHelpText"]
+ ]?.() || strings.select2faCodeHelpText()
+ : strings.select2faCodeHelpText()}
+
- {currentMethod.method === "sms" || currentMethod.method === "email" ? (
-
- ) : null}
-
- {currentMethod.method ? (
- <>
-
+
+ {
- setError(undefined);
+ inputRef={inputRef}
+ length={codeLength}
+ value={codeInput}
+ onChangeText={(value: string) => {
+ setCodeInput(value);
code.current = value;
}}
- cursorColor={colors.selected.accent}
- selectionHandleColor={colors.selected.accent}
- selectionColor={colors.selected.accent}
onSubmitEditing={onNext}
- height={60}
- marginBottom={0}
- inputStyle={{
- fontSize: AppFontSize.lg,
- textAlign: "center",
- letterSpacing: 7,
- width: 250
- }}
- keyboardType={
- currentMethod.method === "recoveryCode" ? "default" : "numeric"
- }
- enablesReturnKeyAutomatically
- containerStyle={{
- minWidth: "50%"
+ keyboardType={isRecoveryCode ? "default" : "number-pad"}
+ sanitize={(value: string) => {
+ if (isRecoveryCode) {
+ return value.replace(/[^0-9a-zA-Z]/g, "").toUpperCase();
+ }
+
+ return value.replace(/[^0-9]/g, "");
}}
/>
{error ? (
@@ -296,76 +271,112 @@ const TwoFactorVerification = ({
) : null}
-
- {
- reset();
- onCancel();
+
-
-
- >
- ) : (
- <>
- {getMethods().map((item) => (
-
+ {
- setCurrentMethod({
- method: item.id,
- isPrimary: false
- });
+ reset();
+ onCancel();
}}
style={{
- paddingHorizontal: DefaultAppStyles.GAP,
- paddingVertical: DefaultAppStyles.GAP_VERTICAL,
- marginTop: 0,
- flexDirection: "row",
- borderRadius: 0,
- alignItems: "center",
- width: "100%",
- justifyContent: "flex-start"
+ width: "48%"
+ }}
+ />
+
+
+
+ {currentMethod.method === "sms" ||
+ currentMethod.method === "email" ? (
+
+ ) : null}
+
+
+
+ >
+ ) : (
+ <>
+ {getMethods().map((item) => (
+ {
+ setCurrentMethod({
+ method: item.id,
+ isPrimary: false
+ });
+ }}
+ style={{
+ paddingHorizontal: DefaultAppStyles.GAP,
+ paddingVertical: DefaultAppStyles.GAP_VERTICAL,
+ marginTop: 0,
+ flexDirection: "row",
+ borderRadius: 0,
+ alignItems: "center",
+ width: "100%",
+ justifyContent: "flex-start"
+ }}
+ >
+
+
-
-
- {item.title}
-
-
- ))}
- >
- )}
-
-
+ {item.title}
+
+
+ ))}
+ >
+ )}
+
);
};
diff --git a/apps/mobile/app/components/auth/use-login.ts b/apps/mobile/app/components/auth/use-login.ts
index d1d7192e1..d873eb2db 100644
--- a/apps/mobile/app/components/auth/use-login.ts
+++ b/apps/mobile/app/components/auth/use-login.ts
@@ -21,19 +21,16 @@ import { strings } from "@notesnook/intl";
import { useRef, useState } from "react";
import { TextInput } from "react-native";
import { db } from "../../common/database";
-import { ToastManager, eSendEvent } from "../../services/event-manager";
+import { ToastManager } from "../../services/event-manager";
import { clearMessage } from "../../services/message";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import { useUserStore } from "../../stores/use-user-store";
-import { eCloseSimpleDialog } from "../../utils/events";
-import TwoFactorVerification from "./two-factor";
import { createFormRef } from "../ui/input/form-input";
export const LoginSteps = {
emailAuth: 1,
- mfaAuth: 2,
- passwordAuth: 3
+ mfaAuth: 2
};
export const useLogin = (
@@ -46,6 +43,7 @@ export const useLogin = (
const [step, setStep] = useState(LoginSteps.emailAuth);
const emailInputRef = useRef(null);
const passwordInputRef = useRef(null);
+ const mfaData = useRef(undefined);
const formRef = useRef(
createFormRef({
email: "",
@@ -67,50 +65,16 @@ export const useLogin = (
const mfaInfo = await db.user.authenticateEmail(
formRef.current.getValue("email")
);
-
if (mfaInfo) {
- TwoFactorVerification.present(
- async (mfa: any, callback: (success: boolean) => void, onerror: (e: Error) => void) => {
- try {
- const success = await db.user.authenticateMultiFactorCode(
- mfa.code,
- mfa.method
- );
-
- if (success) {
- setStep(LoginSteps.passwordAuth);
- setLoading(false);
- setTimeout(() => {
- passwordInputRef.current?.focus();
- }, 500);
- callback && callback(true);
- }
- callback && callback(false);
- } catch (e) {
- callback && callback(false);
- if ((e as Error).message === "invalid_grant") {
- eSendEvent(eCloseSimpleDialog, "two_factor_verify");
- setLoading(false);
- setStep(LoginSteps.emailAuth);
- ToastManager.error(new Error("Token expired, try logging in again"));
- } else {
- onerror(e as Error);
- }
- }
- },
- mfaInfo,
- () => {
- eSendEvent(eCloseSimpleDialog, "two_factor_verify");
- setLoading(false);
- setStep(LoginSteps.emailAuth);
- }
- );
+ mfaData.current = mfaInfo;
+ setStep(LoginSteps.mfaAuth);
+ setLoading(false);
} else {
finishWithError(new Error(strings.unableToSend2faCode()));
}
break;
}
- case LoginSteps.passwordAuth: {
+ case LoginSteps.mfaAuth: {
if (!formRef.current.validate()) {
setLoading(false);
return;
@@ -172,6 +136,7 @@ export const useLogin = (
setLoading,
error,
setError,
- formRef
+ formRef,
+ mfaData
};
};
diff --git a/apps/mobile/app/components/intro/index.tsx b/apps/mobile/app/components/intro/index.tsx
index bcb828ec2..fabb36957 100644
--- a/apps/mobile/app/components/intro/index.tsx
+++ b/apps/mobile/app/components/intro/index.tsx
@@ -56,17 +56,26 @@ const Intro = () => {
gap: Spacing.LEVEL_2
}}
>
- {item.headings?.map((heading) =>
+ {item.headings?.map((heading, index) =>
heading.bold ? (
{heading.value()}
) : (
-
+
{heading.value()}
)
diff --git a/apps/mobile/app/components/intro/progress-pills.tsx b/apps/mobile/app/components/intro/progress-pills.tsx
index 4f527428d..5c60fc1a2 100644
--- a/apps/mobile/app/components/intro/progress-pills.tsx
+++ b/apps/mobile/app/components/intro/progress-pills.tsx
@@ -22,7 +22,10 @@ import { View } from "react-native";
import { useThemeColors } from "@notesnook/theme";
import { Radius, Spacing } from "../../common/design/spacing";
-export const ProgressPills = (props: { activePillIndex: number }) => {
+export const ProgressPills = (props: {
+ activePillIndex: number;
+ count?: number;
+}) => {
const { colors } = useThemeColors();
return (
{
gap: Spacing.LEVEL_1
}}
>
-
-
-
-
-
+ {new Array(props.count || 3).fill(0).map((item, index) => (
+
+ ))}
);
};
diff --git a/apps/mobile/app/components/loading/index.tsx b/apps/mobile/app/components/loading/index.tsx
index dc9293bd1..11ddfe03f 100644
--- a/apps/mobile/app/components/loading/index.tsx
+++ b/apps/mobile/app/components/loading/index.tsx
@@ -18,15 +18,19 @@ along with this program. If not, see .
*/
import { useThemeColors } from "@notesnook/theme";
import React from "react";
-import { View } from "react-native";
+import { View, ViewStyle } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { ProgressBarComponent } from "../ui/svg/lazy";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
+import { SvgView } from "../ui/svg";
+import { Radius, Spacing } from "../../common/design/spacing";
export const Loading = (props: {
title?: string;
description?: string;
icon?: string;
+ svgSrc?: string;
+ style?: ViewStyle;
}) => {
const { colors } = useThemeColors();
return (
@@ -37,18 +41,37 @@ export const Loading = (props: {
backgroundColor: colors.primary.background,
justifyContent: "center",
alignItems: "center",
- paddingHorizontal: 16
+ paddingHorizontal: 16,
+ ...props.style
}}
>
{props.icon ? (
) : null}
+ {props.svgSrc ? (
+
+
+
+ ) : null}
+
{props.title ? (
{props.title}
@@ -57,8 +80,10 @@ export const Loading = (props: {
{props.description ? (
{props.description}
@@ -67,15 +92,15 @@ export const Loading = (props: {
diff --git a/apps/mobile/app/components/ui/input/index.tsx b/apps/mobile/app/components/ui/input/index.tsx
index 19da9fde2..b283f2f54 100644
--- a/apps/mobile/app/components/ui/input/index.tsx
+++ b/apps/mobile/app/components/ui/input/index.tsx
@@ -43,6 +43,7 @@ import Paragraph from "../typography/paragraph";
import phone from "phone";
import isURL from "validator/lib/isURL";
import { DefaultAppStyles } from "../../../utils/styles";
+import { Spacing } from "../../../common/design/spacing";
interface InputProps extends TextInputProps {
fwdRef?: RefObject;
@@ -80,6 +81,7 @@ interface InputProps extends TextInputProps {
containerStyle?: ViewStyle;
wrapperStyle?: ViewStyle;
flexGrow?: number;
+ label?: string;
}
const Input = ({
@@ -93,7 +95,7 @@ const Input = ({
secureTextEntry,
customColor,
customValidator,
- marginBottom = 10,
+ marginBottom = 0,
button,
onBlurInput,
onPress,
@@ -103,6 +105,7 @@ const Input = ({
buttons,
marginRight,
buttonLeft,
+ label,
flexGrow = 1,
inputStyle = {},
containerStyle = {},
@@ -222,11 +225,11 @@ const Input = ({
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
- paddingHorizontal: DefaultAppStyles.GAP,
+ paddingHorizontal: Spacing.LEVEL_2,
paddingRight:
buttons || button || secureTextEntry || error
- ? DefaultAppStyles.GAP
- : DefaultAppStyles.GAP,
+ ? Spacing.LEVEL_3
+ : Spacing.LEVEL_3,
...containerStyle
};
@@ -235,10 +238,10 @@ const Input = ({
fontSize: fontSize,
color:
onPress && loading ? colors.primary.accent : colors.primary.paragraph,
+ paddingTop: Spacing.LEVEL_3,
+ paddingBottom: Spacing.LEVEL_3,
flexGrow: 1,
flexShrink: 1,
- paddingBottom: DefaultAppStyles.GAP_VERTICAL,
- paddingTop: DefaultAppStyles.GAP_VERTICAL,
fontFamily: "Inter-Regular",
...(inputStyle as ViewStyle)
};
@@ -253,6 +256,17 @@ const Input = ({
...wrapperStyle
}}
>
+ {label ? (
+
+ {label}
+
+ ) : undefined}
{
+ console.log(e.nativeEvent.layout);
+ }}
editable={!loading && restProps.editable}
onChangeText={onChange}
onBlur={onBlur}
@@ -376,37 +393,36 @@ const Input = ({
- {validationType === "password" && focus && (
+ {validationType === "password" &&
+ focus &&
+ Object.keys(errorList).filter((k) => errorList[k as ErrorKey] === true)
+ .length !== 0 ? (
- {Object.keys(errorList).filter(
- (k) => errorList[k as ErrorKey] === true
- ).length !== 0
- ? Object.keys(ERRORS_LIST).map((error) => (
-
-
+ {Object.keys(ERRORS_LIST).map((error) => (
+
+
-
- {ERRORS_LIST[error as ErrorKey]}
-
-
- ))
- : null}
+
+ {ERRORS_LIST[error as ErrorKey]}
+
+
+ ))}
- )}
+ ) : null}
>
);
};
diff --git a/apps/mobile/app/components/ui/pin-input/index.tsx b/apps/mobile/app/components/ui/pin-input/index.tsx
new file mode 100644
index 000000000..618471879
--- /dev/null
+++ b/apps/mobile/app/components/ui/pin-input/index.tsx
@@ -0,0 +1,340 @@
+/*
+This file is part of the Notesnook project (https://notesnook.com/)
+
+Copyright (C) 2023 Streetwriters (Private) Limited
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+
+import { useThemeColors } from "@notesnook/theme";
+import Clipboard from "@react-native-clipboard/clipboard";
+import React, { RefObject, useEffect, useMemo, useRef, useState } from "react";
+import {
+ AppState,
+ AppStateStatus,
+ KeyboardTypeOptions,
+ TextInput,
+ TextInputKeyPressEvent,
+ TextInputProps,
+ TouchableOpacity,
+ View
+} from "react-native";
+import { Radius, Spacing } from "../../../common/design/spacing";
+import { AppFontSize } from "../../../utils/size";
+
+type PinInputProps = {
+ value: string;
+ length: number;
+ testID?: string;
+ autoFocus?: boolean;
+ inputRef?: RefObject;
+ keyboardType?: KeyboardTypeOptions;
+ onSubmitEditing?: TextInputProps["onSubmitEditing"];
+ onChangeText: (value: string) => void;
+ sanitize?: (value: string) => string;
+};
+
+const defaultSanitizer = (value: string) => value;
+
+const PinInput = ({
+ value,
+ length,
+ testID,
+ autoFocus,
+ inputRef,
+ keyboardType = "number-pad",
+ onSubmitEditing,
+ onChangeText,
+ sanitize = defaultSanitizer
+}: PinInputProps) => {
+ const { colors } = useThemeColors();
+ const inputRefs = useRef>([]);
+ const appStateRef = useRef(AppState.currentState);
+ const lastAutoFilledValue = useRef("");
+ const [focusedIndex, setFocusedIndex] = useState(null);
+
+ const normalizeToCells = useMemo(
+ () => (raw: string) => {
+ const sanitized = sanitize(raw).slice(0, length);
+ const next = Array.from({ length }, () => "");
+ sanitized.split("").forEach((char, index) => {
+ next[index] = char;
+ });
+ return next;
+ },
+ [length, sanitize]
+ );
+
+ const [cells, setCells] = useState(() => normalizeToCells(value));
+
+ const emitChange = React.useCallback(
+ (nextCells: string[]) => {
+ onChangeText(nextCells.join(""));
+ },
+ [onChangeText]
+ );
+
+ const focusCell = React.useCallback(
+ (index: number) => {
+ const clampedIndex = Math.max(0, Math.min(index, length - 1));
+ requestAnimationFrame(() => {
+ inputRefs.current[clampedIndex]?.focus();
+ });
+ },
+ [length]
+ );
+
+ useEffect(() => {
+ if (autoFocus) {
+ focusCell(0);
+ }
+ }, [autoFocus, focusCell]);
+
+ useEffect(() => {
+ // Parent-driven resets should clear all slots.
+ if (!value) {
+ setCells(Array.from({ length }, () => ""));
+ return;
+ }
+
+ // Parent-driven complete values (OTP autofill, test paste) should sync.
+ if (value.length >= length) {
+ setCells(normalizeToCells(value));
+ }
+ }, [length, normalizeToCells, value]);
+
+ const onCellChange = (index: number, text: string) => {
+ const sanitized = sanitize(text);
+ const nextCells = [...cells];
+
+ if (!sanitized) {
+ nextCells[index] = "";
+ setCells(nextCells);
+ emitChange(nextCells);
+ return;
+ }
+
+ const chars = sanitized.split("");
+ let cursor = index;
+ chars.forEach((char) => {
+ if (cursor < length) {
+ nextCells[cursor] = char;
+ cursor += 1;
+ }
+ });
+
+ setCells(nextCells);
+ emitChange(nextCells);
+
+ if (cursor < length) {
+ focusCell(cursor);
+ } else {
+ inputRefs.current[length - 1]?.blur();
+ }
+ };
+
+ const onCellKeyPress = (index: number, event: TextInputKeyPressEvent) => {
+ if (event.nativeEvent.key !== "Backspace") return;
+
+ const nextCells = [...cells];
+
+ if (nextCells[index]) {
+ nextCells[index] = "";
+ setCells(nextCells);
+ emitChange(nextCells);
+ return;
+ }
+
+ if (index > 0) {
+ nextCells[index - 1] = "";
+ setCells(nextCells);
+ emitChange(nextCells);
+ focusCell(index - 1);
+ }
+ };
+
+ const onBulkInputChange = (text: string) => {
+ const sanitized = sanitize(text).slice(0, length);
+ const nextCells = normalizeToCells(sanitized);
+ setCells(nextCells);
+ emitChange(nextCells);
+
+ if (sanitized.length < length) {
+ focusCell(sanitized.length);
+ }
+ };
+
+ const applyClipboardCode = React.useCallback(
+ (clipboardText: string) => {
+ const sanitized = sanitize(clipboardText).slice(0, length);
+ if (sanitized.length !== length) return;
+
+ const currentJoined = cells.join("");
+ if (
+ currentJoined === sanitized ||
+ lastAutoFilledValue.current === sanitized
+ ) {
+ return;
+ }
+
+ const nextCells = normalizeToCells(sanitized);
+ setCells(nextCells);
+ emitChange(nextCells);
+ lastAutoFilledValue.current = sanitized;
+ inputRefs.current[length - 1]?.blur();
+ },
+ [cells, emitChange, length, normalizeToCells, sanitize]
+ );
+
+ useEffect(() => {
+ const readClipboardAndApply = async () => {
+ try {
+ const clipboardText = await Clipboard.getString();
+ if (!clipboardText) return;
+
+ // Prefer not to override a fully entered code while user is interacting.
+ if (cells.join("").length >= length) return;
+
+ applyClipboardCode(clipboardText);
+ } catch {
+ // Ignore clipboard access failures.
+ }
+ };
+
+ const subscription = AppState.addEventListener("change", (nextState) => {
+ const wasInBackground =
+ appStateRef.current === "background" ||
+ appStateRef.current === "inactive";
+
+ if (wasInBackground && nextState === "active") {
+ void readClipboardAndApply();
+ }
+
+ appStateRef.current = nextState;
+ });
+
+ return () => {
+ subscription.remove();
+ };
+ }, [cells, length, normalizeToCells, sanitize, applyClipboardCode]);
+
+ return (
+
+
+ {cells.map((char, index) => {
+ const isActive = focusedIndex === index;
+
+ return (
+ focusCell(index)}
+ >
+
+ {
+ inputRefs.current[index] = ref;
+ if (index === 0 && inputRef) {
+ inputRef.current = ref;
+ }
+ }}
+ disableFullscreenUI={true}
+ value={char}
+ maxLength={1}
+ keyboardType={keyboardType}
+ onChangeText={(text) => onCellChange(index, text)}
+ onKeyPress={(event) => onCellKeyPress(index, event)}
+ onFocus={() => setFocusedIndex(index)}
+ onBlur={() => {
+ setFocusedIndex((current) =>
+ current === index ? null : current
+ );
+ }}
+ autoCorrect={false}
+ autoComplete="off"
+ textContentType="oneTimeCode"
+ inputMode="numeric"
+ importantForAutofill="yes"
+ selectionColor={colors.selected.accent}
+ style={{
+ width: "100%",
+ height: "100%",
+ textAlign: "center",
+ fontSize: AppFontSize.sm,
+ color: colors.primary.paragraph,
+ padding: 0,
+ margin: 0
+ }}
+ onSubmitEditing={onSubmitEditing}
+ blurOnSubmit={index === length - 1}
+ returnKeyType={index === length - 1 ? "done" : "next"}
+ />
+
+
+ );
+ })}
+
+
+ {/* Hidden aggregate input for full-code paste/autofill and test compatibility. */}
+
+
+ );
+};
+
+export default PinInput;
diff --git a/apps/mobile/app/components/ui/svg/index.tsx b/apps/mobile/app/components/ui/svg/index.tsx
index 77cab76d1..a3d9037f5 100644
--- a/apps/mobile/app/components/ui/svg/index.tsx
+++ b/apps/mobile/app/components/ui/svg/index.tsx
@@ -18,24 +18,26 @@ along with this program. If not, see .
*/
import React from "react";
-import { DimensionValue, View } from "react-native";
+import { DimensionValue, View, ViewStyle } from "react-native";
import { SvgXml } from "./lazy";
-import SvgImage from "react-native-svg/lib/typescript/elements/Image";
export const SvgView = ({
width = 250,
height = 250,
- src
+ src,
+ style
}: {
width?: DimensionValue;
height?: DimensionValue;
src?: string;
+ style?: ViewStyle;
}) => {
if (!src) return null;
return (