mobile: settings module ui fixes

This commit is contained in:
Ammar Ahmed
2026-07-16 16:25:28 +05:00
committed by Abdullah Atta
parent 7fb4a6fa40
commit 6f7a55c316
34 changed files with 510 additions and 279 deletions

View File

@@ -21,7 +21,10 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { View } from "react-native";
import { TextInput } from "react-native-gesture-handler";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { Spacing } from "../../common/design/spacing";
import BackupService from "../../services/backup";
import { ToastManager } from "../../services/event-manager";
import Navigation from "../../services/navigation";
@@ -29,14 +32,12 @@ import { useUserStore } from "../../stores/use-user-store";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
import RecoveryKeySheet from "../sheets/recovery-key";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
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";
import { Spacing } from "../../common/design/spacing";
import RecoveryKeySheet from "../sheets/recovery-key";
export const ChangePassword = () => {
const { colors } = useThemeColors();
@@ -107,13 +108,17 @@ export const ChangePassword = () => {
};
return (
<View
<KeyboardAwareScrollView
style={{
width: "100%",
width: "100%"
}}
contentContainerStyle={{
paddingTop: Spacing.LEVEL_4,
paddingHorizontal: Spacing.LEVEL_3,
gap: Spacing.LEVEL_2
}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
<Dialog context="change-password-dialog" />
<FormInput
@@ -227,6 +232,6 @@ export const ChangePassword = () => {
/>
<Paragraph>{strings.yourSecurityIsPriority()}</Paragraph>
</View>
</View>
</KeyboardAwareScrollView>
);
};

View File

@@ -60,7 +60,7 @@ export type DialogInfo = {
ref: RefObject<TextInput | null>;
inputProps?: TextInputProps;
}[];
onFormSubmit?: (form: FormRef) => Promise<boolean>;
onFormSubmit?: (form: FormRef, checked?: boolean) => Promise<boolean>;
};
input: boolean;
inputLabel?: string;

View File

@@ -74,7 +74,10 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
}
if (dialogInfo.form.onFormSubmit) {
setLoading(true);
const result = await dialogInfo.form.onFormSubmit(formRef.current);
const result = await dialogInfo.form.onFormSubmit(
formRef.current,
checked
);
if (result === false) {
setLoading(false);
return;

View File

@@ -188,7 +188,6 @@ export const VaultDialog: React.FC = () => {
}
await db.vault.delete(deleteAll);
await BiometricService.resetCredentials();
if (deleteAll) {
noteIds.forEach((id) => {
eSendEvent(
@@ -753,9 +752,13 @@ export const VaultDialog: React.FC = () => {
name="password"
formRef={formRef}
label={
isChangePassword
? strings.currentPassword()
: strings.password()
isDeleteVault
? strings.enterAccountPassword()
: isClearVault
? strings.enterVaultPassword()
: isChangePassword
? strings.currentPassword()
: strings.password()
}
fwdRef={passInputRef}
editable={!loading}
@@ -809,11 +812,17 @@ export const VaultDialog: React.FC = () => {
width="100%"
style={{
justifyContent: "flex-start",
paddingHorizontal: 0
paddingHorizontal: 0,
paddingVertical: 0,
marginTop: Spacing.LEVEL_1
}}
title={strings.deleteAllNotes()}
type="transparent"
iconColor={colors.error.accent}
iconColor={
deleteAll
? [colors.error.accent, colors.error.accentForeground]
: colors.error.accent
}
textStyle={{
color: colors.error.accent
}}

View File

@@ -19,7 +19,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, { useState } from "react";
import React, { useRef, useState } from "react";
import { ActivityIndicator, Linking, Platform, View } from "react-native";
import FileViewer from "react-native-file-viewer";
import Share from "react-native-share";
@@ -65,11 +65,14 @@ const ExportNotesSheet = ({
| undefined
>();
const [status, setStatus] = useState<string>();
const abortController = useRef<AbortController | undefined>(undefined);
const exportNoteAs = async (
type: "pdf" | "txt" | "md" | "html" | "md-frontmatter"
) => {
if (exporting) return;
const controller = new AbortController();
abortController.current = controller;
setExporting(true);
update?.({ disableClosing: true } as PresentSheetOptions);
setComplete(false);
@@ -78,7 +81,8 @@ const ExportNotesSheet = ({
result = await Exporter.bulkExport(
db.notes.all.where((eb) => eb("id", "in", ids)),
type,
setStatus
setStatus,
controller.signal
);
} else {
const note = await db.notes.note(ids[0]);
@@ -86,10 +90,15 @@ const ExportNotesSheet = ({
setExporting(false);
return;
}
result = await Exporter.exportNote(note, type, setStatus);
result = await Exporter.exportNote(
note,
type,
setStatus,
controller.signal
);
await sleep(1000);
}
if (!result) {
if (!result || controller.signal.aborted) {
update?.({ disableClosing: false } as PresentSheetOptions);
return setExporting(false);
}
@@ -100,6 +109,13 @@ const ExportNotesSheet = ({
requestInAppReview();
};
const cancelExport = () => {
abortController.current?.abort();
update?.({ disableClosing: false } as PresentSheetOptions);
setExporting(false);
setStatus(undefined);
};
const actions = [
{
title: "PDF",
@@ -276,6 +292,12 @@ const ExportNotesSheet = ({
" " +
strings.pleaseWait()}
</Paragraph>
<Button
title={strings.cancel()}
type="secondary-simple"
width={undefined}
onPress={cancelExport}
/>
</>
) : (
<>

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, { useState } from "react";
import { View } from "react-native";
import { Linking, View } from "react-native";
import { Radius, Spacing } from "../../../common/design/spacing";
import { Button } from "../../ui/button";
import AppIcon from "../../ui/AppIcon";
@@ -142,6 +142,9 @@ function ForceSync({ mode, close }: ForceSyncProps) {
size={AppFontSize.sm}
fontFamily="SEMI_BOLD"
color={colors.primary.accent}
onPress={() => {
Linking.openURL("mailto:support@streetwriters.co");
}}
>
support@streetwriters.co
</Paragraph>
@@ -164,7 +167,7 @@ function ForceSync({ mode, close }: ForceSyncProps) {
>
<AppIcon
size={16}
name={acknowledged ? "checkbox" : "checkbox-blank-outline"}
name={acknowledged ? "checkbox" : "box-empty"}
iconFamily="notesnook"
color={
acknowledged

View File

@@ -193,7 +193,8 @@ export const Toast = ({ context = "global" }) => {
style={{
maxWidth: "100%",
flexDirection: "row",
alignItems: "flex-start",
alignItems:
!description && !toastOptions.func ? "center" : "flex-start",
gap: Spacing.LEVEL_1,
padding: Spacing.LEVEL_1,
borderRadius: Radius.S,
@@ -212,9 +213,6 @@ export const Toast = ({ context = "global" }) => {
iconFamily={toastOptions.icon ? "material" : variant.icon.family}
size={16}
color={variant.accent}
style={{
marginTop: description ? 1 : 0
}}
/>
<View
@@ -227,7 +225,7 @@ export const Toast = ({ context = "global" }) => {
{title ? (
<Heading
fontSize="XS"
lineHeight="100%"
lineHeight={null}
color={colors.primary.heading}
>
{title}

View File

@@ -92,9 +92,6 @@ export const Checkbox = ({
name={checked ? "checkbox" : "box-empty"}
iconFamily="notesnook"
size={iconSize}
style={{
marginTop: 1.5
}}
color={
iconColor ||
(checked

View File

@@ -198,6 +198,11 @@ export const validators = {
? undefined
: message || `Must be at least ${length} characters`,
number:
(message = "Please enter a valid number") =>
(value: string) =>
!value?.trim() || !Number.isNaN(Number(value)) ? undefined : message,
url:
(message = "Please enter a valid URL") =>
(value: string) =>

View File

@@ -43,6 +43,7 @@ export const useVaultStatus = () => {
db.vault?.exists().then(async (exists) => {
const available = await BiometricService.isBiometryAvailable();
const fingerprint = await BiometricService.hasInternetCredentials();
console.log("VAULT EXISTS?", exists);
setVaultStatus({
exists: exists,
biometryEnrolled: fingerprint,

View File

@@ -275,6 +275,10 @@ const Partner = ({
}}
onPress={() => {
Clipboard.setString(code);
ToastManager.show({
message: strings.codeCopied(),
type: "success"
});
}}
/>
</View>

View File

@@ -106,15 +106,24 @@ export function SettingsPicker<T>({
type={
compareValue(currentValue, item)
? "shade-plain"
: "secondary-outline"
: "plain-outline"
}
onPress={() => {
onChange(item);
}}
textStyle={{
color: compareValue(currentValue, item)
? colors.primary.heading
: colors.primary.paragraph
}}
style={{
paddingVertical: Spacing.LEVEL_1,
paddingHorizontal: Spacing.LEVEL_1,
borderRadius: Radius.XS
borderRadius: Radius.XS,
borderWidth: 1,
borderColor: compareValue(currentValue, item)
? "transparent"
: colors.primary.border
}}
/>
))}

View File

@@ -66,7 +66,7 @@ export const FontPicker = createSettingsPicker<
},
getItemKey: (item) => item.id,
options: getFonts(),
compareValue: (current, item) => current === item.id,
compareValue: (current: any, item) => current.id === item.id,
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});

View File

@@ -514,8 +514,8 @@ function ThemeSelector() {
textStyle={{
color:
colorScheme === "all"
? colors.secondary.paragraph
: colors.primary.paragraph
? colors.primary.heading
: colors.secondary.paragraph
}}
title={strings.all()}
fontSize={AppFontSize.sm}
@@ -533,8 +533,8 @@ function ThemeSelector() {
textStyle={{
color:
colorScheme === "dark"
? colors.secondary.paragraph
: colors.primary.paragraph
? colors.primary.heading
: colors.secondary.paragraph
}}
title={strings.dark()}
fontSize={AppFontSize.sm}
@@ -553,8 +553,8 @@ function ThemeSelector() {
textStyle={{
color:
colorScheme === "light"
? colors.secondary.paragraph
: colors.primary.paragraph
? colors.primary.heading
: colors.secondary.paragraph
}}
title={strings.light()}
onPress={() => {

View File

@@ -63,6 +63,7 @@ export const aboutGroup: SettingSection = {
icon: "device-mobile-camera",
iconFamily: "notesnook",
description: strings.checkForUpdatesDesc(),
isModal: true,
modifer: async () => {
presentSheet({
//@ts-ignore // Migrate to ts

View File

@@ -45,6 +45,7 @@ export const accountLocalGroup: SettingSection = {
icon: "trash",
iconFamily: "notesnook",
description: strings.deleteAccountDesc(),
isModal: true,
modifer: () => {
presentDialog({
title: strings.deleteData(),

View File

@@ -168,6 +168,7 @@ export const accountGroup: SettingSection = {
),
icon: "gift",
iconFamily: "notesnook",
isModal: true,
modifer: () => {
presentDialog({
title: strings.redeemGiftCode(),
@@ -232,6 +233,7 @@ export const accountGroup: SettingSection = {
useUserStore((state) => state.profile?.profilePicture),
hidden: () =>
!useUserStore.getState().profile?.profilePicture,
isModal: true,
modifer: () => {
presentDialog({
title: strings.removeProfilePicture(),
@@ -260,6 +262,7 @@ export const accountGroup: SettingSection = {
useHook: () =>
useUserStore((state) => state.profile?.fullName),
hidden: () => !useUserStore.getState().profile?.fullName,
isModal: true,
modifer: () => {
presentDialog({
title: strings.removeFullName(),
@@ -296,6 +299,7 @@ export const accountGroup: SettingSection = {
icon: "warning-circle",
iconFamily: "notesnook",
hidden: () => Platform.OS !== "ios",
isModal: true,
modifer: async () => {
if (Platform.OS === "android") return;
presentSheet({
@@ -366,6 +370,7 @@ export const accountGroup: SettingSection = {
name: strings.change2faMethod(),
icon: "shield-check",
iconFamily: "notesnook",
isModal: true,
modifer: () => {
verifyUser("global", async () => {
MFASheet.present();
@@ -386,6 +391,7 @@ export const accountGroup: SettingSection = {
!(user as User)?.mfa?.isEnabled
);
},
isModal: true,
modifer: () => {
verifyUser("global", async () => {
MFASheet.present(true);
@@ -404,6 +410,7 @@ export const accountGroup: SettingSection = {
!(user as User)?.mfa?.isEnabled
);
},
isModal: true,
modifer: () => {
verifyUser("global", async () => {
MFASheet.present(true);
@@ -414,6 +421,7 @@ export const accountGroup: SettingSection = {
{
id: "view-2fa-codes",
name: strings.viewRecoveryCodes(),
isModal: true,
modifer: () => {
verifyUser("global", async () => {
MFARecoveryCodes.present("sms");
@@ -435,11 +443,11 @@ export const accountGroup: SettingSection = {
id: "recovery-key",
name: strings.saveDataRecoveryKey(),
iconFamily: "notesnook",
isModal: true,
modifer: async () => {
// if (await verifyUser()) {
// }
RecoveryKeySheet.present();
if (await verifyUser()) {
RecoveryKeySheet.present();
}
},
description: strings.saveDataRecoveryKeyDesc(),
icon: "key"
@@ -468,6 +476,7 @@ export const accountGroup: SettingSection = {
name: strings.clearCache(),
icon: "trash",
iconFamily: "notesnook",
isModal: true,
modifer: async () => {
presentDialog({
title: strings.clearCacheConfirm(),
@@ -521,6 +530,7 @@ export const accountGroup: SettingSection = {
description: strings.logoutWarnin(),
icon: "user-sheet-logout",
iconFamily: "notesnook",
isModal: true,
modifer: logoutUser
},
{
@@ -530,6 +540,7 @@ export const accountGroup: SettingSection = {
icon: "user-circle-minus",
iconFamily: "notesnook",
description: strings.deleteAccountDesc(),
isModal: true,
modifer: () => {
presentDialog({
title: strings.deleteAccount(),
@@ -700,6 +711,7 @@ export const accountGroup: SettingSection = {
description: strings.forcePullChangesDesc(),
icon: "git-pull-request",
iconFamily: "notesnook",
isModal: true,
modifer: () => {
ForceSyncSheet.present("fetch");
}
@@ -710,6 +722,7 @@ export const accountGroup: SettingSection = {
description: strings.forcePushChangesDesc(),
icon: "arrow-fat-up",
iconFamily: "notesnook",
isModal: true,
modifer: () => {
ForceSyncSheet.present("send");
}

View File

@@ -129,6 +129,7 @@ export const backRestoreGroup: SettingSection = {
},
icon: "folder",
iconFamily: "notesnook",
isModal: true,
hidden: () =>
!!SettingsService.get().backupDirectoryAndroid ||
Platform.OS !== "android",
@@ -152,6 +153,7 @@ export const backRestoreGroup: SettingSection = {
{
id: "change-backup-dir",
name: strings.changeBackupDir(),
isModal: true,
description: () =>
SettingsService.get().backupDirectoryAndroid?.name || "",
icon: "folder",
@@ -187,7 +189,8 @@ export const backRestoreGroup: SettingSection = {
type: "switch",
name: strings.backupEncryption(),
description: strings.backupEncryptionDesc(),
icon: "lock",
icon: "folder-lock",
iconFamily: "notesnook",
property: "encryptedBackup",
modifer: async () => {
const user = useUserStore.getState().user;
@@ -233,6 +236,7 @@ export const backRestoreGroup: SettingSection = {
id: "restore-from-files",
name: strings.restoreFromFiles(),
icon: "folder",
isModal: true,
modifer: async () => {
useUserStore.setState({
disableAppLockRequests: true
@@ -270,6 +274,7 @@ export const backRestoreGroup: SettingSection = {
id: "select-backup-folder",
name: strings.selectBackupFolder(),
icon: "folder",
isModal: true,
hidden: () => Platform.OS !== "android",
modifer: async () => {
const folder = await ScopedStorage.openDocumentTree(true);
@@ -303,6 +308,7 @@ export const backRestoreGroup: SettingSection = {
icon: "export",
iconFamily: "notesnook",
description: strings.exportAllNotesDesc(),
isModal: true,
modifer: () => {
verifyUser(undefined, () => {
ExportNotesSheet.present(undefined, true);

View File

@@ -130,6 +130,7 @@ export const customizeGroup: SettingSection = {
description: strings.dateFormatDesc(),
icon: "calendar",
iconFamily: "notesnook",
isModal: true,
modifer: () => {
DateFormat.present();
}
@@ -174,6 +175,7 @@ export const customizeGroup: SettingSection = {
description: strings.clearTrashIntervalDesc(),
icon: "trash",
iconFamily: "notesnook",
isModal: true,
modifer: () => {
TrashInterval.present();
}
@@ -273,8 +275,9 @@ export const customizeGroup: SettingSection = {
name: strings.defaultFontSize(),
description: strings.defaultFontSizeDesc(),
type: "input-selector",
minInputValue: 8,
maxInputValue: 120,
minInputValue: 1,
maxInputValue: 400,
step: 1,
inputBadgeValue: "px",
icon: "format-size",
property: "defaultFontSize"
@@ -287,7 +290,8 @@ export const customizeGroup: SettingSection = {
property: "defaultLineHeight",
icon: "format-line-spacing",
minInputValue: EDITOR_LINE_HEIGHT.MIN,
maxInputValue: EDITOR_LINE_HEIGHT.MAX
maxInputValue: EDITOR_LINE_HEIGHT.MAX,
step: 0.1
}
]
},
@@ -310,11 +314,12 @@ export const customizeGroup: SettingSection = {
});
}
},
{
id: "toggle-markdown",
name: strings.mardownShortcuts(),
property: "markdownShortcuts",
icon: "markdown",
iconFamily: "notesnook",
description: strings.mardownShortcutsDesc(),
type: "switch",
featureId: "markdownShortcuts"
@@ -330,6 +335,8 @@ export const customizeGroup: SettingSection = {
id: "title-format",
name: strings.titleFormat(),
component: "title-format",
icon: "pencil-ruler",
iconFamily: "notesnook",
description: strings.titleFormatDesc(),
type: "component"
}

View File

@@ -34,9 +34,9 @@ export const helpSupportGroup: SettingSection = {
name: strings.reportAnIssue(),
icon: "warning-circle",
iconFamily: "notesnook",
isModal: true,
modifer: () => {
presentSheet({
//@ts-ignore Migrate to TS
component: <Issue />
});
},
@@ -76,6 +76,7 @@ export const helpSupportGroup: SettingSection = {
description: strings.downloadDebugLogsDesc(),
icon: "bug-droid",
iconFamily: "notesnook",
isModal: true,
modifer: () => {
DownloadLogs.present();
}

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { validators } from "../../../components/ui/input/form-input";
import { db } from "../../../common/database";
import { AppLockPassword } from "../../../components/dialogs/applock-password";
import AppLockTimeout from "../../../components/sheets/app-lock-timeout";
@@ -67,6 +68,7 @@ export const privacySecurityGroup: SettingSection = {
type: "input",
name: strings.corsBypass(),
description: strings.corsBypassDesc(),
validators: [validators.url()],
inputProperties: {
defaultValue: "https://cors.notesnook.com",
autoCorrect: false,
@@ -97,6 +99,7 @@ export const privacySecurityGroup: SettingSection = {
iconFamily: "notesnook",
useHook: useVaultStatus,
hidden: (current) => (current as VaultStatusType)?.exists,
isModal: true,
modifer: () => {
openVault({
requestType: VaultRequestType.CreateVault,
@@ -143,6 +146,7 @@ export const privacySecurityGroup: SettingSection = {
iconFamily: "notesnook",
description: strings.changeVaultPasswordDesc(),
hidden: (current) => !(current as VaultStatusType)?.exists,
isModal: true,
modifer: () =>
openVault({
requestType: VaultRequestType.ChangePassword,
@@ -159,6 +163,7 @@ export const privacySecurityGroup: SettingSection = {
hidden: (current) => !(current as VaultStatusType)?.exists,
icon: "clock",
iconFamily: "notesnook",
isModal: true,
modifer: () => {
LockVaultTimer.present();
}
@@ -171,6 +176,7 @@ export const privacySecurityGroup: SettingSection = {
icon: "paint-brush-household",
iconFamily: "notesnook",
hidden: (current) => !(current as VaultStatusType)?.exists,
isModal: true,
modifer: () => {
openVault({
requestType: VaultRequestType.ClearVault,
@@ -190,6 +196,7 @@ export const privacySecurityGroup: SettingSection = {
type: "danger",
useHook: useVaultStatus,
hidden: (current) => !(current as VaultStatusType)?.exists,
isModal: true,
modifer: () => {
openVault({
requestType: VaultRequestType.DeleteVault,
@@ -281,6 +288,7 @@ export const privacySecurityGroup: SettingSection = {
description: strings.appLockTimeoutDesc(),
icon: "clock",
iconFamily: "notesnook",
isModal: true,
modifer: async () => {
if (await verifyUserWithApplock()) {
AppLockTimeout.present();
@@ -302,6 +310,7 @@ export const privacySecurityGroup: SettingSection = {
return verifyUserWithApplock();
},
property: "appLockHasPasswordSecurity",
isModal: true,
modifer: () => {
AppLockPassword.present("create");
}
@@ -318,6 +327,7 @@ export const privacySecurityGroup: SettingSection = {
);
},
property: "appLockHasPasswordSecurity",
isModal: true,
modifer: () => {
AppLockPassword.present("change");
}
@@ -334,6 +344,7 @@ export const privacySecurityGroup: SettingSection = {
icon: "backspace",
iconFamily: "notesnook",
property: "appLockHasPasswordSecurity",
isModal: true,
modifer: () => {
AppLockPassword.present("remove");
}

View File

@@ -20,6 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import notifee from "@notifee/react-native";
import { Platform } from "react-native";
import { validators } from "../../../components/ui/input/form-input";
import Notifications from "../../../services/notifications";
import SettingsService from "../../../services/settings";
import { SettingSection } from "../types";
@@ -86,6 +87,7 @@ export const productivityGroup: SettingSection = {
iconFamily: "notesnook",
name: strings.defaultSnoozeTime(),
description: strings.defaultSnoozeTimeDesc(),
validators: [validators.number()],
inputProperties: {
keyboardType: "decimal-pad",
defaultValue: 5 + "",

View File

@@ -38,6 +38,10 @@ import {
updateProgress
} from "../../../components/dialogs/progress";
import { Button } from "../../../components/ui/button";
import {
createFormRef,
validators
} from "../../../components/ui/input/form-input";
import Paragraph from "../../../components/ui/typography/paragraph";
import { ToastManager } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
@@ -50,30 +54,79 @@ import { Spacing } from "../../../common/design/spacing";
type PasswordOrKey = { password?: string; encryptionKey?: string };
const withPassword = () => {
return new Promise<PasswordOrKey>((resolve) => {
let resolved = false;
const RESTORE_CANCELLED = new Error("restore-cancelled");
const getPasswordError = (e: unknown): string | undefined => {
const message = e instanceof Error ? e.message : "";
if (message === "Incorrect password.") return strings.passwordIncorrect();
if (message === "Invalid encryption key.")
return strings.invalid(strings.encryptionKey());
return undefined;
};
const verifyWithImport =
(importBackup: (passwordOrKey: PasswordOrKey) => Promise<unknown>) =>
async (passwordOrKey: PasswordOrKey): Promise<string | undefined> => {
try {
await importBackup(passwordOrKey);
return undefined;
} catch (e) {
const error = getPasswordError(e);
if (error) return error;
throw e;
}
};
const withPassword = (
verify: (passwordOrKey: PasswordOrKey) => Promise<string | undefined>
) => {
return new Promise<PasswordOrKey>((resolve, reject) => {
const formRef = createFormRef({ password: "" });
let done = false;
presentDialog({
context: "local",
title: strings.backupEncrypted(),
input: true,
inputPlaceholder: strings.password(),
paragraph: strings.backupEnterPassword(),
positiveText: strings.restore(),
secureTextEntry: true,
form: {
formRef,
items: [
{
name: "password",
placeholder: strings.password(),
ref: React.createRef(),
validators: [validators.required(strings.passwordNotEntered())]
}
],
onFormSubmit: async (form, isEncryptionKey) => {
if (!form.validate()) return false;
const value = form.getValue("password");
const passwordOrKey: PasswordOrKey = {
encryptionKey: isEncryptionKey ? value : undefined,
password: isEncryptionKey ? undefined : value
};
try {
const error = await verify(passwordOrKey);
if (error) {
form.setError("password", error);
return false;
}
} catch (e) {
done = true;
reject(e);
return true;
}
done = true;
resolve(passwordOrKey);
return true;
}
},
onClose: () => {
if (resolved) return;
if (done) return;
resolve({});
},
negativeText: strings.cancel(),
positivePress: async (password, isEncryptionKey) => {
resolve({
encryptionKey: isEncryptionKey ? password : undefined,
password: isEncryptionKey ? undefined : password
});
resolved = true;
return true;
},
check: {
info: strings.useEncryptionKey(),
type: "transparent"
@@ -174,23 +227,26 @@ export const restoreBackup = async (options: {
const isEncryptedBackup = backup.encrypted;
passwordOrKey = !isEncryptedBackup
? ({} as PasswordOrKey)
: passwordOrKey || (await withPassword());
const importBackup = (passwordOrKey: PasswordOrKey) =>
db.backup.import(backup, {
...passwordOrKey,
attachmentsKey: attachmentsKey
});
if (
isEncryptedBackup &&
!passwordOrKey?.encryptionKey &&
!passwordOrKey?.password
) {
endProgress();
throw new Error(strings.failedToDecryptBackup());
if (!isEncryptedBackup) {
await importBackup({});
continue;
}
await db.backup.import(backup, {
...passwordOrKey,
attachmentsKey: attachmentsKey
});
if (!passwordOrKey) {
passwordOrKey = await withPassword(verifyWithImport(importBackup));
if (!passwordOrKey.encryptionKey && !passwordOrKey.password) {
throw RESTORE_CANCELLED;
}
continue;
}
await importBackup(passwordOrKey);
}
});
@@ -238,29 +294,29 @@ export const restoreBackup = async (options: {
const isEncryptedBackup =
typeof backup.data !== "string" && backup.data.cipher;
updateProgress({
progress: isEncryptedBackup
? strings.decryptingBackup()
: strings.preparingBackupRestore()
});
const { encryptionKey, password } = isEncryptedBackup
? ({} as PasswordOrKey)
: await withPassword();
if (isEncryptedBackup && !encryptionKey && !password) {
endProgress();
throw new Error(strings.failedToDecryptBackup());
}
await db.transaction(async () => {
const importBackup = (passwordOrKey: PasswordOrKey) =>
db.backup.import(backup, passwordOrKey);
if (!isEncryptedBackup) {
updateProgress({
progress: strings.restoringBackup()
});
await importBackup({});
return;
}
updateProgress({
progress: strings.restoringBackup()
});
await db.backup.import(backup, {
encryptionKey,
password
progress: strings.decryptingBackup()
});
// Prompt for the password and validate it by importing from inside the
// dialog so an incorrect password keeps the dialog open.
const passwordOrKey = await withPassword(
verifyWithImport(importBackup)
);
if (!passwordOrKey.encryptionKey && !passwordOrKey.password) {
throw RESTORE_CANCELLED;
}
});
endProgress();
}
@@ -276,6 +332,8 @@ export const restoreBackup = async (options: {
endProgress();
} catch (e) {
endProgress();
// User dismissed the password prompt: abort silently without an error toast.
if (e === RESTORE_CANCELLED) return;
DatabaseLogger.error(e as Error);
ToastManager.error(e as Error, strings.restoreFailed());
}

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { FeatureResult, useIsFeatureAvailable } from "@notesnook/common";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import {
NavigationProp,
@@ -32,6 +33,10 @@ import PaywallSheet from "../../components/sheets/paywall";
import AppIcon from "../../components/ui/AppIcon";
import { IconButton } from "../../components/ui/icon-button";
import Input from "../../components/ui/input";
import FormInput, {
createFormRef,
FormRef
} from "../../components/ui/input/form-input";
import { Pressable } from "../../components/ui/pressable";
import Heading from "../../components/ui/typography/heading";
import Paragraph from "../../components/ui/typography/paragraph";
@@ -41,7 +46,6 @@ import { SettingStore, useSettingStore } from "../../stores/use-setting-store";
import { AppFontSize } from "../../utils/size";
import { components } from "./components/components";
import { RouteParams, SettingSection } from "./types";
import { planToDisplayNameShort } from "../../utils/constants";
const _SectionItem = ({ item }: { item: SettingSection }) => {
const { colors } = useThemeColors();
@@ -66,7 +70,23 @@ const _SectionItem = ({ item }: { item: SettingSection }) => {
);
const inputRef = useRef<TextInput>(null);
const [loading, setLoading] = useState(false);
const [inputSelectorValue, setInputSelectorValue] = useState(() =>
const fieldName = (item.property as string) || item.id;
const formRef = useRef<FormRef>(
createFormRef({
[fieldName]: item.property
? `${
SettingsService.get()[
item.property as keyof SettingStore["settings"]
] ??
item.inputProperties?.defaultValue ??
""
}`
: `${item.inputProperties?.defaultValue ?? ""}`
})
);
const [selectorError, setSelectorError] = useState<string>();
const [selectorValue, setSelectorValue] = useState(() =>
item.property
? `${
SettingsService.get()[
@@ -76,6 +96,66 @@ const _SectionItem = ({ item }: { item: SettingSection }) => {
: ""
);
const step = item.step || 1;
const stepDecimals = `${step}`.split(".")[1]?.length || 0;
const roundToStep = (value: number) => Number(value.toFixed(stepDecimals));
const commitInputValue = (text: string) => {
if (!item.property) return;
const error = formRef.current?.validateField(fieldName);
if (error) return;
SettingsService.set({
[item.property as string]: text
});
};
const validateSelectorValue = (text: string): string | undefined => {
for (const validator of item.validators || []) {
const error = validator(text, {});
if (error) return error;
}
const min = item.minInputValue ?? 0;
const max = item.maxInputValue ?? Number.MAX_SAFE_INTEGER;
const num = Number(text);
if (!text?.trim() || Number.isNaN(num) || num < min || num > max) {
return strings.valueMustBeBetween(min, max);
}
return undefined;
};
const onChangeSelectorValue = (text: string) => {
setSelectorValue(text);
const error = validateSelectorValue(text);
setSelectorError(error);
if (error || !text.trim() || !item.property) return;
SettingsService.set({
[item.property as string]: text
});
};
const stepInputValue = (direction: 1 | -1) => {
if (!checkIsFeatureAvailable()) return;
if (isDisabled || !item.property) return;
const min = item.minInputValue ?? 0;
const max = item.maxInputValue ?? Number.MAX_SAFE_INTEGER;
const raw = `${
SettingsService.get()[item.property as keyof SettingStore["settings"]] ??
""
}`;
const parsed = parseFloat(raw);
const base = Number.isNaN(parsed) ? (direction === 1 ? min : max) : parsed;
let next = roundToStep(base + direction * step);
if (next < min) next = min;
if (next > max) next = max;
if (next === base) return;
setSelectorError(undefined);
setSelectorValue(`${next}`);
SettingsService.set({
[item.property as string]: `${next}`
});
};
const onChangeSettings = async () => {
if (isDisabled) return;
if (!checkIsFeatureAvailable()) return;
@@ -101,39 +181,6 @@ const _SectionItem = ({ item }: { item: SettingSection }) => {
});
};
const updateInput = (value: any) => {
setInputSelectorValue(`${value}`);
};
const onChangeInputSelectorValue = (text: any, commit?: boolean) => {
// While typing (commit === false) an empty field is allowed so the user can
// clear and retype; on commit (submit/blur) an empty field falls back to min.
if (!text && !commit) {
setInputSelectorValue("");
return;
}
const min = item.minInputValue || 0;
const max = item.maxInputValue || 0;
const value = parseInt(text);
// Always cap the upper bound. Only enforce the lower bound on commit so that
// partial entries (e.g. typing "5" on the way to "50" when min is 8) aren't
// snapped up prematurely.
let clamped: number;
if (Number.isNaN(value)) clamped = min;
else if (value > max) clamped = max;
else if (commit && value < min) clamped = min;
else clamped = value;
// The input is controlled via `value`, so the clamped result is reflected in
// the field directly and can never display a value outside the range.
setInputSelectorValue(`${clamped}`);
SettingsService.set({
[item.property as string]: `${clamped}`
});
};
useEffect(() => {
setIsHidden(item.hidden && item.hidden(item.property || current));
setIsDisabled(
@@ -337,7 +384,7 @@ const _SectionItem = ({ item }: { item: SettingSection }) => {
</View>
) : null}
{item.type === "screen" ? (
{item.type === "screen" || item.isModal ? (
<AppIcon
name="chevron-right"
iconFamily="notesnook"
@@ -365,180 +412,160 @@ const _SectionItem = ({ item }: { item: SettingSection }) => {
)}
{item.type === "input" && (
<Input
<FormInput
{...item.inputProperties}
onSubmit={(e) => {
SettingsService.set({
[item.property as string]: e.nativeEvent.text
});
item.inputProperties?.onSubmitEditing?.(e);
}}
name={fieldName}
formRef={formRef}
validators={item.validators || []}
label={item.inputLabel}
editable={!isDisabled}
onChangeText={(text) => {
SettingsService.set({
[item.property as string]: text
});
item.inputProperties?.onSubmitEditing?.(text as any);
}}
fwdRef={inputRef}
fontSize={AppFontSize.sm}
marginBottom={0}
containerStyle={{
marginTop: Spacing.LEVEL_2,
backgroundColor: colors.secondary.background,
borderWidth: 0
borderRadius: Radius.XS
}}
inputStyle={{
color: colors.primary.heading
}}
fontSize={AppFontSize.sm}
fwdRef={inputRef}
onLayout={() => {
inputRef?.current?.setNativeProps({
text:
SettingsService.get()[
item.property as keyof SettingStore["settings"]
] + ""
});
onChangeText={(text) => {
commitInputValue(text);
}}
onSubmitEditing={(e) => {
commitInputValue(e.nativeEvent.text);
item.inputProperties?.onSubmitEditing?.(e);
}}
defaultValue={item.inputProperties?.defaultValue}
/>
)}
{item.type === "input-selector" && (
<View
style={{
flexDirection: "row",
alignItems: "center",
marginTop: Spacing.LEVEL_2,
backgroundColor: colors.secondary.background,
alignSelf: "flex-start",
padding: Spacing.LEVEL_2,
gap: Spacing.LEVEL_1,
borderRadius: Radius.S
alignSelf: "flex-start"
}}
>
<IconButton
name="plus"
color={colors.primary.icon}
iconFamily="notesnook"
onPress={() => {
if (!checkIsFeatureAvailable()) return;
if (isDisabled) return;
const rawValue = SettingsService.get()[
item.property as keyof SettingStore["settings"]
] as string;
if (rawValue) {
const currentValue = parseInt(rawValue);
const max = item.maxInputValue || 0;
if (currentValue >= max) return;
const nextValue = currentValue + 1;
SettingsService.set({
[item.property as string]: nextValue
});
updateInput(nextValue);
}
}}
size={16}
type="tertiary"
style={{
borderRadius: Radius.XXS,
padding: Spacing.LEVEL_0,
width: undefined,
height: undefined
}}
/>
<View
style={{
flexDirection: "row",
alignItems: "flex-end",
gap: 1
alignItems: "center",
backgroundColor: colors.secondary.background,
padding: Spacing.LEVEL_2,
gap: Spacing.LEVEL_1,
borderRadius: Radius.S,
borderWidth: 1,
borderColor: selectorError
? colors.error.border
: "transparent"
}}
>
<Input
{...item.inputProperties}
onSubmit={(e) => {
onChangeInputSelectorValue(e.nativeEvent.text, true);
item.inputProperties?.onSubmitEditing?.(e);
<IconButton
name="minus"
iconFamily="notesnook"
color={colors.primary.icon}
onPress={() => stepInputValue(-1)}
size={16}
type="tertiary"
style={{
borderRadius: Radius.XXS,
padding: Spacing.LEVEL_0,
width: undefined,
height: undefined
}}
editable={!isDisabled}
value={inputSelectorValue}
onChangeText={(text) => {
onChangeInputSelectorValue(text);
item.inputProperties?.onSubmitEditing?.(text as any);
/>
<View
style={{
flexDirection: "row",
alignItems: "flex-end",
gap: 1
}}
keyboardType="decimal-pad"
containerStyle={{
borderWidth: 0,
paddingLeft: 0,
paddingRight: 0,
height: 25,
borderRadius: 0,
minWidth: 25
>
<Input
{...item.inputProperties}
onSubmit={(e) => {
onChangeSelectorValue(e.nativeEvent.text);
item.inputProperties?.onSubmitEditing?.(e);
}}
editable={!isDisabled}
value={selectorValue}
onChangeText={onChangeSelectorValue}
keyboardType="decimal-pad"
containerStyle={{
borderWidth: 0,
paddingLeft: 0,
paddingRight: 0,
height: 25,
borderRadius: 0,
minWidth: 25
}}
fontSize={AppFontSize.sm}
inputStyle={{
textAlign: "center",
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
fontFamily: FontFamily.SEMI_BOLD,
color: colors.primary.heading
}}
wrapperStyle={{
flexGrow: 0,
marginBottom: 0,
paddingLeft: 0,
paddingRight: 0
}}
buttons={
<>
{item.inputBadgeValue ? (
<Paragraph
fontSize="XXS"
style={{
marginLeft: 1,
marginTop: 2,
color: colors.primary.heading
}}
color={colors.primary.paragraph}
>
{item.inputBadgeValue}
</Paragraph>
) : null}
</>
}
/>
</View>
<IconButton
name="plus"
color={colors.primary.icon}
iconFamily="notesnook"
onPress={() => stepInputValue(1)}
size={16}
type="tertiary"
style={{
borderRadius: Radius.XXS,
padding: Spacing.LEVEL_0,
width: undefined,
height: undefined
}}
fontSize={AppFontSize.sm}
inputStyle={{
textAlign: "center",
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
fontFamily: FontFamily.SEMI_BOLD,
color: colors.primary.heading
}}
wrapperStyle={{
flexGrow: 0,
marginBottom: 0,
paddingLeft: 0,
paddingRight: 0
}}
buttons={
<>
{item.inputBadgeValue ? (
<Paragraph
fontSize="XXS"
style={{
marginLeft: 1,
marginTop: 2,
color: colors.primary.heading
}}
color={colors.primary.paragraph}
>
px
</Paragraph>
) : null}
</>
}
/>
</View>
<IconButton
name="minus"
iconFamily="notesnook"
color={colors.primary.icon}
onPress={() => {
if (!checkIsFeatureAvailable()) return;
if (isDisabled) return;
const rawValue = SettingsService.get()[
item.property as keyof SettingStore["settings"]
] as string;
if (rawValue) {
const currentValue = parseInt(rawValue);
const minValue = item.minInputValue || 0;
if (currentValue <= minValue) return;
const nextValue = currentValue - 1;
SettingsService.set({
[item.property as string]: nextValue
});
updateInput(nextValue);
}
}}
size={16}
type="tertiary"
style={{
borderRadius: Radius.XXS,
padding: Spacing.LEVEL_0,
width: undefined,
height: undefined
}}
/>
{selectorError ? (
<Paragraph
size={AppFontSize.xs}
style={{ color: colors.error.icon }}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{selectorError}
</Paragraph>
) : null}
</View>
)}
</View>

View File

@@ -21,6 +21,7 @@ import { TextInput } from "react-native";
import { Settings } from "../../stores/use-setting-store";
import { FeatureId } from "@notesnook/common";
import { IconProps } from "../../components/ui/AppIcon";
import { FieldValidator } from "../../components/ui/input/form-input";
export type SettingSection = {
id: string;
@@ -51,11 +52,25 @@ export type SettingSection = {
options?: any[];
minInputValue?: number;
maxInputValue?: number;
/**
* Amount to increment/decrement an `input-selector` by when the plus/minus
* buttons are pressed. Defaults to `1` (e.g. font size). Use a fractional
* value for point-based inputs like line height (e.g. `0.1`).
*/
step?: number;
/**
* Validators applied to `input` and `input-selector` fields. Invalid values
* surface an error (styled like `FormInput`) and are not saved.
*/
validators?: FieldValidator[];
/** Optional label rendered above an `input` field. */
inputLabel?: string;
inputBadgeValue?: string;
onVerify?: () => Promise<boolean>;
hideHeader?: boolean;
disabled?: (current: unknown) => boolean;
featureId?: FeatureId;
isModal?: boolean;
};
export type SettingsGroup = {

View File

@@ -176,7 +176,8 @@ async function exportAttachmentToFile(
async function bulkExport(
notes: FilteredSelector<Note>,
type: "txt" | "pdf" | "md" | "html" | "md-frontmatter",
callback: (progress?: string) => void
callback: (progress?: string) => void,
signal?: AbortSignal
) {
const totalNotes = await notes.count();
@@ -193,6 +194,11 @@ async function bulkExport(
format: type,
unlockVault: unlockVaultForNoteExport as () => Promise<boolean>
})) {
if (signal?.aborted) {
RNFetchBlob.fs.unlink(cacheFolder).catch(() => {});
return;
}
if (item instanceof Error) {
DatabaseLogger.error(item);
continue;
@@ -223,7 +229,8 @@ async function bulkExport(
async function exportNote(
note: Note,
type: "txt" | "pdf" | "md" | "html" | "md-frontmatter",
callback: (progress?: string) => void
callback: (progress?: string) => void,
signal?: AbortSignal
) {
const fileFuncions = await resolveFileFunctions(type);
@@ -239,6 +246,11 @@ async function exportNote(
format: type,
unlockVault: unlockVaultForNoteExport as () => Promise<boolean>
})) {
if (signal?.aborted) {
RNFetchBlob.fs.unlink(cacheFolder).catch(() => {});
return;
}
if (item instanceof Error) {
DatabaseLogger.error(item);
continue;

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const isGithubRelease = false;
const config = {
// commands: require("@callstack/repack/commands/rspack")
commands: require("@callstack/repack/commands/rspack")
};
if (!config.dependencies) config.dependencies = {};

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 10H13.5V9.75001C13.5 9.28588 13.3156 8.84076 12.9874 8.51257C12.6592 8.18438 12.2141 8.00001 11.75 8.00001C11.2859 8.00001 10.8408 8.18438 10.5126 8.51257C10.1844 8.84076 10 9.28588 10 9.75001V10H9.5C9.36739 10 9.24021 10.0527 9.14645 10.1465C9.05268 10.2402 9 10.3674 9 10.5V13C9 13.1326 9.05268 13.2598 9.14645 13.3536C9.24021 13.4473 9.36739 13.5 9.5 13.5H14C14.1326 13.5 14.2598 13.4473 14.3536 13.3536C14.4473 13.2598 14.5 13.1326 14.5 13V10.5C14.5 10.3674 14.4473 10.2402 14.3536 10.1465C14.2598 10.0527 14.1326 10 14 10ZM11 9.75001C11 9.55109 11.079 9.36033 11.2197 9.21968C11.3603 9.07902 11.5511 9.00001 11.75 9.00001C11.9489 9.00001 12.1397 9.07902 12.2803 9.21968C12.421 9.36033 12.5 9.55109 12.5 9.75001V10H11V9.75001ZM13.5 12.5H10V11H13.5V12.5ZM13.5 4.50001H8.20687L6.5 2.79313C6.40748 2.69987 6.29734 2.62593 6.17599 2.5756C6.05464 2.52528 5.9245 2.49959 5.79313 2.50001H2.5C2.23478 2.50001 1.98043 2.60536 1.79289 2.7929C1.60536 2.98043 1.5 3.23479 1.5 3.50001V12.5388C1.50033 12.7936 1.60171 13.0379 1.78191 13.2181C1.96211 13.3983 2.20641 13.4997 2.46125 13.5H7.035C7.16761 13.5 7.29479 13.4473 7.38855 13.3536C7.48232 13.2598 7.535 13.1326 7.535 13C7.535 12.8674 7.48232 12.7402 7.38855 12.6465C7.29479 12.5527 7.16761 12.5 7.035 12.5H2.5V5.50001H13.5V6.50001C13.5 6.63261 13.5527 6.75979 13.6464 6.85356C13.7402 6.94733 13.8674 7.00001 14 7.00001C14.1326 7.00001 14.2598 6.94733 14.3536 6.85356C14.4473 6.75979 14.5 6.63261 14.5 6.50001V5.50001C14.5 5.23479 14.3946 4.98043 14.2071 4.7929C14.0196 4.60536 13.7652 4.50001 13.5 4.50001ZM5.79313 3.50001L6.79313 4.50001H2.5V3.50001H5.79313Z" fill="#181818"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1896,6 +1896,10 @@ msgstr "Code"
msgid "Code block"
msgstr "Code block"
#: src/strings.ts:3017
msgid "Code copied"
msgstr "Code copied"
#: src/strings.ts:2330
msgid "Code remove"
msgstr "Code remove"
@@ -3017,6 +3021,10 @@ msgstr "Enter the gift code to redeem your subscription."
msgid "Enter title"
msgstr "Enter title"
#: src/strings.ts:3018
msgid "Enter vault password"
msgstr "Enter vault password"
#: src/strings.ts:1541
msgid "Enter verification code sent to your new email"
msgstr "Enter verification code sent to your new email"

View File

@@ -1885,6 +1885,10 @@ msgstr ""
msgid "Code block"
msgstr ""
#: src/strings.ts:3017
msgid "Code copied"
msgstr ""
#: src/strings.ts:2330
msgid "Code remove"
msgstr ""
@@ -3006,6 +3010,10 @@ msgstr ""
msgid "Enter title"
msgstr ""
#: src/strings.ts:3018
msgid "Enter vault password"
msgstr ""
#: src/strings.ts:1541
msgid "Enter verification code sent to your new email"
msgstr ""

View File

@@ -3017,5 +3017,7 @@ Continue without attachments?`,
setExpiryDesc: () => t`Note will get deleted on the set date.`,
changeCreatedDate: () => t`Change created date`,
changeCreatedDateDesc: () =>
t`Select date and time to change the created date`
t`Select date and time to change the created date`,
codeCopied: () => t`Code copied`,
enterVaultPassword: () => t`Enter vault password`
};