Merge branch 'master' into beta

This commit is contained in:
Ammar Ahmed
2026-05-15 12:53:44 +05:00
42 changed files with 1643 additions and 517 deletions

View File

@@ -140,7 +140,7 @@ android {
if (project.hasProperty("prBuildNumber")) {
versionCode Integer.parseInt(prBuildNumber())
} else {
versionCode 3103
versionCode 3104
}
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')

View File

@@ -22,7 +22,7 @@ import { Attachment, Note, VirtualizedGrouping } from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { RefObject, useEffect, useState } from "react";
import { View } from "react-native";
import { TextInput, View } from "react-native";
import { ActionSheetRef } from "react-native-actions-sheet";
import { ScrollView } from "react-native-gesture-handler";
import { db } from "../../common/database";
@@ -59,6 +59,7 @@ import Paragraph from "../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../utils/styles";
import Navigation from "../../services/navigation";
import { createFormRef, validators } from "../ui/input/form-input";
const Actions = ({
attachment,
@@ -153,50 +154,100 @@ const Actions = ({
{
name: strings.rename(),
onPress: () => {
presentDialog({
input: true,
title: strings.renameFile(),
defaultValue: attachment.filename,
positivePress: async (value) => {
if (value && value.length > 0) {
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
}
return true;
},
positiveText: strings.rename()
});
close?.();
setTimeout(() => {
presentDialog({
title: strings.renameFile(),
form: {
formRef: createFormRef({
name: attachment.filename
}),
items: [
{
name: "name",
defaultValue: attachment.filename,
placeholder: strings.enterTitle(),
ref: React.createRef<TextInput | null>(),
validators: [validators.required(strings.nameIsRequired())]
}
],
onFormSubmit: async (form) => {
try {
const value = form.getValue("name");
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
ToastManager.show({
message: `Attachment renamed to ${value}`,
type: "success"
});
return true;
} catch (e) {
form.setError("name", (e as Error).message);
return false;
}
}
},
positiveText: strings.rename()
});
}, 500);
},
icon: "form-textbox"
},
{
name: strings.delete(),
onPress: async () => {
const relations = await db.relations.to(attachment, "note").get();
await db.attachments.remove(attachment.hash, false);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
relations
.map((relation) => relation.fromId)
.forEach(async (id) => {
useTabStore.getState().forEachNoteTab(id, async (tab) => {
const isFocused = useTabStore.getState().currentTab === tab.id;
if (isFocused) {
eSendEvent(eOnLoadNote, {
item: await db.notes.note(id),
forced: true
});
} else {
editorController.current.commands.setLoading(true, tab.id);
}
});
});
close?.();
setTimeout(() => {
presentDialog({
title: strings.deleteAttachment(),
paragraph: strings.deleteAttachmentConfirm(),
positiveText: strings.yes(),
negativeText: strings.no(),
positiveType: "errorShade",
positivePress: async () => {
try {
const relations = await db.relations
.to(attachment, "note")
.get();
await db.attachments.remove(attachment.hash, false);
ToastManager.show({
type: "success",
message: strings.attachmentDeleted()
});
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
relations
.map((relation) => relation.fromId)
.forEach(async (id) => {
useTabStore.getState().forEachNoteTab(id, async (tab) => {
const isFocused =
useTabStore.getState().currentTab === tab.id;
if (isFocused) {
eSendEvent(eOnLoadNote, {
item: await db.notes.note(id),
forced: true
});
} else {
editorController.current.commands.setLoading(
true,
tab.id
);
}
});
});
return true;
} catch (e) {
return false;
}
}
});
}, 500);
},
icon: "delete-outline"
}

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
@@ -26,42 +27,44 @@ import { eSendEvent, ToastManager } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
import { eOpenRecoveryKeyDialog } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import Input from "../ui/input";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import Paragraph from "../ui/typography/paragraph";
import { TextInput } from "react-native-gesture-handler";
export const ChangePassword = () => {
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const { colors } = useThemeColors();
const formRef = useRef(
createFormRef({
oldPassword: "",
password: ""
})
);
const oldPasswordInputRef = useRef<TextInput>(null);
const oldPassword = useRef<string>(undefined);
const [error, setError] = useState(false);
const passwordInputRef = useRef<TextInput>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const user = useUserStore((state) => state.user);
const changePassword = async () => {
setError(undefined);
formRef.current.clearErrors();
if (!user?.isEmailConfirmed) {
ToastManager.show({
heading: strings.emailNotConfirmed(),
message: strings.emailNotConfirmedDesc(),
type: "error",
context: "local"
});
setError(strings.emailNotConfirmedDesc());
return;
}
if (error || !oldPassword.current || !password.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
if (!formRef.current.validate()) {
return;
}
const values = formRef.current.getValues();
setLoading(true);
try {
const result = await BackupService.run(
@@ -74,8 +77,8 @@ export const ChangePassword = () => {
}
const passwordChanged = await db.user.changePassword(
oldPassword.current,
password.current
values.oldPassword,
values.password
);
if (!passwordChanged) {
@@ -91,15 +94,15 @@ export const ChangePassword = () => {
Navigation.goBack();
eSendEvent(eOpenRecoveryKeyDialog);
} catch (e) {
const message = (e as Error).message;
setLoading(false);
ToastManager.show({
heading: strings.passwordChangeFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
if (/old password/i.test(message)) {
formRef.current.setError("oldPassword", message);
} else {
setError(message);
}
}
setLoading(false);
};
return (
@@ -110,36 +113,61 @@ export const ChangePassword = () => {
}}
>
<Dialog context="change-password-dialog" />
<Input
<FormInput
name="oldPassword"
formRef={formRef}
fwdRef={oldPasswordInputRef}
onChangeText={(value) => {
oldPassword.current = value;
}}
loading={loading}
validators={[validators.required(strings.currentPasswordRequired())]}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.oldPassword()}
placeholder={strings.currentPassword()}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
onErrorCheck={(e) => setError(e)}
loading={loading}
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
validationType="password"
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.newPassword()}
onSubmitEditing={() => {
changePassword();
}}
/>
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error}
</Paragraph>
) : null}
<Notice text={strings.changePasswordNotice()} type="alert" />
<View style={{ height: 10 }} />

View File

@@ -27,7 +27,7 @@ import { useThemeColors } from "@notesnook/theme";
import DialogHeader from "../dialog/dialog-header";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import Seperator from "../ui/seperator";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
@@ -36,21 +36,22 @@ import { DefaultAppStyles } from "../../utils/styles";
export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
const { colors } = useThemeColors("sheet");
const email = useRef<string>(userEmail);
const formRef = useRef(
createFormRef({
email: userEmail || ""
})
);
const emailInputRef = useRef<TextInput>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const sendRecoveryEmail = async () => {
if (!email.current || error) {
ToastManager.show({
heading: strings.emailRequired(),
type: "error",
context: "local"
});
if (formRef.current.validateField("email")) {
return;
}
const values = formRef.current.getValues();
setLoading(true);
try {
const lastRecoveryEmailTime = SettingsService.get().lastRecoveryEmailTime;
@@ -60,7 +61,7 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
) {
throw new Error(strings.pleaseWaitBeforeSendEmail());
}
await db.user.recoverAccount(email.current.toLowerCase());
await db.user.recoverAccount(values.email.toLowerCase());
SettingsService.set({
lastRecoveryEmailTime: Date.now()
});
@@ -75,12 +76,7 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
setSent(true);
} catch (e) {
setLoading(false);
ToastManager.show({
heading: strings.recoveryEmailFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
formRef.current.setError("email", (e as Error).message);
}
};
@@ -126,22 +122,25 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
<DialogHeader title={strings.accountRecovery()} />
<Seperator />
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
defaultValue={email.current}
onErrorCheck={(e) => setError(e)}
loading={loading}
returnKeyLabel={strings.next()}
returnKeyType="next"
autoComplete="email"
validationType="email"
keyboardType="email-address"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
onSubmit={() => {}}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
sendRecoveryEmail();
}}
/>
<Button

View File

@@ -37,8 +37,9 @@ import { DefaultAppStyles } from "../../utils/styles";
import { sleep } from "../../utils/time";
import { Dialog } from "../dialog";
import { Progress } from "../sheets/progress";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import Input from "../ui/input";
import FormInput, { validators } from "../ui/input/form-input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { hideAuth } from "./common";
@@ -63,14 +64,13 @@ export const Login = ({
const {
step,
setStep,
password,
email,
emailInputRef,
passwordInputRef,
loading,
setLoading,
setError,
login
login,
error,
formRef
} = useLogin(async () => {
eSendEvent(eUserLoggedIn, true);
await sleep(500);
@@ -93,6 +93,11 @@ export const Login = ({
});
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const onContinue = () => {
login();
};
useEffect(() => {
async () => {
setStep(LoginSteps.emailAuth);
@@ -198,27 +203,27 @@ export const Login = ({
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
validationType="email"
keyboardType="email-address"
marginBottom={0}
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
defaultValue={email.current}
editable={step === LoginSteps.emailAuth && !loading}
onSubmit={() => {
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
if (step === LoginSteps.emailAuth) {
login();
onContinue();
} else {
passwordInputRef.current?.focus();
}
@@ -227,11 +232,10 @@ export const Login = ({
{step === LoginSteps.passwordAuth && (
<>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
testID="input.password"
returnKeyLabel={strings.done()}
returnKeyType="done"
@@ -242,9 +246,9 @@ export const Login = ({
placeholder={strings.password()}
marginBottom={0}
editable={!loading}
defaultValue={password.current}
onSubmit={() => {
login();
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onContinue();
}}
/>
<Button
@@ -255,9 +259,13 @@ export const Login = ({
paddingHorizontal: 0
}}
onPress={() => {
if (loading || !email.current) return;
if (loading) return;
presentSheet({
component: <ForgotPassword userEmail={email.current} />
component: (
<ForgotPassword
userEmail={formRef.current.getValue("email")}
/>
)
});
}}
textStyle={{
@@ -273,8 +281,7 @@ export const Login = ({
<Button
loading={loading}
onPress={() => {
if (loading) return;
login();
onContinue();
}}
style={{
width: "100%"
@@ -328,6 +335,25 @@ export const Login = ({
</Paragraph>
</TouchableOpacity>
) : null}
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error.message}
</Paragraph>
) : null}
</View>
</View>
</View>

View File

@@ -43,29 +43,26 @@ import SheetProvider from "../sheet-provider";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { LoginSteps, useLogin } from "./use-login";
import { strings } from "@notesnook/intl";
import { getObfuscatedEmail } from "../../utils/functions";
import { DefaultAppStyles } from "../../utils/styles";
import FormInput, { validators } from "../ui/input/form-input";
export const SessionExpired = () => {
const { colors } = useThemeColors();
const [visible, setVisible] = useState(false);
const [focused, setFocused] = useState(false);
const { step, password, email, passwordInputRef, loading, login } = useLogin(
() => {
eSendEvent(eUserLoggedIn, true);
setVisible(false);
setFocused(false);
useUserStore.setState({
disableAppLockRequests: false
});
},
true
);
const { step, passwordInputRef, loading, login, formRef } = useLogin(() => {
eSendEvent(eUserLoggedIn, true);
setVisible(false);
setFocused(false);
useUserStore.setState({
disableAppLockRequests: false
});
}, true);
const logout = async () => {
try {
@@ -104,7 +101,7 @@ export const SessionExpired = () => {
if (!complete) {
const user = await db.user.getUser();
if (!user) return;
email.current = user.email;
formRef.current.setValue("email", user.email);
setVisible(true);
setFocused(false);
return;
@@ -117,14 +114,14 @@ export const SessionExpired = () => {
} catch (e) {
const user = await db.user.getUser();
if (!user) return;
email.current = user.email;
formRef.current.setValue("email", user.email);
setFocused(false);
setVisible(true);
useUserStore.setState({
disableAppLockRequests: true
});
}
}, [email]);
}, [formRef]);
useEffect(() => {
const sub = eSubscribeEvent(eLoginSessionExpired, open);
@@ -155,6 +152,7 @@ export const SessionExpired = () => {
enableSheetKeyboardHandler={true}
visible={true}
>
<Dialog context="two_factor_verify" />
<View
style={{
width: focused ? "100%" : "99.9%",
@@ -192,17 +190,17 @@ export const SessionExpired = () => {
}}
>
{strings.sessionExpiredDesc(
getObfuscatedEmail(email.current as string)
getObfuscatedEmail(formRef.current.getValue("email") as string)
)}
</Paragraph>
</View>
{step === LoginSteps.passwordAuth ? (
<Input
<FormInput
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
formRef={formRef}
name="password"
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.done()}
returnKeyType="next"
secureTextEntry
@@ -210,7 +208,7 @@ export const SessionExpired = () => {
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
onSubmit={() => {
onSubmitEditing={() => {
login();
}}
/>

View File

@@ -30,7 +30,6 @@ import {
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
import { ToastManager } from "../../services/event-manager";
import { clearMessage, setEmailVerifyMessage } from "../../services/message";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
@@ -39,13 +38,14 @@ import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Loading } from "../loading";
import { Button } from "../ui/button";
import Input from "../ui/input";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { AuthHeader } from "./header";
import { SignupContext } from "./signup-context";
import { RouteParams } from "../../stores/use-navigation-store";
import SettingsService from "../../services/settings";
import AppIcon from "../ui/AppIcon";
const SignupSteps = {
signup: 0,
@@ -62,13 +62,17 @@ export const Signup = ({
}) => {
const [currentStep, setCurrentStep] = useState(SignupSteps.signup);
const { colors } = useThemeColors();
const email = useRef<string>(undefined);
const formRef = useRef(
createFormRef({
email: "",
password: "",
confirmPassword: ""
})
);
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const confirmPasswordInputRef = useRef<TextInput>(null);
const confirmPassword = useRef<string>(undefined);
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>();
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const setLastSynced = useUserStore((state) => state.setLastSynced);
@@ -76,29 +80,17 @@ export const Signup = ({
const isTablet = width > 600;
const route = useRoute<RouteProp<RouteParams, "Auth">>();
const validateInfo = () => {
if (!password.current || !email.current || !confirmPassword.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
return false;
}
return true;
};
const signup = async () => {
if (!validateInfo() || error) return;
setErrorMessage(undefined);
if (!formRef.current.validate()) return;
if (loading) return;
const values = formRef.current.getValues();
setLoading(true);
try {
setCurrentStep(SignupSteps.createAccount);
await db.user.signup(email.current!.toLowerCase(), password.current!);
await db.user.signup(values.email.toLowerCase(), values.password);
const user = await db.user.getUser();
setUser(user);
setLastSynced(await db.lastSynced());
@@ -115,12 +107,14 @@ export const Signup = ({
} catch (e) {
setCurrentStep(SignupSteps.signup);
setLoading(false);
ToastManager.show({
heading: strings.signupFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
if (
(e as Error).message === "Unable to create an account on this email."
) {
formRef.current.setError("email", (e as Error).message);
} else {
setErrorMessage((e as Error).message);
}
return false;
}
};
@@ -215,37 +209,35 @@ export const Signup = ({
alignSelf: "center"
}}
>
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
defaultValue={email.current}
loading={loading}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
validationType="email"
keyboardType="email-address"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
blurOnSubmit={false}
onSubmit={() => {
if (!email.current) return;
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
defaultValue={password.current}
loading={loading}
testID="input.password"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
@@ -254,20 +246,18 @@ export const Signup = ({
blurOnSubmit={false}
autoCorrect={false}
placeholder={strings.password()}
onSubmit={() => {
if (!password.current) return;
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
confirmPasswordInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="confirmPassword"
formRef={formRef}
fwdRef={confirmPasswordInputRef}
onChangeText={(value) => {
confirmPassword.current = value;
}}
defaultValue={confirmPassword.current}
loading={loading}
testID="input.confirmPassword"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Signup"
returnKeyType="done"
secureTextEntry
@@ -275,17 +265,22 @@ export const Signup = ({
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
validationType="confirmPassword"
customValidator={() => password.current || ""}
placeholder={strings.confirmPassword()}
marginBottom={12}
onSubmit={() => {
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField(
"password",
strings.passwordNotMatched()
)
]}
onSubmitEditing={() => {
signup();
}}
/>
<Button
title={!loading ? "Continue" : null}
title={!loading ? strings.continue() : null}
type="accent"
loading={loading}
onPress={() => {
@@ -319,6 +314,25 @@ export const Signup = ({
</Paragraph>
</Paragraph>
</TouchableOpacity>
{errorMessage ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{errorMessage}
</Paragraph>
) : null}
</View>
<View

View File

@@ -28,6 +28,7 @@ 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,
@@ -39,45 +40,33 @@ export const useLogin = (
onFinishLogin?: () => void,
sessionExpired = false
) => {
const [error, setError] = useState(false);
const [error, setError] = useState<Error>();
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const [step, setStep] = useState(LoginSteps.emailAuth);
const email = useRef<string>(undefined);
const password = useRef<string>(undefined);
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const validateInfo = () => {
if (
(!password.current && step === LoginSteps.passwordAuth) ||
(!email.current && step === LoginSteps.emailAuth)
) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
return false;
}
return true;
};
const formRef = useRef(
createFormRef({
email: "",
password: ""
})
);
const login = async () => {
if (!validateInfo() || error) return;
try {
if (loading) return;
setError(undefined);
setLoading(true);
switch (step) {
case LoginSteps.emailAuth: {
if (!email.current) {
if (formRef.current.validateField("email")) {
setLoading(false);
return;
}
const mfaInfo = await db.user.authenticateEmail(email.current);
const mfaInfo = await db.user.authenticateEmail(
formRef.current.getValue("email")
);
if (mfaInfo) {
TwoFactorVerification.present(
@@ -122,13 +111,14 @@ export const useLogin = (
break;
}
case LoginSteps.passwordAuth: {
if (!email.current || !password.current) {
if (!formRef.current.validate()) {
setLoading(false);
return;
}
const values = formRef.current.getValues();
await db.user.authenticatePassword(
email.current,
password.current,
values.email,
values.password,
undefined,
sessionExpired
);
@@ -145,12 +135,11 @@ export const useLogin = (
const finishWithError = async (e: Error) => {
if (e.message === "invalid_grant") setStep(LoginSteps.emailAuth);
setLoading(false);
ToastManager.show({
heading: strings.loginFailed(),
message: e.message,
type: "error",
context: "local"
});
if (e.message === "Password is incorrect.") {
formRef.current.setError("password", e.message);
} else {
setError(e);
}
};
const finishLogin = async () => {
@@ -177,13 +166,12 @@ export const useLogin = (
login,
step,
setStep,
email,
password,
passwordInputRef,
emailInputRef,
loading,
setLoading,
error,
setError
setError,
formRef
};
};

View File

@@ -136,7 +136,7 @@ const BaseDialog = ({
? background
: transparent
? "transparent"
: "rgba(0,0,0,0.3)"
: "rgba(0,0,0,0.1)"
}}
>
<KeyboardAvoidingView

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { StyleSheet, View } from "react-native";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { notesnook } from "../../../e2e/test.ids";
import { getColorLinearShade } from "../../utils/colors";
@@ -79,6 +79,11 @@ const DialogButtons = ({
/>
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
</View>
) : loading ? (
<ActivityIndicator
size={AppFontSize.lg}
color={colors.primary.accent}
/>
) : (
<View />
)}
@@ -105,7 +110,6 @@ const DialogButtons = ({
style={{
marginLeft: 10
}}
loading={loading}
bold
type={positiveType || "transparent"}
title={positiveTitle}

View File

@@ -17,10 +17,12 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { KeyboardTypeOptions } from "react-native";
import { KeyboardTypeOptions, TextInput } from "react-native";
import { eSendEvent } from "../../services/event-manager";
import { eCloseSimpleDialog, eOpenSimpleDialog } from "../../utils/events";
import { ButtonProps } from "../ui/button";
import { FieldValidator, FormRef } from "../ui/input/form-input";
import { RefObject } from "react";
export type DialogInfo = {
title?: string;
@@ -43,6 +45,18 @@ export type DialogInfo = {
| "errorShade";
icon?: string;
paragraphColor: string;
form?: {
formRef: FormRef;
items: {
name: string;
placeholder: string;
label?: string;
validators: FieldValidator[];
defaultValue?: string;
ref: RefObject<TextInput | null>;
}[];
onFormSubmit?: (form: FormRef) => Promise<boolean>;
};
input: boolean;
inputPlaceholder: string;
defaultValue: string;

View File

@@ -18,7 +18,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useRef,
useState,
RefObject
} from "react";
import { TextInput, View, ViewStyle } from "react-native";
import { DDS } from "../../services/device-detection";
import {
@@ -34,6 +40,7 @@ import { sleep } from "../../utils/time";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import Input from "../ui/input";
import { FormInput, type FormRef } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import Seperator from "../ui/seperator";
import BaseDialog from "./base-dialog";
@@ -53,15 +60,38 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
});
const inputRef = useRef<TextInput>(null);
const [dialogInfo, setDialogInfo] = useState<DialogInfo>();
const formRef = useRef(dialogInfo?.form?.formRef);
formRef.current = dialogInfo?.form?.formRef;
const onPressPositive = async () => {
if (dialogInfo?.positivePress) {
// Handle form submission if form is available
if (dialogInfo?.form && formRef.current) {
inputRef.current?.blur();
try {
const isValid = await formRef.current.validate();
if (!isValid) {
return;
}
if (dialogInfo.form.onFormSubmit) {
setLoading(true);
const result = await dialogInfo.form.onFormSubmit(formRef.current);
if (result === false) {
setLoading(false);
return;
}
}
} catch (e) {
/** Empty */
}
setLoading(false);
} else if (dialogInfo?.positivePress) {
// Handle old input-based submission
inputRef.current?.blur();
setLoading(true);
let result = false;
try {
result = await dialogInfo.positivePress(
values.current.inputValue || dialogInfo.defaultValue,
values.current.inputValue,
checked
);
} catch (e) {
@@ -76,6 +106,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
setChecked(false);
values.current.inputValue = undefined;
formRef.current = undefined;
setVisible(false);
};
@@ -85,6 +116,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
if (data.context !== context) return;
setDialogInfo(data);
setChecked(data.check?.defaultValue);
formRef.current = data?.form?.formRef;
values.current.inputValue = data.defaultValue;
setVisible(true);
},
@@ -94,6 +126,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
const hide = React.useCallback(() => {
setChecked(false);
values.current.inputValue = undefined;
formRef.current = undefined;
setVisible(false);
setDialogInfo(undefined);
dialogInfo?.onClose?.();
@@ -134,19 +167,30 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
? false
: dialogInfo.statusBarTranslucent
}
bounce={!dialogInfo.input}
bounce={!dialogInfo.input && !dialogInfo.form}
closeOnTouch={!dialogInfo.disableBackdropClosing}
background={dialogInfo.background}
transparent={
dialogInfo.transparent === undefined ? true : dialogInfo.transparent
dialogInfo.transparent === undefined ? false : dialogInfo.transparent
}
onShow={async () => {
if (dialogInfo.input) {
if (dialogInfo.input && !dialogInfo.form) {
inputRef.current?.setNativeProps({
text: dialogInfo.defaultValue
});
await sleep(300);
inputRef.current?.focus();
} else if (dialogInfo.form) {
const items = dialogInfo.form?.items;
const firstItem = items[0];
for (const item of items) {
if (item.defaultValue) {
item.ref.current?.setNativeProps({
text: dialogInfo.defaultValue
});
}
}
firstItem?.ref?.current?.focus();
}
}}
visible={true}
@@ -170,7 +214,36 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
/>
<Seperator half />
{dialogInfo.input ? (
{dialogInfo.form ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP / 2
}}
>
{dialogInfo.form.items.map((item, index) => (
<FormInput
key={item.name}
fwdRef={item.ref}
name={item.name}
autoFocus={index === 0}
placeholder={item.placeholder}
formRef={formRef as RefObject<FormRef>}
validators={item.validators}
defaultValue={item.defaultValue}
secureTextEntry={dialogInfo.secureTextEntry}
onSubmitEditing={() => {
const nextItem = dialogInfo?.form?.items?.[index + 1];
if (nextItem) {
nextItem?.ref.current?.focus();
} else {
onPressPositive();
}
}}
/>
))}
</View>
) : dialogInfo.input ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
@@ -184,7 +257,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
}}
testID="input-value"
secureTextEntry={dialogInfo.secureTextEntry}
//defaultValue={dialogInfo.defaultValue}
defaultValue={dialogInfo.defaultValue}
onSubmit={() => {
onPressPositive();
}}
@@ -237,7 +310,10 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
<DialogButtons
onPressNegative={onNegativePress}
onPressPositive={dialogInfo.positivePress && onPressPositive}
onPressPositive={
(dialogInfo.positivePress || dialogInfo.form?.onFormSubmit) &&
onPressPositive
}
loading={loading}
positiveTitle={dialogInfo.positiveText}
negativeTitle={dialogInfo.negativeText}

View File

@@ -69,8 +69,6 @@ export const SectionHeader = React.memo<
dataType as "note" | "notebook" | "searchResult"
);
console.log(item);
return (
<View
style={{

View File

@@ -323,7 +323,6 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
}
function getDate(item: Notebook | Note, groupType?: GroupingKey): number {
console.log(groupType);
return (
getSortValue(
groupType

View File

@@ -0,0 +1,411 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
import React, { RefObject, useEffect, useMemo, useState } from "react";
import {
ColorValue,
TextInput,
TextInputProps,
TextStyle,
TouchableOpacity,
View,
ViewStyle
} from "react-native";
import isEmail from "validator/lib/isEmail";
import isURL from "validator/lib/isURL";
import { useThemeColors } from "@notesnook/theme";
import { defaultBorderRadius, AppFontSize } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import { IconButton } from "../icon-button";
import Paragraph from "../typography/paragraph";
import AppIcon from "../AppIcon";
export type FormValues = Record<string, string>;
export type FormErrors = Partial<Record<string, string>>;
export type FieldValidator = (
value: string,
values: FormValues
) => string | undefined;
export type ValidationSchema = Partial<Record<string, FieldValidator[]>>;
export interface FormRef {
values: FormValues;
errors: FormErrors;
setValue: (name: string, value: string) => void;
getValue: (name: string) => string;
getValues: () => FormValues;
setError: (name: string, error?: string) => void;
getError: (name: string) => string | undefined;
clearErrors: () => void;
registerField: (name: string, validators?: FieldValidator[]) => void;
unregisterField: (name: string) => void;
validateField: (name: string) => string | undefined;
validate: () => boolean;
subscribe: (listener: () => void) => () => void;
}
export function createFormRef(initialValues: FormValues = {}): FormRef {
const listeners = new Set<() => void>();
const values: FormValues = { ...initialValues };
const errors: FormErrors = {};
const schema: ValidationSchema = {};
const notify = () => {
listeners.forEach((listener) => listener());
};
return {
values,
errors,
setValue(name, value) {
values[name] = value;
if (errors[name]) {
delete errors[name];
notify();
}
},
getValue(name) {
return values[name] ?? "";
},
getValues() {
return { ...values };
},
setError(name, error) {
if (!error) {
delete errors[name];
} else {
errors[name] = error;
}
notify();
},
getError(name) {
return errors[name];
},
clearErrors() {
Object.keys(errors).forEach((key) => delete errors[key]);
notify();
},
registerField(name, validators = []) {
schema[name] = validators;
if (values[name] === undefined) values[name] = "";
},
unregisterField(name) {
delete schema[name];
delete errors[name];
notify();
},
validateField(name) {
const fieldValidators = schema[name] || [];
const value = values[name] ?? "";
for (const validator of fieldValidators) {
const error = validator(value, values);
if (error) {
errors[name] = error;
notify();
return error;
}
}
delete errors[name];
notify();
return undefined;
},
validate() {
const nextErrors = validateForm(values, schema);
Object.keys(errors).forEach((key) => delete errors[key]);
Object.keys(nextErrors).forEach((key) => {
errors[key] = nextErrors[key];
});
notify();
return !hasFormErrors(nextErrors);
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
};
}
export function validateForm(
values: FormValues,
schema: ValidationSchema
): FormErrors {
const errors: FormErrors = {};
Object.keys(schema).forEach((field) => {
const fieldValidators = schema[field] || [];
const value = values[field] ?? "";
for (const validator of fieldValidators) {
const error = validator(value, values);
if (error) {
errors[field] = error;
break;
}
}
});
return errors;
}
export function hasFormErrors(errors: FormErrors) {
return Object.keys(errors).length > 0;
}
export const validators = {
required:
(message = "This field is required") =>
(value: string) =>
value?.trim() ? undefined : message,
email:
(message = "Please enter a valid email") =>
(value: string) =>
!value?.trim() || isEmail(value.trim()) ? undefined : message,
minLength: (length: number, message?: string) => (value: string) =>
!value || value.length >= length
? undefined
: message || `Must be at least ${length} characters`,
url:
(message = "Please enter a valid URL") =>
(value: string) =>
!value?.trim() || isURL(value.trim(), { allow_underscores: true })
? undefined
: message,
matchField:
(fieldName: string, message = "Values do not match") =>
(value: string, values: FormValues) =>
value === values[fieldName] ? undefined : message
};
interface FormInputProps extends TextInputProps {
name: string;
formRef: RefObject<FormRef>;
validators?: FieldValidator[];
fwdRef?: RefObject<TextInput | null>;
loading?: boolean;
error?: string;
customColor?: ColorValue;
marginBottom?: number;
marginRight?: number;
button?: {
icon: string;
color: ColorValue;
onPress: () => void;
testID?: string;
size?: number;
};
buttons?: React.ReactNode;
buttonLeft?: React.ReactNode;
height?: number;
fontSize?: number;
inputStyle?: TextInputProps["style"];
containerStyle?: ViewStyle;
wrapperStyle?: ViewStyle;
errorStyle?: TextStyle;
}
export function FormInput({
name,
formRef,
validators: fieldValidators = [],
fwdRef,
loading,
error,
secureTextEntry,
customColor,
marginBottom = 10,
marginRight,
button,
buttonLeft,
buttons,
height = 45,
fontSize = AppFontSize.sm,
inputStyle = {},
containerStyle = {},
wrapperStyle = {},
onFocus,
onBlur,
onPress,
onChangeText,
errorStyle,
...restProps
}: FormInputProps) {
const { colors, isDark } = useThemeColors();
const [focused, setFocused] = useState(false);
const [secureEntry, setSecureEntry] = useState(true);
const [, setVersion] = useState(0);
useEffect(() => {
const form = formRef.current;
form.registerField(name, fieldValidators);
const unsubscribe = form.subscribe(() => {
setVersion((v) => v + 1);
});
return () => {
unsubscribe();
form.unregisterField(name);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formRef, name]);
const value = formRef.current.getValue(name);
const fieldError = error || formRef.current.getError(name);
const borderColor = useMemo(() => {
if (fieldError) return colors.error.accent;
if (focused) return customColor || colors.selected.border;
return colors.primary.border;
}, [colors, customColor, fieldError, focused]);
const style: ViewStyle = {
borderWidth: 1,
borderRadius: defaultBorderRadius,
borderColor,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
...containerStyle
};
const textStyle: TextInputProps["style"] = {
paddingHorizontal: 0,
fontSize,
color:
onPress && loading ? colors.primary.accent : colors.primary.paragraph,
flexGrow: 1,
flexShrink: 1,
paddingBottom: DefaultAppStyles.GAP_VERTICAL,
paddingTop: DefaultAppStyles.GAP_VERTICAL,
fontFamily: "Inter-Regular",
...(inputStyle as ViewStyle)
};
const handleChangeText = (nextValue: string) => {
formRef.current.setValue(name, nextValue);
onChangeText?.(nextValue);
};
return (
<View
importantForAccessibility="yes"
style={{
marginBottom,
marginRight,
...wrapperStyle
}}
>
<TouchableOpacity
disabled={!loading}
onPress={onPress}
activeOpacity={1}
style={style}
>
{buttonLeft}
<TextInput
{...restProps}
defaultValue={value}
ref={fwdRef}
editable={!loading && restProps.editable !== false}
onChangeText={handleChangeText}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
keyboardAppearance={isDark ? "dark" : "light"}
style={textStyle}
secureTextEntry={secureTextEntry && secureEntry}
placeholderTextColor={colors.primary.placeholder}
/>
<View
style={{
flexDirection: "row",
justifyContent: "center",
height: 35 > height ? height : 35,
alignItems: "center"
}}
>
{secureTextEntry && (
<IconButton
name="eye"
size={20}
top={10}
bottom={10}
onPress={() => {
fwdRef?.current?.blur();
setSecureEntry(!secureEntry);
}}
style={{
width: 25,
marginLeft: 5
}}
color={
secureEntry ? colors.secondary.icon : colors.primary.accent
}
/>
)}
{buttons}
{button && (
<IconButton
testID={button.testID}
name={button.icon}
size={button.size || AppFontSize.xl}
top={10}
bottom={10}
onPress={button.onPress}
color={button.color}
style={{
marginRight: -8
}}
/>
)}
</View>
</TouchableOpacity>
{fieldError ? (
<Paragraph
size={AppFontSize.xs}
style={[{ marginTop: 5, color: colors.error.icon }, errorStyle]}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{fieldError}
</Paragraph>
) : null}
</View>
);
}
export default FormInput;

View File

@@ -121,8 +121,8 @@ const Input = ({
const color = error
? colors.error.border
: focus
? customColor || colors.selected.border
: colors.primary.border;
? customColor || colors.selected.border
: colors.primary.border;
const validate = async (value: string) => {
if (!validationType) return;

View File

@@ -370,7 +370,14 @@ export const useActions = ({
inputPlaceholder: strings.name(),
defaultValue: item.title,
positivePress: async (value) => {
if (!value || value.trim().length === 0) return;
if (!value || value.trim().length === 0) {
ToastManager.error(
new Error(strings.nameIsRequired()),
undefined,
"local"
);
return;
}
await db.colors.add({
id: item.id,
title: value

View File

@@ -97,6 +97,7 @@ import { BETA } from "../utils/constants";
import {
eAfterSync,
eCloseSheet,
eCloseSimpleDialog,
eEditorReset,
eLoginSessionExpired,
eOnLoadNote,
@@ -300,7 +301,10 @@ const onUserSubscriptionStatusChanged = async (
subscription: subscription
}
});
Walkthrough.present("prouser", false, true);
eSendEvent(eCloseSimpleDialog);
setTimeout(() => {
Walkthrough.present("prouser", false, true);
}, 500);
}
await PremiumService.setPremiumStatus();
useMessageStore.getState().setAnnouncement();

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Plan, SubscriptionPlan } from "@notesnook/core";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useAsync } from "react-async-hook";
import { Platform } from "react-native";
import Config from "react-native-config";
@@ -181,6 +181,8 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
const [cancelPromo, setCancelPromo] = useState(false);
const [userCanRequestTrial, setUserCanRequestTrial] = useState(false);
const [webPricingPlans, setWebPricingPlans] = useState<Plan[]>([]);
const plansRef = useRef(plans);
plansRef.current = plans;
const getProduct = React.useCallback(
(planId: string, skuId: string) => {
@@ -190,11 +192,11 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
);
return (
plans.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plans.find((p) => p.id === planId)?.products?.[skuId]
plansRef.current.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plansRef.current.find((p) => p.id === planId)?.products?.[skuId]
);
},
[plans, webPricingPlans]
[webPricingPlans]
);
const getProductAndroid = (planId: string, skuId: string) => {
@@ -253,35 +255,41 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
useEffect(() => {
const loadPlans = async () => {
const items = await PremiumService.loadProductsAndSubs();
pricingPlans.forEach((plan) => {
plan.subscriptions = {};
plan.products = {};
plan.subscriptionSkuList.forEach((sku) => {
if (!plan.subscriptions) plan.subscriptions = {};
plan.subscriptions[sku] = items.subs.find((p) => p.productId === sku);
try {
const items = await PremiumService.loadProductsAndSubs();
pricingPlans.forEach((plan) => {
plan.subscriptions = {};
plan.products = {};
plan.subscriptionSkuList.forEach((sku) => {
if (!plan.subscriptions) plan.subscriptions = {};
plan.subscriptions[sku] = items.subs.find(
(p) => p.productId === sku
);
});
plan.productSkuList.forEach((sku) => {
if (!plan.products) plan.products = {};
plan.products[sku] = items.products.find(
(p) => p.productId === sku
);
});
});
plan.productSkuList.forEach((sku) => {
if (!plan.products) plan.products = {};
plan.products[sku] = items.products.find((p) => p.productId === sku);
});
});
setPlans([...pricingPlans]);
setUserCanRequestTrial(hasTrialOffer());
if (
Config.GITHUB_RELEASE === "true" &&
!SettingsService.getProperty("serverUrls")
) {
try {
const products = WebPlanCache || (await db.pricing.products());
WebPlanCache = products;
setWebPricingPlans(products);
} catch (e) {
/**
setPlans([...pricingPlans]);
setUserCanRequestTrial(hasTrialOffer());
if (
Config.GITHUB_RELEASE === "true" &&
!SettingsService.getProperty("serverUrls")
) {
try {
const products = WebPlanCache || (await db.pricing.products());
WebPlanCache = products;
setWebPricingPlans(products);
} catch (e) {
/**
empty */
}
}
}
setLoadingPlans(false);
setLoadingPlans(false);
} catch (e) {}
};
loadPlans();
}, [options?.promoOffer, cancelPromo, hasTrialOffer]);

View File

@@ -35,7 +35,6 @@ import { db } from "../../common/database";
import { Dialog } from "../../components/dialog";
import { Header } from "../../components/header";
import { Button } from "../../components/ui/button";
import Input from "../../components/ui/input";
import { ReminderTime } from "../../components/ui/reminder-time";
import Paragraph from "../../components/ui/typography/paragraph";
import { DDS } from "../../services/device-detection";
@@ -59,6 +58,11 @@ import { TimeSince } from "../../components/ui/time-since";
import Heading from "../../components/ui/typography/heading";
import { eOnLoadNote } from "../../utils/events";
import { fluidTabsRef } from "../../utils/global-refs";
import FormInput, {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import AppIcon from "../../components/ui/AppIcon";
const ReminderModes =
Platform.OS === "ios"
@@ -113,7 +117,12 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
const [repeatFrequency, setRepeatFrequency] = useState(1);
const referencedItem = reference ? (reference as Note) : null;
const recurringReminderFeature = useIsFeatureAvailable("recurringReminders");
const formRef = useRef(
createFormRef({
title: reminder?.title || referencedItem?.title || "",
description: reminder?.description || referencedItem?.headline || ""
})
);
const title = useRef<string | undefined>(
!reminder ? referencedItem?.title : reminder?.title
);
@@ -132,6 +141,8 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
: null,
[reminder?.id]
);
const [dateError, setDateError] = useState<string>();
const [selectDayError, setSelectDayError] = useState<string>();
const showDatePicker = () => {
setDatePickerVisibility(true);
@@ -143,6 +154,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
const handleConfirm = (date: Date) => {
timer.current = setTimeout(() => {
setDateError(undefined);
hideDatePicker();
setDate(date);
}, 10);
@@ -172,26 +184,27 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
async function saveReminder() {
try {
if (!(await Notifications.checkAndRequestPermissions(true)))
throw new Error(strings.noNotificationPermission());
if (!date && reminderMode !== ReminderModes.Permanent) return;
if (!formRef.current.validate()) return;
if (date.getTime() < Date.now() && reminderMode === "once") {
setDateError(strings.dateError());
return;
}
if (
reminderMode === ReminderModes.Repeat &&
recurringMode !== "day" &&
recurringMode !== "year" &&
selectedDays.length === 0
)
throw new Error(strings.selectDayError());
if (!title.current) throw new Error(strings.setTitleError());
if (
date.getTime() < Date.now() &&
reminderMode === "once" &&
!props.route.params.reminder
) {
throw new Error(strings.dateError());
setSelectDayError(strings.selectDayError());
return;
}
if (!date && reminderMode !== ReminderModes.Permanent) return;
if (!(await Notifications.checkAndRequestPermissions(true)))
throw new Error(strings.noNotificationPermission());
date.setSeconds(0, 0);
const reminderId = await db.reminders?.add({
@@ -261,7 +274,10 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
>
<Input
<FormInput
name="title"
validators={[validators.required(strings.titleIsRequired())]}
formRef={formRef}
fwdRef={titleRef}
defaultValue={reminder?.title || referencedItem?.title}
placeholder={strings.remindeMeOf()}
@@ -270,12 +286,15 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
wrapperStyle={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
onSubmit={() => {
onSubmitEditing={() => {
descriptionRef.current?.focus();
}}
/>
<Input
<FormInput
name="description"
validators={[]}
formRef={formRef}
defaultValue={
reminder ? reminder?.description : referencedItem?.headline
}
@@ -461,6 +480,22 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
/>
))}
</ScrollView>
{selectDayError ? (
<Paragraph
size={AppFontSize.xs}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
color: colors.error.icon
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{selectDayError}
</Paragraph>
) : null}
</View>
) : null}
@@ -476,16 +511,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
<DateTimePickerModal
isVisible={isDatePickerVisible}
mode="datetime"
minimumDate={
reminderMode === "once" ? dayjs().toDate() : new Date(0)
}
onConfirm={handleConfirm}
onCancel={hideDatePicker}
isDarkModeEnabled={isDark}
firstDayOfWeek={weekFormat === "Mon" ? 1 : 0}
is24Hour={db.settings.getTimeFormat() === "24-hour"}
date={date || new Date(Date.now())}
themeVariant={isDark ? "dark" : "light"}
/>
<DatePicker
date={date}
minimumDate={
reminderMode === "once" ? dayjs().toDate() : new Date(0)
}
maximumDate={dayjs(date).add(3, "months").toDate()}
onDateChange={handleConfirm}
theme={isDark ? "dark" : "light"}
@@ -519,6 +561,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
}}
/>
)}
{dateError ? (
<Paragraph
size={AppFontSize.xs}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
color: colors.error.icon
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{dateError}
</Paragraph>
) : null}
</View>
)}

View File

@@ -21,6 +21,7 @@ import { sanitizeFilename, useIsFeatureAvailable } from "@notesnook/common";
import { strings } from "@notesnook/intl";
import { useThemeColors, VariantsWithStaticColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import isMobilePhone from "validator/lib/isMobilePhone";
import React, {
Dispatch,
SetStateAction,
@@ -28,7 +29,13 @@ import React, {
useRef,
useState
} from "react";
import { ActivityIndicator, Linking, Platform, View } from "react-native";
import {
ActivityIndicator,
Linking,
Platform,
TextInput,
View
} from "react-native";
import RNFetchBlob from "react-native-blob-util";
import { FlatList } from "react-native-gesture-handler";
import * as ScopedStorage from "react-native-scoped-storage";
@@ -37,7 +44,10 @@ import filesystem from "../../common/filesystem";
import DialogHeader from "../../components/dialog/dialog-header";
import { Button } from "../../components/ui/button";
import { IconButton } from "../../components/ui/icon-button";
import Input from "../../components/ui/input";
import FormInput, {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { Pressable } from "../../components/ui/pressable";
import Seperator from "../../components/ui/seperator";
import { SvgView } from "../../components/ui/svg";
@@ -183,27 +193,60 @@ export const MFASetup = ({
}: MFAStepProps) => {
const { colors } = useThemeColors();
const user = useUserStore((state) => state.user);
const methodId = method?.id;
const formRef = useRef(
createFormRef({
target: "",
code: ""
})
);
const targetInputRef = useRef<TextInput>(null);
const codeInputRef = useRef<TextInput>(null);
const [authenticatorDetails, setAuthenticatorDetails] = useState({
sharedKey: null,
authenticatorUri: null
});
const code = useRef<string>(undefined);
const phoneNumber = useRef<string>(undefined);
const { seconds, setId, start } = useTimer(method?.id);
const [loading, setLoading] = useState(method?.id === "app" ? true : false);
const [enabling, setEnabling] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(false);
const [generalError, setGeneralError] = useState<string>();
useEffect(() => {
if (method?.id === "app") {
db.mfa?.setup("app").then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
});
if (methodId === "app") {
setLoading(true);
db.mfa
?.setup("app")
.then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
})
.catch((error: Error) => {
setLoading(false);
setGeneralError(error.message);
});
return;
}
}, [method?.id]);
setLoading(false);
}, [methodId]);
useEffect(() => {
if (!methodId) return;
formRef.current.clearErrors();
formRef.current.setValue(
"target",
methodId === "email"
? user?.email || ""
: methodId === "app"
? authenticatorDetails.sharedKey || ""
: formRef.current.getValue("target")
);
formRef.current.setValue("code", "");
setGeneralError(undefined);
}, [authenticatorDetails.sharedKey, methodId, user?.email]);
const codeHelpText = {
app: "After putting the above code in authenticator app, the app will display a code that you can enter below.",
@@ -212,15 +255,43 @@ export const MFASetup = ({
"You will receive a 2FA code on your email address which you can enter below"
};
const targetValidators =
method?.id === "sms"
? [
validators.required(strings.phoneNumberNotEntered()),
(value: string) =>
isMobilePhone(value, "any", {
strictMode: true
})
? undefined
: strings.enterValidPhone()
]
: method?.id === "email"
? [
validators.required(strings.emailRequired()),
validators.email(strings.enterValidEmail())
]
: [];
const codeValidators = [
validators.required(strings.enterSixDigitCode()),
(value: string) =>
/^\d{6}$/.test(value.trim()) ? undefined : strings.enterSixDigitCode()
];
const onNext = async () => {
if (!code.current || code.current.length !== 6) return;
if (formRef.current.validateField("code")) return;
try {
if (!method) return;
const code = formRef.current.getValue("code").trim();
setGeneralError(undefined);
setEnabling(true);
if (recovery) {
await db.mfa.enableFallback(method.id, code.current);
await db.mfa.enableFallback(method.id, code);
} else {
await db.mfa.enable(method.id, code.current);
await db.mfa.enable(method.id, code);
}
const user = await db.user.fetchUser();
@@ -229,14 +300,18 @@ export const MFASetup = ({
setEnabling(false);
} catch (e) {
const error = e as Error;
ToastManager.error(error, "Error submitting 2fa code");
formRef.current.setError("code", error.message);
setEnabling(false);
}
};
const onSendCode = async () => {
if (error) return;
if (!method || sending) return;
if (method.id !== "app" && formRef.current.validateField("target")) {
return;
}
if (method.id === "app" && authenticatorDetails.sharedKey) {
Clipboard.setString(authenticatorDetails.sharedKey);
if (authenticatorDetails.authenticatorUri) {
@@ -254,30 +329,37 @@ export const MFASetup = ({
}
try {
if (seconds) throw new Error(strings.resendCodeWait());
if (method.id === "sms" && !phoneNumber.current)
throw new Error(strings.phoneNumberNotEntered());
const target = formRef.current.getValue("target").trim();
setGeneralError(undefined);
if (seconds) {
setGeneralError(strings.resendCodeWait());
return;
}
setSending(true);
await db.mfa.setup(method?.id, phoneNumber.current);
await db.mfa.setup(method.id, method.id === "sms" ? target : undefined);
if (method.id === "sms") {
setId(method.id + phoneNumber.current);
setId(method.id + target);
}
await sleep(300);
start(
60,
method.id === "sms" ? method.id + phoneNumber.current : method.id
);
start(60, method.id === "sms" ? method.id + target : method.id);
setSending(false);
ToastManager.show({
heading: strings["2faCodeSentVia"](method.id),
type: "success",
context: "local"
});
codeInputRef.current?.focus();
} catch (e) {
setSending(false);
const error = e as Error;
ToastManager.error(error, strings.errorSend2fa());
if (method.id === "sms" || method.id === "email") {
formRef.current.setError("target", error.message);
} else {
setGeneralError(error.message);
}
}
};
@@ -316,55 +398,54 @@ export const MFASetup = ({
</View>
) : (
<>
<Input
loading={method?.id !== "sms" ? true : false}
value={
method?.id === "email"
? user?.email
: method?.id === "app"
? authenticatorDetails?.sharedKey || ""
: undefined
<FormInput
key={`${method.id}-${authenticatorDetails.sharedKey || user?.email || ""}`}
name="target"
formRef={formRef}
fwdRef={targetInputRef}
loading={method?.id !== "sms"}
editable={method.id === "sms"}
defaultValue={
method.id === "email"
? user?.email || ""
: method.id === "app"
? authenticatorDetails.sharedKey || ""
: undefined
}
multiline={method.id === "app"}
onChangeText={(value) => {
phoneNumber.current = value;
onChangeText={() => {
setGeneralError(undefined);
}}
placeholder={
method?.id === "email"
method.id === "email"
? strings.enterEmailAddress()
: "+1234567890"
}
onSubmit={() => {
onSendCode();
}}
onErrorCheck={(e) => setError(e)}
validationType={method?.id === "email" ? "email" : "phonenumber"}
onSubmitEditing={onSendCode}
validators={targetValidators}
keyboardType={
method.id == "email" ? "email-address" : "phone-pad"
}
errorMessage={
method?.id === "email"
? strings.enterValidEmail()
: strings.enterValidPhone()
method.id === "email" ? "email-address" : "phone-pad"
}
buttons={
error ? null : (
<Button
onPress={onSendCode}
loading={sending}
title={
sending
? null
: method.id === "app"
<Button
onPress={onSendCode}
loading={sending}
style={{
paddingVertical: 0,
paddingHorizontal: 0
}}
title={
sending
? null
: method.id === "app"
? strings.copy()
: `${
seconds
? strings.resendCode(seconds as number)
: strings.sendCode()
}`
}
/>
)
}
/>
}
/>
@@ -373,13 +454,22 @@ export const MFASetup = ({
</Heading>
<Paragraph>{codeHelpText[method?.id]}</Paragraph>
<Seperator />
<Input
<FormInput
name="code"
formRef={formRef}
fwdRef={codeInputRef}
placeholder="xxxxxx"
maxLength={6}
loading={loading}
textAlign="center"
keyboardType="numeric"
onChangeText={(value) => (code.current = value)}
onChangeText={() => {
setGeneralError(undefined);
}}
onSubmitEditing={onNext}
returnKeyLabel={strings.next()}
returnKeyType="done"
validators={codeValidators}
inputStyle={{
fontSize: AppFontSize.lg,
height: 60,
@@ -392,7 +482,25 @@ export const MFASetup = ({
borderWidth: 0,
width: undefined
}}
errorStyle={{
textAlign: "center"
}}
/>
{generalError ? (
<Paragraph
size={AppFontSize.sm}
style={{
color: colors.error.icon,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
textAlign: "center",
width: "100%"
}}
>
{generalError}
</Paragraph>
) : null}
<Seperator />
<Button
title={enabling ? null : strings.next()}
@@ -517,7 +625,7 @@ export const MFARecoveryCodes = ({
ToastManager.show({
heading: strings.codesCopied(),
type: "success",
context: "global"
context: "local"
});
}}
style={{

View File

@@ -16,15 +16,20 @@ 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 <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { db } from "../../../common/database";
import { eSendEvent, ToastManager } from "../../../services/event-manager";
import { eUserLoggedIn } from "../../../utils/events";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
import Input from "../../../components/ui/input";
import { Button } from "../../../components/ui/button";
import FormInput, {
createFormRef,
validators
} from "../../../components/ui/input/form-input";
import { eSendEvent, ToastManager } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { eUserLoggedIn } from "../../../utils/events";
import { DefaultAppStyles } from "../../../utils/styles";
enum EmailChangeSteps {
verify,
@@ -32,14 +37,16 @@ enum EmailChangeSteps {
}
export const ChangeEmail = () => {
const { colors } = useThemeColors();
const [step, setStep] = useState(EmailChangeSteps.verify);
const emailChangeData = useRef<{
email?: string;
password?: string;
code?: string;
}>({});
const formRef = useRef(
createFormRef({
email: "",
password: "",
code: ""
})
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const emailInputRef = useRef<TextInput>(null);
const passInputRef = useRef<TextInput>(null);
const codeInputRef = useRef<TextInput>(null);
@@ -47,44 +54,52 @@ export const ChangeEmail = () => {
const onSubmit = async () => {
try {
if (step === EmailChangeSteps.verify) {
if (
!emailChangeData.current.email ||
!emailChangeData.current.password ||
error
)
return;
const hasEmailError = formRef.current.validateField("email");
const hasPasswordError = formRef.current.validateField("password");
if (hasEmailError || hasPasswordError) return;
const { email, password } = formRef.current.getValues();
setLoading(true);
const verified = await db.user?.verifyPassword(
emailChangeData.current.password
);
const verified = await db.user?.verifyPassword(password);
if (!verified) throw new Error(strings.passwordIncorrect());
await db.user?.sendVerificationEmail(emailChangeData.current.email);
await db.user?.sendVerificationEmail(email);
setStep(EmailChangeSteps.changeEmail);
formRef.current.clearErrors();
formRef.current.setValue("code", "");
setLoading(false);
} else {
if (
!emailChangeData.current.email ||
!emailChangeData.current.password ||
error ||
!emailChangeData.current.code
)
return;
await db.user?.changeEmail(
emailChangeData.current.email,
emailChangeData.current.password,
emailChangeData.current.code
);
const hasCodeError = formRef.current.validateField("code");
if (hasCodeError) return;
const { email, password, code } = formRef.current.getValues();
setLoading(true);
await db.user?.changeEmail(email, password, code);
eSendEvent(eUserLoggedIn);
close?.();
ToastManager.show({
heading: strings.emailUpdated(emailChangeData.current.email),
heading: strings.emailUpdated(email),
type: "success",
context: "global"
});
Navigation.goBack();
setLoading(false);
}
} catch (e) {
setLoading(false);
ToastManager.error(e as Error);
const error = e as Error;
if (step === EmailChangeSteps.verify) {
if (error.message === strings.passwordIncorrect()) {
formRef.current.setError("password", error.message);
} else {
formRef.current.setError("email", error.message);
}
return;
} else {
formRef.current.setError("code", error.message);
return;
}
}
};
@@ -97,34 +112,54 @@ export const ChangeEmail = () => {
>
{step === EmailChangeSteps.verify ? (
<>
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
placeholder={strings.enterNewEmail()}
validationType="email"
onErrorCheck={(e) => setError(e)}
onChangeText={(email) => {
emailChangeData.current.email = email;
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterValidEmail())
]}
onSubmitEditing={() => {
passInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passInputRef}
placeholder={strings.enterAccountPassword()}
secureTextEntry
onChangeText={(pass) => {
emailChangeData.current.password = pass;
}}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={onSubmit}
/>
</>
) : (
<>
<Input
key="code-input"
<FormInput
name="code"
formRef={formRef}
fwdRef={codeInputRef}
placeholder={strings.code()}
defaultValue=""
onChangeText={(code) => {
emailChangeData.current.code = code;
}}
keyboardType="number-pad"
autoCapitalize="none"
autoCorrect={false}
maxLength={6}
validators={[
validators.required(strings.enterSixDigitCode()),
(value: string) =>
/^\d{6}$/.test(value.trim())
? undefined
: strings.enterSixDigitCode()
]}
onSubmitEditing={onSubmit}
/>
</>
)}
@@ -135,8 +170,8 @@ export const ChangeEmail = () => {
loading
? undefined
: step === EmailChangeSteps.verify
? strings.verify()
: strings.changeEmail()
? strings.verify()
: strings.changeEmail()
}
type="accent"
style={{

View File

@@ -47,6 +47,14 @@ export async function verifyUser(
negativeText: closeText || strings.cancel(),
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const user = await db.user.getUser();
let verified = !user ? true : await db.user.verifyPassword(value);
if (verified) {
@@ -95,6 +103,14 @@ export async function verifyUserWithApplock() {
keyboardType: keyboardType,
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const verified = await validateAppLockPassword(value);
if (!verified) {
ToastManager.show({

View File

@@ -31,8 +31,8 @@ import dayjs from "dayjs";
import React from "react";
import { Appearance, Linking, Platform } from "react-native";
import { getVersion } from "react-native-device-info";
import { TextInput } from "react-native-gesture-handler";
import * as RNIap from "react-native-iap";
import ScreenGuardModule from "react-native-screenguard";
import { DatabaseLogger, db } from "../../common/database";
import filesystem from "../../common/filesystem";
import { presentDialog } from "../../components/dialog/functions";
@@ -42,43 +42,46 @@ import ExportNotesSheet from "../../components/sheets/export-notes";
import { Issue } from "../../components/sheets/github/issue";
import { Progress } from "../../components/sheets/progress";
import { Update } from "../../components/sheets/update";
import {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { VaultStatusType, useVaultStatus } from "../../hooks/use-vault-status";
import { BackgroundSync } from "../../services/background-sync";
import BackupService from "../../services/backup";
import BiometricService from "../../services/biometrics";
import {
ToastManager,
VaultRequestType,
eSendEvent,
eSubscribeEvent,
openVault,
presentSheet,
VaultRequestType
presentSheet
} from "../../services/event-manager";
import Navigation from "../../services/navigation";
import Notifications from "../../services/notifications";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import Sync from "../../services/sync";
import { clearAllStores } from "../../stores";
import { refreshAllStores } from "../../stores/create-db-collection-store";
import { useThemeStore } from "../../stores/use-theme-store";
import { useUserStore } from "../../stores/use-user-store";
import { EDITOR_LINE_HEIGHT } from "../../utils/constants";
import {
eAfterSync,
eCloseSheet,
eOpenRecoveryKeyDialog
} from "../../utils/events";
import { NotesnookModule } from "../../utils/notesnook-module";
import { sleep } from "../../utils/time";
import { resetTabStore } from "../editor/tiptap/use-tab-store";
import { MFARecoveryCodes, MFASheet } from "./2fa";
import { useDragState } from "./editor/state";
import { verifyUser, verifyUserWithApplock } from "./functions";
import { logoutUser } from "./logout";
import { SettingSection } from "./types";
import { getTimeLeft } from "./user-section";
import { EDITOR_LINE_HEIGHT } from "../../utils/constants";
import { MMKV } from "../../common/database/mmkv";
import { resetTabStore } from "../editor/tiptap/use-tab-store";
import { clearAllStores } from "../../stores";
import { refreshAllStores } from "../../stores/create-db-collection-store";
import { useSettingStore } from "../../stores/use-setting-store";
export const settingsGroups: SettingSection[] = [
@@ -235,18 +238,29 @@ export const settingsGroups: SettingSection[] = [
presentDialog({
title: strings.redeemGiftCode(),
paragraph: strings.redeemGiftCodeDesc(),
input: true,
inputPlaceholder: strings.code(),
positiveText: strings.redeem(),
positivePress: async (value) => {
db.subscriptions.redeemCode(value).catch((e) => {
ToastManager.show({
heading: "Error redeeming code",
message: (e as Error).message,
type: "error"
});
});
}
form: {
formRef: createFormRef({
code: ""
}),
items: [
{
name: "code",
placeholder: strings.code(),
ref: React.createRef<TextInput | null>(),
validators: [validators.required(strings.giftCodeRequired())]
}
],
onFormSubmit: async (form) => {
try {
await db.subscriptions.redeemCode(form.getValue("code"));
return true;
} catch (e) {
form.setError("code", (e as Error).message);
return false;
}
}
},
positiveText: strings.redeem()
});
}
},
@@ -516,6 +530,14 @@ export const settingsGroups: SettingSection[] = [
positiveText: strings.delete(),
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const verified = await db.user?.verifyPassword(value);
if (verified) {
setTimeout(async () => {

View File

@@ -29,6 +29,10 @@ import ImagePicker from "react-native-image-crop-picker";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../common/database";
import { presentDialog } from "../../components/dialog/functions";
import {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { PlanLimits } from "../../components/sheets/plan-limits";
import AppIcon from "../../components/ui/AppIcon";
import { Button } from "../../components/ui/button";
@@ -197,19 +201,34 @@ const SettingsUserSection = ({ item }) => {
title: strings.setFullName(),
paragraph: strings.setFullNameDesc(),
positiveText: strings.save(),
input: true,
inputPlaceholder: strings.enterFullName(),
defaultValue: userProfile?.fullName,
positivePress: async (value) => {
db.settings
.setProfile({
fullName: value
})
.then(async () => {
form: {
formRef: createFormRef({
fullName: userProfile?.fullName || ""
}),
items: [
{
name: "fullName",
placeholder: strings.enterFullName(),
defaultValue: userProfile?.fullName,
validators: [
validators.required(strings.nameIsRequired())
]
}
],
onFormSubmit: async (form) => {
try {
await db.settings.setProfile({
fullName: form.getValue("fullName")
});
useUserStore.setState({
profile: db.settings.getProfile()
});
});
return true;
} catch (e) {
form.setError("fullName", e.message);
return false;
}
}
}
});
}}

View File

@@ -26,7 +26,6 @@ import {
} from "../stores/use-setting-store";
import { NotesnookModule } from "../utils/notesnook-module";
import { scale, updateSize } from "../utils/size";
import { DatabaseLogger } from "../common/database";
import { useUserStore } from "../stores/use-user-store";
import ScreenGuardModule from "react-native-screenguard";
ScreenGuardModule.initSettings();
@@ -89,7 +88,6 @@ function migrateAppLock() {
biometricsAuthEnabled: true
});
}
DatabaseLogger.debug("App lock Migrated");
}
function migrateSettings(settings: SettingStore["settings"]) {

View File

@@ -65,6 +65,14 @@ export async function unlockVault({
paragraph: paragraph,
inputPlaceholder: strings.enterPassword(),
positivePress: async (value) => {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const unlocked = await db.vault.unlock(value);
if (!unlocked) {
ToastManager.show({

View File

@@ -2263,14 +2263,35 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- react-native-screenguard (1.0.0):
- react-native-screenguard (2.0.0-beta5):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Codegen
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SDWebImage (~> 5.11.1)
- SDWebImage (~> 5.11)
- SocketRocket
- Yoga
- react-native-share-extension (2.9.5):
- React
- react-native-sodium (1.6.8):
@@ -3125,8 +3146,6 @@ PODS:
- RNNotifee/NotifeeCore (= 7.4.12)
- RNNotifee/NotifeeCore (7.4.12):
- React-Core
- RNPrivacySnapshot (1.0.0):
- React-Core
- RNReanimated (4.2.0):
- boost
- DoubleConversion
@@ -3602,7 +3621,6 @@ DEPENDENCIES:
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
- RNKeychain (from `../node_modules/react-native-keychain`)
- "RNNotifee (from `../node_modules/@ammarahmed/notifee-react-native`)"
- RNPrivacySnapshot (from `../node_modules/react-native-privacy-snapshot`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
- RNSecureRandom (from `../node_modules/react-native-securerandom`)
@@ -3870,8 +3888,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-keychain"
RNNotifee:
:path: "../node_modules/@ammarahmed/notifee-react-native"
RNPrivacySnapshot:
:path: "../node_modules/react-native-privacy-snapshot"
RNReanimated:
:path: "../node_modules/react-native-reanimated"
RNScreens:
@@ -3974,7 +3990,7 @@ SPEC CHECKSUMS:
react-native-pdf: edc236298f13f1609e42d41e45b8b6ea88ed10f9
react-native-quick-sqlite: 1ed8d3db1e22a8604d006be69f06053382e93bb0
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
react-native-screenguard: 9fc3b4ad5b97783fc0832638fae0dce51272c661
react-native-screenguard: 975d4612dce0c348b19b08a48aa23e9b68584d98
react-native-share-extension: fdc6aaab51591a2d445df239c446aaa3a99658ec
react-native-sodium: 066f76e46c9be13e9260521e3fa994937c4cdab4
react-native-theme-switch-animation: 449d6db7a760f55740505e7403ae8061debc9a7e
@@ -4027,7 +4043,6 @@ SPEC CHECKSUMS:
RNImageCropPicker: 5fd4ceaead64d8c53c787e4e559004f97bc76df7
RNKeychain: ffd0513e676445c637410b47249460cbf56bc9cb
RNNotifee: dea82c9ec44684eeeac9da85deb5eeb8ebe5937f
RNPrivacySnapshot: ccad3a548338c2f526bb7b1789af3fb0618b7d1d
RNReanimated: f1868b36f4b2b52a0ed00062cfda69506f75eaee
RNScreens: ffbb0296608eb3560de641a711bbdb663ed1f6b4
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc

View File

@@ -231,15 +231,40 @@ export async function restoreBackupFile(backupFile: File) {
}
await db.initCollections();
} else {
const { createUnzipIterator } = await import(
"../utils/streams/unzip-stream"
);
let skipAttachments = false;
if (!useUserStore.getState().isLoggedIn) {
let hasAttachments = false;
for await (const entry of createUnzipIterator(backupFile)) {
if (
entry.name.startsWith("attachments/") &&
entry.name !== "attachments/.attachments_key"
) {
hasAttachments = true;
break;
}
}
if (hasAttachments) {
const result = await ConfirmDialog.show({
title: strings.loginToRestoreAttachments(),
message: strings.loginToRestoreAttachmentsDesc(),
positiveButtonText: strings.yes(),
negativeButtonText: strings.no()
});
if (!result) return;
skipAttachments = true;
}
}
const error = await TaskManager.startTask<Error | void>({
title: strings.restoringBackup(),
subtitle: strings.restoringBackupDesc(),
type: "modal",
action: async (report) => {
const { createUnzipIterator } = await import(
"../utils/streams/unzip-stream"
);
let cachedPassword: string | undefined = undefined;
let cachedKey: string | undefined = undefined;
// const { read, totalFiles } = await Reader(backupFile);
@@ -254,13 +279,13 @@ export async function restoreBackupFile(backupFile: File) {
isValid = true;
continue;
}
if (entry.name === "attachments/.attachments_key")
if (!skipAttachments && entry.name === "attachments/.attachments_key")
attachmentsKey = JSON.parse(await entry.text()) as
| SerializedKey
| Cipher<"base64">;
else if (entry.name.startsWith("attachments/"))
else if (!skipAttachments && entry.name.startsWith("attachments/"))
attachments.push(entry);
else entries.push(entry);
else if (!entry.name.startsWith("attachments/")) entries.push(entry);
}
if (!isValid)
console.warn(
@@ -339,6 +364,8 @@ export async function restoreBackupFile(backupFile: File) {
if (error) {
console.error(error);
showToast("error", `${strings.restoreFailed()}: ${error.message}`);
} else {
showToast("success", strings.backupRestored());
}
}
}

View File

@@ -93,6 +93,7 @@ export function DayPicker(props: DayPickerProps) {
key={month.month + year}
value={month.month}
data-date={month.$date.toDateString()}
disabled={month.disabled}
>
{month.month}
</option>
@@ -121,6 +122,7 @@ export function DayPicker(props: DayPickerProps) {
key={year.year}
value={year.year}
data-date={year.$date.toDateString()}
disabled={year.disabled}
>
{year.year}
</option>

View File

@@ -105,6 +105,7 @@ import ListItem from "../list-item";
import { PublishDialog } from "../publish-view";
import TimeAgo from "../time-ago";
import { NoteExpiryDateDialog } from "../../dialogs/note-expiry-date-dialog";
import { withFeatureCheck } from "../../common";
type NoteProps = NoteResolvedData & {
item: NoteType;
@@ -666,11 +667,11 @@ export const noteMenuItems: (
title: strings.setExpiry(),
icon: Destruct.path,
premium: !features.expiringNotes.isAllowed,
onClick: async () => {
onClick: withFeatureCheck(features.expiringNotes, async () => {
await NoteExpiryDateDialog.show({
noteId: note.id
});
}
})
},
{
type: "button",

View File

@@ -44,7 +44,7 @@ export const AddNotebookDialog = DialogManager.register(
const onSubmit = useCallback(async () => {
if (!title.current.trim())
return showToast("error", strings.allFieldsRequired());
return showToast("error", strings.titleIsRequired());
const id = await db.notebooks.add({
id: props.notebook?.id,

View File

@@ -47,6 +47,7 @@ import { FlexScrollContainer } from "../../components/scroll-container";
import { useCallback, useEffect, useRef, useState } from "react";
import {
DropdownSettingComponent,
Section,
SectionGroup,
SectionKeys,
Setting,
@@ -254,9 +255,15 @@ export const SettingsDialog = DialogManager.register(function SettingsDialog(
overflow: "auto"
}}
>
{activeSettings.map((group) => (
<SettingsGroupComponent item={group} />
))}
{activeSettings.length > 0 ? (
activeSettings.map((group) => (
<SettingsGroupComponent item={group} />
))
) : (
<Text variant="body" sx={{ color: "paragraph-secondary" }}>
{strings.noResultsFound()}
</Text>
)}
</FlexScrollContainer>
</Flex>
</Dialog>
@@ -317,12 +324,17 @@ function SettingsSideBar(props: SettingsSideBarProps) {
SettingsGroups.filter((g) => g.section === route)
);
const groups: SettingsGroup[] = [];
let groups: SettingsGroup[] = [];
for (const group of SettingsGroups) {
const section = findSection(group.section);
if (section?.isHidden?.() || group.isHidden?.()) continue;
const isTitleMatch =
typeof group.header === "string" &&
group.header.toLowerCase().includes(query);
const isSectionMatch = group.section.includes(query);
const isSectionMatch = group.section
.toLowerCase()
.includes(query);
if (isTitleMatch || isSectionMatch) {
groups.push(group);
@@ -347,6 +359,18 @@ function SettingsSideBar(props: SettingsSideBarProps) {
if (!settings.length) continue;
groups.push({ ...group, settings });
}
const matchedSections = findSections(query);
if (matchedSections.length > 0) {
const matchedGroups = SettingsGroups.filter((g) =>
matchedSections.some((s) => s.key === g.section)
);
// remove groups whose sections were matched to avoid duplicate
// entries.
groups = groups.filter(
(g) => !matchedGroups.some((mg) => mg.section === g.section)
);
groups.push(...matchedGroups);
}
onNavigate(groups);
}}
/>
@@ -717,3 +741,22 @@ function NumberInput({
</Flex>
);
}
function findSection(key: SectionKeys) {
for (const group of sectionGroups) {
const section = group.sections.find((s) => s.key === key);
if (section) return section;
}
return null;
}
function findSections(query: string) {
const sections: Section[] = [];
for (const group of sectionGroups) {
for (const section of group.sections) {
if (section.isHidden?.()) continue;
if (section.title.toLowerCase().includes(query)) sections.push(section);
}
}
return sections;
}

View File

@@ -49,6 +49,7 @@ import {
} from "@notesnook/core";
import { logger } from "../utils/logger";
import { newQueue } from "@henrygd/queue";
import { strings } from "@notesnook/intl";
export const ABYTES = 17;
const CHUNK_SIZE = 512 * 1024;
@@ -499,12 +500,20 @@ async function downloadFile(
{ type: "download", hash: filename }
);
const signedUrl = (
await axios.get(url, {
headers,
responseType: "text"
})
).data;
const signedUrlResponse = await axios
.get(url, { headers, responseType: "text" })
.catch((e) => {
if (e.response?.status === 401) {
showToast("error", strings.pleaseLoginToDownloadAttachments());
return null;
}
throw e;
});
if (!signedUrlResponse) {
reportProgress(undefined, { type: "download", hash: filename });
return false;
}
const signedUrl = signedUrlResponse.data;
logger.debug("Got attachment signed url", { filename });

View File

@@ -0,0 +1,8 @@
---
title: Login to restore attachments in backup.
description: We require users to be logged in to restore attachments in backup.
---
# Login to restore attachments in backup.
We require users to be logged in to restore attachments in backup. This is because attachments are encrypted using a sub-key derived from your database encryption key. Without a login, we cannot encrypt/upload/sync attachments.

View File

@@ -91,3 +91,4 @@ navigation:
- path: faqs/what-are-merge-conflicts.md
- path: faqs/is-there-an-eta.md
- path: faqs/login-to-upload-attachments.md
- path: faqs/login-to-restore-attachments-in-backup.md

View File

@@ -0,0 +1,3 @@
- Bug fixes and improvements
Thank you for using Notesnook!

View File

@@ -564,18 +564,24 @@ class UserManager {
const email = newEmail.toLowerCase();
await http.patch(
`${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
{
type: "change_email",
new_email: newEmail,
password: await this.db.storage().hash(password, email, {
usesFallback: await this.usesFallbackPWHash(password)
}),
verification_code: code
},
token
);
try {
await http.patch(
`${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
{
type: "change_email",
new_email: newEmail,
password: await this.db.storage().hash(password, email, {
usesFallback: await this.usesFallbackPWHash(password)
}),
verification_code: code
},
token
);
} catch (e) {
const error = e as Error;
if (error.message === "Invalid token.") throw new Error("Invalid code.");
throw error;
}
}
recoverAccount(email: string) {

View File

@@ -1,6 +1,7 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-04-20 11:33+0500\n"
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -975,6 +976,10 @@ msgstr "Are you sure you want to clear all logs from {key}?"
msgid "Are you sure you want to clear trash?"
msgstr "Are you sure you want to clear trash?"
#: src/strings.ts:2734
msgid "Are you sure you want to delete this attachment?"
msgstr "Are you sure you want to delete this attachment?"
#: src/strings.ts:1544
msgid "Are you sure you want to logout and clear all data stored on THIS DEVICE?"
msgstr "Are you sure you want to logout and clear all data stored on THIS DEVICE?"
@@ -1032,6 +1037,10 @@ msgstr "attachment"
msgid "Attachment"
msgstr "Attachment"
#: src/strings.ts:2735
msgid "Attachment deleted"
msgstr "Attachment deleted"
#: src/strings.ts:2460
msgid "Attachment manager"
msgstr "Attachment manager"
@@ -1830,6 +1839,10 @@ msgstr "Confirm new password"
msgid "Confirm password"
msgstr "Confirm password"
#: src/strings.ts:2729
msgid "Confirm password required"
msgstr "Confirm password required"
#: src/strings.ts:1489
msgid "Confirm pin"
msgstr "Confirm pin"
@@ -2040,10 +2053,15 @@ msgstr "Curate the toolbar that fits your needs and matches your personality."
msgid "Current note"
msgstr "Current note"
#: src/strings.ts:1481
#: src/strings.ts:1487
msgid "Current password"
msgstr "Current password"
#: src/strings.ts:2738
msgid "Current password required"
msgstr "Current password required"
#: src/strings.ts:1231
msgid "Current path: {path}"
msgstr "Current path: {path}"
@@ -2222,6 +2240,10 @@ msgstr "Delete"
msgid "Delete account"
msgstr "Delete account"
#: src/strings.ts:2732
msgid "Delete attachment"
msgstr "Delete attachment"
#: src/strings.ts:1321
msgid "Delete collapsed section"
msgstr "Delete collapsed section"
@@ -3230,6 +3252,10 @@ msgstr "Getting information"
msgid "Getting recovery codes"
msgstr "Getting recovery codes"
#: src/strings.ts:2731
msgid "Gift code required"
msgstr "Gift code required"
#: src/strings.ts:2174
msgid "GNU GENERAL PUBLIC LICENSE Version 3"
msgstr "GNU GENERAL PUBLIC LICENSE Version 3"
@@ -3937,6 +3963,10 @@ msgstr "Login failed"
msgid "Login required"
msgstr "Login required"
#: src/strings.ts:2739
msgid "Login required to restore attachments"
msgstr "Login required to restore attachments"
#: src/strings.ts:779
msgid "Login successful"
msgstr "Login successful"
@@ -4187,6 +4217,10 @@ msgstr "Multi-layer encryption to most important notes"
msgid "Name"
msgstr "Name"
#: src/strings.ts:2737
msgid "Name is required."
msgstr "Name is required."
#: src/strings.ts:1690
msgid "Native high-performance encryption"
msgstr "Native high-performance encryption"
@@ -4568,10 +4602,6 @@ msgstr "Okay"
msgid "Old - new"
msgstr "Old - new"
#: src/strings.ts:1481
msgid "Old password"
msgstr "Old password"
#: src/strings.ts:1837
msgid "Older version"
msgstr "Older version"
@@ -4725,6 +4755,10 @@ msgstr "Password not entered"
msgid "Password protection"
msgstr "Password protection"
#: src/strings.ts:2728
msgid "Password required"
msgstr "Password required"
#: src/strings.ts:809
msgid "Password updated"
msgstr "Password updated"
@@ -4836,6 +4870,7 @@ msgid "Please enter a key name"
msgstr "Please enter a key name"
#: src/strings.ts:1514
#: src/strings.ts:2730
msgid "Please enter a valid email address"
msgstr "Please enter a valid email address"
@@ -4899,6 +4934,10 @@ msgstr "Please fill all the fields to continue."
msgid "Please grant notifications permission to add new reminders."
msgstr "Please grant notifications permission to add new reminders."
#: src/strings.ts:2745
msgid "Please login to download attachments."
msgstr "Please login to download attachments."
#: src/strings.ts:873
msgid "Please make sure you have saved the recovery key. Tap one more time to confirm."
msgstr "Please make sure you have saved the recovery key. Tap one more time to confirm."
@@ -6856,6 +6895,10 @@ msgstr "Title"
msgid "Title format"
msgstr "Title format"
#: src/strings.ts:2736
msgid "Title is required"
msgstr "Title is required"
#: src/strings.ts:1190
msgid "To use app lock, you must enable biometrics such as Fingerprint lock or Face ID on your phone."
msgstr "To use app lock, you must enable biometrics such as Fingerprint lock or Face ID on your phone."
@@ -7681,6 +7724,16 @@ msgstr "You have unsynced notes. Take a backup or sync your notes to avoid losin
msgid "You must log out in order to change/reset server URLs."
msgstr "You must log out in order to change/reset server URLs."
#: src/strings.ts:2741
msgid ""
"You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).\n"
" \n"
"Continue without attachments?"
msgstr ""
"You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).\n"
" \n"
"Continue without attachments?"
#: src/strings.ts:836
msgid "You simply cannot get any better of a note taking app than @notesnook. The UI is clean and slick, it is feature rich, encrypted, reasonably priced (esp. for students & educators) & open source"
msgstr "You simply cannot get any better of a note taking app than @notesnook. The UI is clean and slick, it is feature rich, encrypted, reasonably priced (esp. for students & educators) & open source"

View File

@@ -1,6 +1,7 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-04-20 11:33+0500\n"
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
"POT-Creation-Date: 2026-05-11 12:10+0500\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
@@ -973,6 +974,10 @@ msgstr ""
#: src/strings.ts:1326
msgid "Are you sure you want to clear trash?"
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2734
msgid "Are you sure you want to delete this attachment?"
msgstr ""
#: src/strings.ts:1544
@@ -1030,6 +1035,10 @@ msgstr ""
#: src/strings.ts:300
#: src/strings.ts:2356
msgid "Attachment"
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2735
msgid "Attachment deleted"
msgstr ""
#: src/strings.ts:2460
@@ -1817,6 +1826,10 @@ msgstr ""
#: src/strings.ts:1485
msgid "Confirm password"
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2729
msgid "Confirm password required"
msgstr ""
#: src/strings.ts:1489
@@ -2027,12 +2040,17 @@ msgstr ""
#: src/strings.ts:1838
msgid "Current note"
msgstr ""
msgstr "<<<<<<< HEAD"
#: src/strings.ts:1481
#: src/strings.ts:1487
msgid "Current password"
msgstr ""
#: src/strings.ts:2738
msgid "Current password required"
msgstr ""
#: src/strings.ts:1231
msgid "Current path: {path}"
msgstr ""
@@ -2209,6 +2227,10 @@ msgstr ""
#: src/strings.ts:1078
msgid "Delete account"
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2732
msgid "Delete attachment"
msgstr ""
#: src/strings.ts:1321
@@ -3210,7 +3232,11 @@ msgstr ""
#: src/strings.ts:400
msgid "Getting recovery codes"
msgstr "<<<<<<< HEAD"
msgstr "<<<<<<< HEAD<<<<<<< HEAD"
#: src/strings.ts:2731
msgid "Gift code required"
msgstr ""
#: src/strings.ts:2174
msgid "GNU GENERAL PUBLIC LICENSE Version 3"
@@ -3917,6 +3943,10 @@ msgstr ""
msgid "Login required"
msgstr ""
#: src/strings.ts:2739
msgid "Login required to restore attachments"
msgstr ""
#: src/strings.ts:779
msgid "Login successful"
msgstr ""
@@ -4165,6 +4195,10 @@ msgstr ""
#: src/strings.ts:887
msgid "Name"
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2737
msgid "Name is required."
msgstr ""
#: src/strings.ts:1690
@@ -4540,11 +4574,7 @@ msgstr ""
#: src/strings.ts:641
msgid "Old - new"
msgstr ""
#: src/strings.ts:1481
msgid "Old password"
msgstr ""
msgstr "<<<<<<< HEAD"
#: src/strings.ts:1837
msgid "Older version"
@@ -4699,6 +4729,10 @@ msgstr ""
msgid "Password protection"
msgstr ""
#: src/strings.ts:2728
msgid "Password required"
msgstr ""
#: src/strings.ts:809
msgid "Password updated"
msgstr "<<<<<<< HEAD"
@@ -4807,9 +4841,10 @@ msgstr "<<<<<<< HEAD"
#: src/strings.ts:2674
msgid "Please enter a key name"
msgstr ""
msgstr "<<<<<<< HEAD"
#: src/strings.ts:1514
#: src/strings.ts:2730
msgid "Please enter a valid email address"
msgstr ""
@@ -4873,6 +4908,10 @@ msgstr ""
msgid "Please grant notifications permission to add new reminders."
msgstr ""
#: src/strings.ts:2745
msgid "Please login to download attachments."
msgstr ""
#: src/strings.ts:873
msgid "Please make sure you have saved the recovery key. Tap one more time to confirm."
msgstr ""
@@ -6813,6 +6852,10 @@ msgstr ""
#: src/strings.ts:1159
msgid "Title format"
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2736
msgid "Title is required"
msgstr ""
#: src/strings.ts:1190
@@ -7623,6 +7666,13 @@ msgstr ""
msgid "You must log out in order to change/reset server URLs."
msgstr ""
#: src/strings.ts:2741
msgid ""
"You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).\n"
" \n"
"Continue without attachments?"
msgstr ""
#: src/strings.ts:836
msgid "You simply cannot get any better of a note taking app than @notesnook. The UI is clean and slick, it is feature rich, encrypted, reasonably priced (esp. for students & educators) & open source"
msgstr ""
@@ -7826,7 +7876,7 @@ msgstr "<<<<<<< HEAD"
#: src/strings.ts:2055
msgid "Your support request has been forwarded"
msgstr ""
msgstr "<<<<<<< HEAD"
#: src/strings.ts:2064
msgid "Your support request has been forwarded to our support team. We will get back to you via email as soon as possible. If you don't receive an email from us within 24-48 hours, please send us an email directly at support@notesnook.com."

View File

@@ -1478,7 +1478,7 @@ $day$: Current day (eg. Monday)`,
someNotesPublished: () => t`Some notes are published`,
unpublishToDelete: () => t`Unpublish notes to delete them`,
filterAttachments: () => t`Filter attachments by filename, type or hash`,
oldPassword: () => t`Old password`,
oldPassword: () => t`Current password`,
newPassword: () => t`New password`,
email: () => t`Email`,
emailInvalid: () => t`Invalid email`,
@@ -2724,5 +2724,23 @@ Use this if changes from other devices are not appearing on this device. This wi
valueMustBeBetween: (min: number, max: number) =>
t`Value must be between ${min} and ${max}`,
pgpPrivateKeyProtected: () =>
t`Private key is passphrase-protected. Please provide the decrypted key or a key without a passphrase.`
t`Private key is passphrase-protected. Please provide the decrypted key or a key without a passphrase.`,
passwordRequired: () => t`Password required`,
confirmPasswordRequired: () => t`Confirm password required`,
enterAValidEmailAddress: () => t`Please enter a valid email address`,
giftCodeRequired: () => t`Gift code required`,
deleteAttachment: () => t`Delete attachment`,
deleteAttachmentConfirm: () =>
t`Are you sure you want to delete this attachment?`,
attachmentDeleted: () => t`Attachment deleted`,
titleIsRequired: () => t`Title is required`,
nameIsRequired: () => t`Name is required.`,
currentPasswordRequired: () => t`Current password required`,
loginToRestoreAttachments: () => t`Login required to restore attachments`,
loginToRestoreAttachmentsDesc: () =>
t`You need to login to restore attachments from a backup file. [Read more](https://help.notesnook.com/faqs/login-to-restore-attachments-in-backup).
Continue without attachments?`,
pleaseLoginToDownloadAttachments: () =>
t`Please login to download attachments.`
};