mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
mobile: settings-module fixes
This commit is contained in:
committed by
Abdullah Atta
parent
9d090dbfbc
commit
30ca9c1529
@@ -30,6 +30,7 @@ interface IDelayLayoutProps extends ViewProps {
|
||||
type?: "default" | "settings";
|
||||
color?: string;
|
||||
animated?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const placeholder = {
|
||||
@@ -39,13 +40,14 @@ const placeholder = {
|
||||
|
||||
export default function DelayLayout({
|
||||
animated = true,
|
||||
disabled,
|
||||
...props
|
||||
}: IDelayLayoutProps) {
|
||||
const { colors } = useThemeColors();
|
||||
const loading = useDelayLayout(props.delay === undefined ? 200 : props.delay);
|
||||
const Placeholder = placeholder[props.type || "default"];
|
||||
|
||||
return loading || props.wait ? (
|
||||
return (loading || props.wait) && !disabled ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.primary.background,
|
||||
|
||||
@@ -210,7 +210,11 @@ export const Toast = ({ context = "global" }) => {
|
||||
>
|
||||
<AppIcon
|
||||
name={toastOptions.icon || variant.icon.name}
|
||||
iconFamily={toastOptions.iconFamily || variant.icon.family}
|
||||
iconFamily={
|
||||
toastOptions.icon && toastOptions.iconFamily
|
||||
? toastOptions.iconFamily
|
||||
: variant.icon.family
|
||||
}
|
||||
size={16}
|
||||
color={variant.accent}
|
||||
/>
|
||||
|
||||
@@ -64,6 +64,7 @@ import { eCloseSheet } from "../../../utils/events";
|
||||
import { AppFontSize } from "../../../utils/size";
|
||||
import { sleep } from "../../../utils/time";
|
||||
import PaywallSheet from "../../../components/sheets/paywall";
|
||||
import LineSeparator from "../../../components/ui/seperator/line-separator";
|
||||
const mfaMethods: MFAMethod[] = [
|
||||
{
|
||||
id: "app",
|
||||
@@ -141,7 +142,12 @@ export const MFAMethodsPickerStep = ({ recovery, onSuccess }: MFAStepProps) => {
|
||||
paddingHorizontal: Spacing.LEVEL_3
|
||||
}}
|
||||
/>
|
||||
<Seperator />
|
||||
|
||||
<LineSeparator
|
||||
paddingVertical={Spacing.LEVEL_3}
|
||||
paddingHorizontal={Spacing.LEVEL_3}
|
||||
/>
|
||||
|
||||
{getMethods().map((item, index, methods) => (
|
||||
<Pressable
|
||||
key={item.title}
|
||||
@@ -466,7 +472,21 @@ export const MFASetup = ({
|
||||
}
|
||||
buttons={
|
||||
<Button
|
||||
onPress={onSendCode}
|
||||
onPress={() => {
|
||||
if (method.id !== "sms" && user) {
|
||||
Clipboard.setString(
|
||||
method.id === "app"
|
||||
? authenticatorDetails.sharedKey!
|
||||
: user.email!
|
||||
);
|
||||
ToastManager.show({
|
||||
message:
|
||||
method.id === "app"
|
||||
? strings.codeCopied()
|
||||
: strings.emailCopied()
|
||||
});
|
||||
}
|
||||
}}
|
||||
type="transparent"
|
||||
fontFamily="MEDIUM"
|
||||
fontSize={AppFontSize.sm}
|
||||
@@ -479,6 +499,8 @@ export const MFASetup = ({
|
||||
paddingHorizontal: 0,
|
||||
marginLeft: Spacing.LEVEL_1
|
||||
}}
|
||||
icon="copy"
|
||||
iconFamily="notesnook"
|
||||
title={
|
||||
sending
|
||||
? null
|
||||
|
||||
@@ -70,13 +70,13 @@ export function SettingsPicker<T>({
|
||||
if ((await isFeatureAvailable()) && (await isOptionAvailable(item))) {
|
||||
menuRef.current?.hide();
|
||||
await updateValue(item);
|
||||
setCurrentValue(item);
|
||||
setCurrentValue(getValue());
|
||||
return;
|
||||
}
|
||||
|
||||
menuRef.current?.hide();
|
||||
await updateValue(item);
|
||||
setCurrentValue(item);
|
||||
setCurrentValue(getValue());
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -66,7 +66,7 @@ export const FontPicker = createSettingsPicker<
|
||||
},
|
||||
getItemKey: (item) => item.id,
|
||||
options: getFonts(),
|
||||
compareValue: (current: any, item) => current.id === item.id,
|
||||
compareValue: (current: any, item) => current === item.id,
|
||||
isFeatureAvailable: async () => true,
|
||||
isOptionAvailable: async () => true
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ const SoundItem = ({
|
||||
height: 60,
|
||||
borderBottomWidth: 1,
|
||||
borderRadius: 0,
|
||||
borderBottomColor: colors.primary.border,
|
||||
borderBottomColor: colors.primary.separator,
|
||||
paddingHorizontal: DefaultAppStyles.GAP
|
||||
}}
|
||||
onPress={async () => {
|
||||
@@ -83,7 +83,13 @@ const SoundItem = ({
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={22}
|
||||
size={16}
|
||||
color={
|
||||
selectedSoundId === item.soundID ||
|
||||
(!selectedSoundId && item.soundID === "defaultSound")
|
||||
? colors.primary.accent
|
||||
: colors.primary.icon
|
||||
}
|
||||
name={
|
||||
selectedSoundId === item.soundID ||
|
||||
(!selectedSoundId && item.soundID === "defaultSound")
|
||||
@@ -99,7 +105,7 @@ const SoundItem = ({
|
||||
{item.soundID === "defaultSound" ? null : (
|
||||
<IconButton
|
||||
type={isPlaying ? "secondaryAccented" : "plain"}
|
||||
size={22}
|
||||
size={16}
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
color={isPlaying ? colors.primary.accent : colors.primary.icon}
|
||||
onPress={() => {
|
||||
|
||||
@@ -80,7 +80,7 @@ const Group = ({
|
||||
<LineSeparator paddingHorizontal={Spacing.LEVEL_3} />
|
||||
)}
|
||||
|
||||
<DelayLayout type="settings">
|
||||
<DelayLayout disabled={route.params.hideHeader} type="settings">
|
||||
<View
|
||||
style={{
|
||||
flex: 1
|
||||
|
||||
@@ -298,7 +298,15 @@ export const accountGroup: SettingSection = {
|
||||
name: strings.subscriptionNotActivated(),
|
||||
icon: "warning-circle",
|
||||
iconFamily: "notesnook",
|
||||
hidden: () => Platform.OS !== "ios",
|
||||
useHook: () =>
|
||||
useUserStore(
|
||||
(state) =>
|
||||
!state.user ||
|
||||
!state.user.subscription ||
|
||||
state.user?.subscription?.plan === SubscriptionPlan.FREE
|
||||
),
|
||||
hidden: (isFreeUser) =>
|
||||
Platform.OS !== "ios" || !(isFreeUser as boolean),
|
||||
isModal: true,
|
||||
modifer: async () => {
|
||||
if (Platform.OS === "android") return;
|
||||
|
||||
@@ -266,7 +266,8 @@ export const customizeGroup: SettingSection = {
|
||||
name: strings.defaultFontFamily(),
|
||||
description: strings.defaultFontFamilyDesc(),
|
||||
type: "component",
|
||||
icon: "format-font",
|
||||
icon: "text-aa",
|
||||
iconFamily: "notesnook",
|
||||
property: "defaultFontFamily",
|
||||
component: "font-selector"
|
||||
},
|
||||
@@ -279,7 +280,8 @@ export const customizeGroup: SettingSection = {
|
||||
maxInputValue: 400,
|
||||
step: 1,
|
||||
inputBadgeValue: "px",
|
||||
icon: "format-size",
|
||||
icon: "text-aa",
|
||||
iconFamily: "notesnook",
|
||||
property: "defaultFontSize"
|
||||
},
|
||||
{
|
||||
@@ -288,7 +290,9 @@ export const customizeGroup: SettingSection = {
|
||||
description: strings.lineHeightDesc(),
|
||||
type: "input-selector",
|
||||
property: "defaultLineHeight",
|
||||
icon: "format-line-spacing",
|
||||
icon: "list",
|
||||
iconSize: 8,
|
||||
iconFamily: "notesnook",
|
||||
minInputValue: EDITOR_LINE_HEIGHT.MIN,
|
||||
maxInputValue: EDITOR_LINE_HEIGHT.MAX,
|
||||
step: 0.1
|
||||
@@ -306,7 +310,9 @@ export const customizeGroup: SettingSection = {
|
||||
description: strings.doubleSpacedLinesDesc(),
|
||||
type: "switch",
|
||||
property: "doubleSpacedLines",
|
||||
icon: "format-line-spacing",
|
||||
icon: "list",
|
||||
iconFamily: "notesnook",
|
||||
iconSize: 8,
|
||||
onChange: () => {
|
||||
ToastManager.show({
|
||||
heading: strings.lineSpacingChanged(),
|
||||
@@ -335,7 +341,7 @@ export const customizeGroup: SettingSection = {
|
||||
id: "title-format",
|
||||
name: strings.titleFormat(),
|
||||
component: "title-format",
|
||||
icon: "pencil-ruler",
|
||||
icon: "article-ny-times",
|
||||
iconFamily: "notesnook",
|
||||
description: strings.titleFormatDesc(),
|
||||
type: "component"
|
||||
|
||||
@@ -90,8 +90,8 @@ export const productivityGroup: SettingSection = {
|
||||
validators: [
|
||||
validators.number(),
|
||||
validators.custom((value) => {
|
||||
return value < 5
|
||||
? strings.valueMustBeGreaterThan("5")
|
||||
return value < 4
|
||||
? strings.valueMustBeGreaterThan("4")
|
||||
: undefined;
|
||||
})
|
||||
],
|
||||
@@ -112,9 +112,7 @@ export const productivityGroup: SettingSection = {
|
||||
component: "sound-picker",
|
||||
icon: "speaker-high",
|
||||
iconFamily: "notesnook",
|
||||
hidden: () =>
|
||||
Platform.OS === "ios" ||
|
||||
(Platform.OS === "android" && Platform.Version > 25)
|
||||
hidden: () => Platform.OS === "android"
|
||||
},
|
||||
{
|
||||
id: "reminder-sound-android",
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useThemeColors } from "@notesnook/theme";
|
||||
import { Spacing } from "../../common/design/spacing";
|
||||
import { View } from "react-native";
|
||||
import LineSeparator from "../../components/ui/seperator/line-separator";
|
||||
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
|
||||
|
||||
const keyExtractor = (item: SettingSection) => item.id;
|
||||
|
||||
@@ -40,6 +41,7 @@ const Home = ({
|
||||
navigation
|
||||
}: NativeStackScreenProps<RouteParams, "SettingsHome">) => {
|
||||
const { colors } = useThemeColors();
|
||||
const insets = useGlobalSafeAreaInsets();
|
||||
const settingsGroups = useSettingsData();
|
||||
useNavigationFocus(navigation, {
|
||||
onFocus: () => {
|
||||
@@ -86,7 +88,7 @@ const Home = ({
|
||||
contentContainerStyle={{
|
||||
gap: Spacing.LEVEL_2,
|
||||
marginTop: Spacing.LEVEL_4,
|
||||
paddingBottom: 80
|
||||
paddingBottom: insets.bottom
|
||||
}}
|
||||
/>
|
||||
</DelayLayout>
|
||||
|
||||
@@ -19,325 +19,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { LegendList } from "@legendapp/list";
|
||||
import { formatBytes, getFormattedDate } from "@notesnook/common";
|
||||
import { LegacyBackupFile } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Platform, View } from "react-native";
|
||||
import RNFetchBlob, { ReactNativeBlobUtilStat } from "react-native-blob-util";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
import { unzip } from "react-native-zip-archive";
|
||||
import { DatabaseLogger, db } from "../../../common/database";
|
||||
import { Spacing } from "../../../common/design/spacing";
|
||||
import filesystem from "../../../common/filesystem";
|
||||
import { deleteCacheFileByName } from "../../../common/filesystem/io";
|
||||
import { cacheDir, copyFileAsync } from "../../../common/filesystem/utils";
|
||||
import { presentDialog } from "../../../components/dialog/functions";
|
||||
import {
|
||||
endProgress,
|
||||
startProgress,
|
||||
updateProgress
|
||||
} from "../../../components/dialogs/progress";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../../components/ui/input/form-input";
|
||||
import Heading from "../../../components/ui/typography/heading";
|
||||
import Paragraph from "../../../components/ui/typography/paragraph";
|
||||
import { ToastManager } from "../../../services/event-manager";
|
||||
import Navigation from "../../../services/navigation";
|
||||
import { refreshAllStores } from "../../../stores/create-db-collection-store";
|
||||
import { useSettingStore } from "../../../stores/use-setting-store";
|
||||
import { AppFontSize } from "../../../utils/size";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import Heading from "../../../components/ui/typography/heading";
|
||||
import { Spacing } from "../../../common/design/spacing";
|
||||
import { restoreBackup } from "./restore-progress";
|
||||
|
||||
type PasswordOrKey = { password?: string; encryptionKey?: string };
|
||||
|
||||
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(),
|
||||
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 (done) return;
|
||||
resolve({});
|
||||
},
|
||||
negativeText: strings.cancel(),
|
||||
check: {
|
||||
info: strings.useEncryptionKey(),
|
||||
type: "transparent"
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreBackup = async (options: {
|
||||
uri: string;
|
||||
deleteFile?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
if (
|
||||
!options.uri.endsWith(".nnbackup") &&
|
||||
!options.uri.endsWith(".nnbackupz")
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid backup file selected. Only .nnbackup and .nnbackupz files can be restored.`
|
||||
);
|
||||
}
|
||||
|
||||
const isLegacyBackup = options.uri.endsWith(".nnbackup");
|
||||
|
||||
startProgress({
|
||||
title: strings.restoring(),
|
||||
paragraph: strings.preparingBackupRestore(),
|
||||
icon: "arrows-clockwise",
|
||||
canHideProgress: false
|
||||
});
|
||||
|
||||
let filePath = options.uri;
|
||||
let deleteBackupFile = options.deleteFile;
|
||||
|
||||
if (!isLegacyBackup) {
|
||||
if (Platform.OS === "android") {
|
||||
updateProgress({
|
||||
progress: strings.copyingBackupFileToCache()
|
||||
});
|
||||
const cacheFile = `file://${RNFetchBlob.fs.dirs.CacheDir}/backup.zip`;
|
||||
if (await RNFetchBlob.fs.exists(cacheFile)) {
|
||||
await RNFetchBlob.fs.unlink(cacheFile);
|
||||
}
|
||||
|
||||
await RNFetchBlob.fs.createFile(cacheFile, "", "utf8");
|
||||
if (filePath.startsWith("content://")) {
|
||||
await copyFileAsync(filePath, cacheFile);
|
||||
} else {
|
||||
await RNFetchBlob.fs.cp(filePath, cacheFile);
|
||||
}
|
||||
filePath = cacheFile;
|
||||
deleteBackupFile = true;
|
||||
}
|
||||
|
||||
const zipOutputFolder = `${cacheDir}/backup_extracted`;
|
||||
if (await RNFetchBlob.fs.exists(zipOutputFolder)) {
|
||||
await RNFetchBlob.fs.unlink(zipOutputFolder);
|
||||
await RNFetchBlob.fs.mkdir(zipOutputFolder);
|
||||
}
|
||||
updateProgress({
|
||||
progress: strings.extractingFiles()
|
||||
});
|
||||
await unzip(filePath, zipOutputFolder);
|
||||
|
||||
const extractedBackupFiles = await RNFetchBlob.fs.ls(zipOutputFolder);
|
||||
|
||||
const extractedAttachments = extractedBackupFiles.includes("attachments")
|
||||
? await RNFetchBlob.fs.ls(`${zipOutputFolder}/attachments`)
|
||||
: [];
|
||||
|
||||
const attachmentsKeyPath: any = extractedAttachments?.find(
|
||||
(path) => path === ".attachments_key"
|
||||
);
|
||||
const attachmentsKey = attachmentsKeyPath
|
||||
? await JSON.parse(
|
||||
await RNFetchBlob.fs.readFile(
|
||||
`${zipOutputFolder}/attachments/${attachmentsKeyPath}`,
|
||||
"utf8"
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
let count = 0;
|
||||
await db.transaction(async () => {
|
||||
let passwordOrKey: PasswordOrKey | undefined = undefined;
|
||||
for (const path of extractedBackupFiles) {
|
||||
if (path === ".nnbackup" || path === "attachments") continue;
|
||||
|
||||
updateProgress({
|
||||
progress: `${strings.restoringBackup()} (${count++}/${
|
||||
extractedBackupFiles.length
|
||||
})`
|
||||
});
|
||||
|
||||
const filePath = `${zipOutputFolder}/${path}`;
|
||||
const data = await RNFetchBlob.fs.readFile(filePath, "utf8");
|
||||
const backup = JSON.parse(data);
|
||||
|
||||
const isEncryptedBackup = backup.encrypted;
|
||||
|
||||
const importBackup = (passwordOrKey: PasswordOrKey) =>
|
||||
db.backup.import(backup, {
|
||||
...passwordOrKey,
|
||||
attachmentsKey: attachmentsKey
|
||||
});
|
||||
|
||||
if (!isEncryptedBackup) {
|
||||
await importBackup({});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!passwordOrKey) {
|
||||
passwordOrKey = await withPassword(verifyWithImport(importBackup));
|
||||
if (!passwordOrKey.encryptionKey && !passwordOrKey.password) {
|
||||
throw RESTORE_CANCELLED;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await importBackup(passwordOrKey);
|
||||
}
|
||||
});
|
||||
|
||||
await db.initCollections();
|
||||
count = 0;
|
||||
for (const path of extractedAttachments) {
|
||||
if (path === ".attachments_key") continue;
|
||||
updateProgress({
|
||||
progress: `Restoring attachments (${count++}/${
|
||||
extractedAttachments.length
|
||||
})`
|
||||
});
|
||||
const hash = path;
|
||||
const attachment = await db.attachments.attachment(hash as string);
|
||||
if (!attachment) continue;
|
||||
|
||||
await deleteCacheFileByName(hash);
|
||||
await RNFetchBlob.fs.cp(
|
||||
`${zipOutputFolder}/attachments/${hash}`,
|
||||
`${cacheDir}/${hash}`
|
||||
);
|
||||
}
|
||||
updateProgress({
|
||||
progress: strings.cleaningUp()
|
||||
});
|
||||
// Remove files from cache
|
||||
RNFetchBlob.fs.unlink(zipOutputFolder).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
if (Platform.OS === "android" || deleteBackupFile) {
|
||||
RNFetchBlob.fs.unlink(filePath).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateProgress({
|
||||
progress: strings.readingBackupFile()
|
||||
});
|
||||
const rawData =
|
||||
Platform.OS === "android"
|
||||
? await ScopedStorage.readFile(filePath, "utf8")
|
||||
: await RNFetchBlob.fs.readFile(filePath, "utf8");
|
||||
const backup: LegacyBackupFile = JSON.parse(rawData) as LegacyBackupFile;
|
||||
|
||||
const isEncryptedBackup =
|
||||
typeof backup.data !== "string" && backup.data.cipher;
|
||||
|
||||
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.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();
|
||||
}
|
||||
|
||||
ToastManager.show({
|
||||
heading: strings.backupRestored(),
|
||||
type: "success"
|
||||
});
|
||||
|
||||
await db.initCollections();
|
||||
refreshAllStores();
|
||||
Navigation.queueRoutesForUpdate();
|
||||
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());
|
||||
}
|
||||
};
|
||||
// The restore flow (progress, password, confirmation, error handling) lives in
|
||||
// ./restore-progress alongside the dialog that drives it. Re-exported here so
|
||||
// existing importers of "../restore-backup" keep working.
|
||||
export { restoreBackup } from "./restore-progress";
|
||||
|
||||
const BACKUP_FILES_CACHE: (ReactNativeBlobUtilStat | ScopedStorage.FileType)[] =
|
||||
[];
|
||||
@@ -417,7 +118,7 @@ export const RestoreBackup = () => {
|
||||
style={{
|
||||
marginBottom: Spacing.LEVEL_2
|
||||
}}
|
||||
color={colors.secondary.paragraph}
|
||||
color={colors.primary.accent}
|
||||
size={AppFontSize.sm}
|
||||
fontFamily="MEDIUM"
|
||||
>
|
||||
@@ -489,10 +190,6 @@ const BackupItem = ({
|
||||
index: number;
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
|
||||
const isLegacyBackup =
|
||||
item.path?.endsWith(".nnbackup") ||
|
||||
(item as ScopedStorage.FileType).uri?.endsWith(".nnbackup");
|
||||
const itemName = (
|
||||
(item as ReactNativeBlobUtilStat).filename ||
|
||||
(item as ScopedStorage.FileType).name
|
||||
@@ -540,23 +237,13 @@ const BackupItem = ({
|
||||
paddingVertical: Spacing.LEVEL_1
|
||||
}}
|
||||
onPress={() => {
|
||||
presentDialog({
|
||||
title: `${strings.restore()} ${itemName}`,
|
||||
paragraph: strings.restoreBackupConfirm(),
|
||||
positiveText: strings.restore(),
|
||||
negativeText: strings.cancel(),
|
||||
context: "global",
|
||||
positivePress: async () => {
|
||||
setTimeout(() => {
|
||||
restoreBackup({
|
||||
uri:
|
||||
Platform.OS === "android"
|
||||
? (item as ScopedStorage.FileType).uri
|
||||
: (item as ReactNativeBlobUtilStat).path
|
||||
});
|
||||
}, 500);
|
||||
return true;
|
||||
}
|
||||
restoreBackup({
|
||||
confirm: true,
|
||||
name: itemName,
|
||||
uri:
|
||||
Platform.OS === "android"
|
||||
? (item as ScopedStorage.FileType).uri
|
||||
: (item as ReactNativeBlobUtilStat).path
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
/*
|
||||
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 { format, LegacyBackupFile, logManager } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import Clipboard from "@react-native-clipboard/clipboard";
|
||||
import React, { useRef, useState, useSyncExternalStore } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
import { unzip } from "react-native-zip-archive";
|
||||
import { Radius, Spacing } from "../../../common/design/spacing";
|
||||
import { DatabaseLogger, db } from "../../../common/database";
|
||||
import { deleteCacheFileByName } from "../../../common/filesystem/io";
|
||||
import { cacheDir, copyFileAsync } from "../../../common/filesystem/utils";
|
||||
import DialogButtons from "../../../components/dialog/dialog-buttons";
|
||||
import {
|
||||
hideDialog,
|
||||
presentDialog
|
||||
} from "../../../components/dialog/functions";
|
||||
import AppIcon from "../../../components/ui/AppIcon";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import {
|
||||
createFormRef,
|
||||
FormInput,
|
||||
validators
|
||||
} from "../../../components/ui/input/form-input";
|
||||
import { ProgressBarComponent } from "../../../components/ui/svg/lazy";
|
||||
import Heading from "../../../components/ui/typography/heading";
|
||||
import Paragraph from "../../../components/ui/typography/paragraph";
|
||||
import { ToastManager } from "../../../services/event-manager";
|
||||
import { DDS } from "../../../services/device-detection";
|
||||
import Navigation from "../../../services/navigation";
|
||||
import { refreshAllStores } from "../../../stores/create-db-collection-store";
|
||||
import { getContainerBorder } from "../../../utils/colors";
|
||||
import { getElevationStyle } from "../../../utils/elevation";
|
||||
|
||||
export type PasswordOrKey = { password?: string; encryptionKey?: string };
|
||||
|
||||
type Mode = "progress" | "password" | "confirm" | "error";
|
||||
|
||||
type RestoreProgressState = {
|
||||
mode: Mode;
|
||||
title?: string;
|
||||
paragraph?: string;
|
||||
icon?: string;
|
||||
progress?: string;
|
||||
confirmTitle?: string;
|
||||
confirmParagraph?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
let state: RestoreProgressState = { mode: "progress" };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let passwordResolve: ((value: PasswordOrKey) => void) | null = null;
|
||||
let passwordReject: ((reason?: unknown) => void) | null = null;
|
||||
let passwordVerify:
|
||||
| ((passwordOrKey: PasswordOrKey) => Promise<string | undefined>)
|
||||
| null = null;
|
||||
let confirmResolve: ((value: boolean) => void) | null = null;
|
||||
let retryHandler: (() => void) | null = null;
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function setState(patch: Partial<RestoreProgressState>) {
|
||||
state = { ...state, ...patch };
|
||||
emit();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function clearPending() {
|
||||
passwordResolve = null;
|
||||
passwordReject = null;
|
||||
passwordVerify = null;
|
||||
confirmResolve = null;
|
||||
retryHandler = null;
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
presentDialog({
|
||||
context: "global",
|
||||
disableBackdropClosing: true,
|
||||
onClose: () => {
|
||||
const resolvePassword = passwordResolve;
|
||||
const resolveConfirm = confirmResolve;
|
||||
clearPending();
|
||||
resolvePassword?.({});
|
||||
resolveConfirm?.(false);
|
||||
},
|
||||
component: () => <RestoreProgressDialog />
|
||||
});
|
||||
}
|
||||
|
||||
const RestoreProgress = {
|
||||
present(options: { title?: string; paragraph?: string; icon?: string }) {
|
||||
clearPending();
|
||||
state = { mode: "progress", progress: undefined, ...options };
|
||||
emit();
|
||||
openDialog();
|
||||
},
|
||||
update(progress: string) {
|
||||
setState({ progress });
|
||||
},
|
||||
requestConfirmation(options: {
|
||||
title?: string;
|
||||
paragraph?: string;
|
||||
}): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
confirmResolve = resolve;
|
||||
setState({
|
||||
mode: "confirm",
|
||||
confirmTitle: options.title,
|
||||
confirmParagraph: options.paragraph
|
||||
});
|
||||
});
|
||||
},
|
||||
requestPassword(
|
||||
verify: (passwordOrKey: PasswordOrKey) => Promise<string | undefined>
|
||||
): Promise<PasswordOrKey> {
|
||||
return new Promise<PasswordOrKey>((resolve, reject) => {
|
||||
passwordVerify = verify;
|
||||
passwordResolve = resolve;
|
||||
passwordReject = reject;
|
||||
setState({ mode: "password" });
|
||||
});
|
||||
},
|
||||
showError(error: Error, onRetry: () => void) {
|
||||
clearPending();
|
||||
retryHandler = onRetry;
|
||||
setState({
|
||||
mode: "error",
|
||||
errorMessage: error.message || strings.somethingWentWrong()
|
||||
});
|
||||
openDialog();
|
||||
},
|
||||
end() {
|
||||
clearPending();
|
||||
hideDialog();
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
export const restoreBackup = async (options: {
|
||||
uri: string;
|
||||
deleteFile?: boolean;
|
||||
confirm?: boolean;
|
||||
name?: string;
|
||||
}) => {
|
||||
const confirmRestore = () =>
|
||||
RestoreProgress.requestConfirmation({
|
||||
title: options.name
|
||||
? `${strings.restore()} ${options.name}`
|
||||
: strings.restore(),
|
||||
paragraph: strings.restoreBackupConfirm()
|
||||
});
|
||||
|
||||
try {
|
||||
if (
|
||||
!options.uri.endsWith(".nnbackup") &&
|
||||
!options.uri.endsWith(".nnbackupz")
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid backup file selected. Only .nnbackup and .nnbackupz files can be restored.`
|
||||
);
|
||||
}
|
||||
|
||||
const isLegacyBackup = options.uri.endsWith(".nnbackup");
|
||||
|
||||
RestoreProgress.present({
|
||||
title: strings.restoring(),
|
||||
paragraph: strings.preparingBackupRestore(),
|
||||
icon: "arrows-clockwise"
|
||||
});
|
||||
|
||||
let filePath = options.uri;
|
||||
let deleteBackupFile = options.deleteFile;
|
||||
|
||||
if (!isLegacyBackup) {
|
||||
if (Platform.OS === "android") {
|
||||
RestoreProgress.update(strings.copyingBackupFileToCache());
|
||||
const cacheFile = `file://${RNFetchBlob.fs.dirs.CacheDir}/backup.zip`;
|
||||
if (await RNFetchBlob.fs.exists(cacheFile)) {
|
||||
await RNFetchBlob.fs.unlink(cacheFile);
|
||||
}
|
||||
|
||||
await RNFetchBlob.fs.createFile(cacheFile, "", "utf8");
|
||||
if (filePath.startsWith("content://")) {
|
||||
await copyFileAsync(filePath, cacheFile);
|
||||
} else {
|
||||
await RNFetchBlob.fs.cp(filePath, cacheFile);
|
||||
}
|
||||
filePath = cacheFile;
|
||||
deleteBackupFile = true;
|
||||
}
|
||||
|
||||
const zipOutputFolder = `${cacheDir}/backup_extracted`;
|
||||
if (await RNFetchBlob.fs.exists(zipOutputFolder)) {
|
||||
await RNFetchBlob.fs.unlink(zipOutputFolder);
|
||||
await RNFetchBlob.fs.mkdir(zipOutputFolder);
|
||||
}
|
||||
RestoreProgress.update(strings.extractingFiles());
|
||||
await unzip(filePath, zipOutputFolder);
|
||||
|
||||
const extractedBackupFiles = await RNFetchBlob.fs.ls(zipOutputFolder);
|
||||
|
||||
const extractedAttachments = extractedBackupFiles.includes("attachments")
|
||||
? await RNFetchBlob.fs.ls(`${zipOutputFolder}/attachments`)
|
||||
: [];
|
||||
|
||||
const attachmentsKeyPath: any = extractedAttachments?.find(
|
||||
(path) => path === ".attachments_key"
|
||||
);
|
||||
const attachmentsKey = attachmentsKeyPath
|
||||
? await JSON.parse(
|
||||
await RNFetchBlob.fs.readFile(
|
||||
`${zipOutputFolder}/attachments/${attachmentsKeyPath}`,
|
||||
"utf8"
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
let count = 0;
|
||||
let confirmed = false;
|
||||
await db.transaction(async () => {
|
||||
let passwordOrKey: PasswordOrKey | undefined = undefined;
|
||||
for (const path of extractedBackupFiles) {
|
||||
if (path === ".nnbackup" || path === "attachments") continue;
|
||||
|
||||
const filePath = `${zipOutputFolder}/${path}`;
|
||||
const data = await RNFetchBlob.fs.readFile(filePath, "utf8");
|
||||
const backup = JSON.parse(data);
|
||||
|
||||
const isEncryptedBackup = backup.encrypted;
|
||||
|
||||
const importBackup = (passwordOrKey: PasswordOrKey) =>
|
||||
db.backup.import(backup, {
|
||||
...passwordOrKey,
|
||||
attachmentsKey: attachmentsKey
|
||||
});
|
||||
|
||||
if (!isEncryptedBackup) {
|
||||
if (options.confirm && !confirmed) {
|
||||
confirmed = true;
|
||||
if (!(await confirmRestore())) throw RESTORE_CANCELLED;
|
||||
}
|
||||
RestoreProgress.update(
|
||||
`${strings.restoringBackup()} (${count++}/${
|
||||
extractedBackupFiles.length
|
||||
})`
|
||||
);
|
||||
await importBackup({});
|
||||
continue;
|
||||
}
|
||||
|
||||
RestoreProgress.update(
|
||||
`${strings.restoringBackup()} (${count++}/${
|
||||
extractedBackupFiles.length
|
||||
})`
|
||||
);
|
||||
|
||||
if (!passwordOrKey) {
|
||||
passwordOrKey = await RestoreProgress.requestPassword(
|
||||
verifyWithImport(importBackup)
|
||||
);
|
||||
if (!passwordOrKey.encryptionKey && !passwordOrKey.password) {
|
||||
throw RESTORE_CANCELLED;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await importBackup(passwordOrKey);
|
||||
}
|
||||
});
|
||||
|
||||
await db.initCollections();
|
||||
count = 0;
|
||||
for (const path of extractedAttachments) {
|
||||
if (path === ".attachments_key") continue;
|
||||
RestoreProgress.update(
|
||||
`Restoring attachments (${count++}/${extractedAttachments.length})`
|
||||
);
|
||||
const hash = path;
|
||||
const attachment = await db.attachments.attachment(hash as string);
|
||||
if (!attachment) continue;
|
||||
|
||||
await deleteCacheFileByName(hash);
|
||||
await RNFetchBlob.fs.cp(
|
||||
`${zipOutputFolder}/attachments/${hash}`,
|
||||
`${cacheDir}/${hash}`
|
||||
);
|
||||
}
|
||||
RestoreProgress.update(strings.cleaningUp());
|
||||
// Remove files from cache
|
||||
RNFetchBlob.fs.unlink(zipOutputFolder).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
if (Platform.OS === "android" || deleteBackupFile) {
|
||||
RNFetchBlob.fs.unlink(filePath).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
}
|
||||
} else {
|
||||
RestoreProgress.update(strings.readingBackupFile());
|
||||
const rawData =
|
||||
Platform.OS === "android"
|
||||
? await ScopedStorage.readFile(filePath, "utf8")
|
||||
: await RNFetchBlob.fs.readFile(filePath, "utf8");
|
||||
const backup: LegacyBackupFile = JSON.parse(rawData) as LegacyBackupFile;
|
||||
|
||||
const isEncryptedBackup =
|
||||
typeof backup.data !== "string" && backup.data.cipher;
|
||||
|
||||
await db.transaction(async () => {
|
||||
const importBackup = (passwordOrKey: PasswordOrKey) =>
|
||||
db.backup.import(backup, passwordOrKey);
|
||||
|
||||
if (!isEncryptedBackup) {
|
||||
if (options.confirm && !(await confirmRestore())) {
|
||||
throw RESTORE_CANCELLED;
|
||||
}
|
||||
RestoreProgress.update(strings.restoringBackup());
|
||||
await importBackup({});
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordOrKey = await RestoreProgress.requestPassword(
|
||||
verifyWithImport(importBackup)
|
||||
);
|
||||
if (!passwordOrKey.encryptionKey && !passwordOrKey.password) {
|
||||
throw RESTORE_CANCELLED;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ToastManager.show({
|
||||
heading: strings.backupRestored(),
|
||||
type: "success"
|
||||
});
|
||||
|
||||
await db.initCollections();
|
||||
refreshAllStores();
|
||||
Navigation.queueRoutesForUpdate();
|
||||
RestoreProgress.end();
|
||||
} catch (e) {
|
||||
if (e === RESTORE_CANCELLED) {
|
||||
RestoreProgress.end();
|
||||
return;
|
||||
}
|
||||
DatabaseLogger.error(e as Error);
|
||||
RestoreProgress.showError(e as Error, () => restoreBackup(options));
|
||||
}
|
||||
};
|
||||
|
||||
function RestoreProgressDialog() {
|
||||
const { colors } = useThemeColors();
|
||||
const current = useSyncExternalStore(subscribe, getSnapshot);
|
||||
const formRef = useRef(createFormRef({ password: "" }));
|
||||
const [isEncryptionKey, setIsEncryptionKey] = useState(false);
|
||||
|
||||
const onSubmitPassword = async () => {
|
||||
if (!formRef.current.validate()) return;
|
||||
const value = formRef.current.getValue("password");
|
||||
const passwordOrKey: PasswordOrKey = {
|
||||
encryptionKey: isEncryptionKey ? value : undefined,
|
||||
password: isEncryptionKey ? undefined : value
|
||||
};
|
||||
|
||||
const verify = passwordVerify;
|
||||
const resolve = passwordResolve;
|
||||
const reject = passwordReject;
|
||||
|
||||
setState({ mode: "progress", progress: strings.decryptingBackup() });
|
||||
|
||||
let error: string | undefined;
|
||||
try {
|
||||
error = await verify?.(passwordOrKey);
|
||||
} catch (e) {
|
||||
clearPending();
|
||||
reject?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
formRef.current.setError("password", error);
|
||||
setState({ mode: "password" });
|
||||
return;
|
||||
}
|
||||
|
||||
clearPending();
|
||||
resolve?.(passwordOrKey);
|
||||
};
|
||||
|
||||
const onCancelPassword = () => {
|
||||
const resolve = passwordResolve;
|
||||
clearPending();
|
||||
resolve?.({});
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
const resolve = confirmResolve;
|
||||
confirmResolve = null;
|
||||
setState({ mode: "progress", progress: strings.restoringBackup() });
|
||||
resolve?.(true);
|
||||
};
|
||||
|
||||
const onCancelConfirm = () => {
|
||||
const resolve = confirmResolve;
|
||||
confirmResolve = null;
|
||||
resolve?.(false);
|
||||
};
|
||||
|
||||
const onRetry = () => {
|
||||
const retry = retryHandler;
|
||||
retryHandler = null;
|
||||
retry?.();
|
||||
};
|
||||
|
||||
const onCloseError = () => {
|
||||
retryHandler = null;
|
||||
hideDialog();
|
||||
};
|
||||
|
||||
const onCopyLogs = async () => {
|
||||
try {
|
||||
const groups = await logManager?.get();
|
||||
const logsText =
|
||||
groups?.[0]?.logs?.map((log) => format(log)).join("\n") ?? "";
|
||||
Clipboard.setString(
|
||||
`${current.errorMessage ?? ""}\n\n${logsText}`.trim()
|
||||
);
|
||||
ToastManager.show({
|
||||
heading: strings.logsCopied(),
|
||||
type: "success",
|
||||
context: "local"
|
||||
});
|
||||
} catch (e) {
|
||||
ToastManager.show({
|
||||
heading: strings.failedToCopyToClipboard(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isError = current.mode === "error";
|
||||
|
||||
const title =
|
||||
current.mode === "password"
|
||||
? strings.backupEncrypted()
|
||||
: current.mode === "confirm"
|
||||
? current.confirmTitle
|
||||
: current.mode === "error"
|
||||
? strings.restoreFailed()
|
||||
: current.title;
|
||||
|
||||
const paragraph =
|
||||
current.mode === "password"
|
||||
? strings.backupEnterPassword()
|
||||
: current.mode === "confirm"
|
||||
? current.confirmParagraph
|
||||
: current.mode === "error"
|
||||
? current.errorMessage
|
||||
: current.progress || current.paragraph;
|
||||
|
||||
const iconName =
|
||||
current.mode === "password"
|
||||
? "lock"
|
||||
: current.mode === "error"
|
||||
? "warning-circle"
|
||||
: current.icon || "arrows-clockwise";
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...getElevationStyle(5),
|
||||
width: DDS.isTab ? 400 : "85%",
|
||||
borderRadius: Radius.LG,
|
||||
backgroundColor: colors.primary.background,
|
||||
paddingVertical: Spacing.LEVEL_4,
|
||||
gap: Spacing.LEVEL_4,
|
||||
...getContainerBorder(colors.primary.border, 0.5),
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: Spacing.LEVEL_2,
|
||||
paddingHorizontal: Spacing.LEVEL_3
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: Radius.XS,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: isError
|
||||
? colors.error.background
|
||||
: colors.secondary.background
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
name={iconName}
|
||||
iconFamily="notesnook"
|
||||
size={20}
|
||||
color={isError ? colors.error.icon : colors.primary.icon}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1, gap: Spacing.LEVEL_1 }}>
|
||||
<Heading
|
||||
fontSize="LG"
|
||||
lineHeight="100%"
|
||||
color={colors.primary.heading}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
<Paragraph fontSize="SM" color={colors.secondary.paragraph}>
|
||||
{paragraph}
|
||||
</Paragraph>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{current.mode === "error" ? (
|
||||
<>
|
||||
<View style={{ paddingHorizontal: Spacing.LEVEL_3 }}>
|
||||
<Button
|
||||
title={strings.copyLogs()}
|
||||
icon="copy"
|
||||
iconFamily="notesnook"
|
||||
type="secondary"
|
||||
width="100%"
|
||||
onPress={onCopyLogs}
|
||||
/>
|
||||
</View>
|
||||
<DialogButtons
|
||||
positiveTitle={strings.retry()}
|
||||
positiveType="accent"
|
||||
negativeTitle={strings.close()}
|
||||
onPressPositive={onRetry}
|
||||
onPressNegative={onCloseError}
|
||||
/>
|
||||
</>
|
||||
) : current.mode === "confirm" ? (
|
||||
<DialogButtons
|
||||
positiveTitle={strings.restore()}
|
||||
positiveType="accent"
|
||||
onPressPositive={onConfirm}
|
||||
onPressNegative={onCancelConfirm}
|
||||
/>
|
||||
) : current.mode === "password" ? (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: Spacing.LEVEL_3,
|
||||
gap: Spacing.LEVEL_2
|
||||
}}
|
||||
>
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
placeholder={
|
||||
isEncryptionKey ? strings.encryptionKey() : strings.password()
|
||||
}
|
||||
secureTextEntry
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
validators={[validators.required(strings.passwordNotEntered())]}
|
||||
onSubmitEditing={onSubmitPassword}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={isEncryptionKey}
|
||||
onPress={() => setIsEncryptionKey(!isEncryptionKey)}
|
||||
title={strings.useEncryptionKey()}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<DialogButtons
|
||||
positiveTitle={strings.restore()}
|
||||
positiveType="accent"
|
||||
onPressPositive={onSubmitPassword}
|
||||
onPressNegative={onCancelPassword}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
paddingHorizontal: Spacing.LEVEL_3
|
||||
}}
|
||||
>
|
||||
<ProgressBarComponent
|
||||
height={8}
|
||||
width={null}
|
||||
style={{ flex: 1 }}
|
||||
animated={true}
|
||||
useNativeDriver
|
||||
indeterminate
|
||||
indeterminateAnimationDuration={2000}
|
||||
unfilledColor={colors.secondary.background}
|
||||
color={colors.primary.accent}
|
||||
borderWidth={0}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,11 @@ export async function verifyUser(
|
||||
closeText?: string
|
||||
) {
|
||||
return new Promise((resolve) => {
|
||||
if (!useUserStore.getState().user) {
|
||||
resolve(true);
|
||||
onsuccess?.();
|
||||
return;
|
||||
}
|
||||
presentDialog({
|
||||
context: context || "global",
|
||||
title: strings.verifyItsYou(),
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
3
packages/icons/svgs/article-ny-times.svg
Normal file
3
packages/icons/svgs/article-ny-times.svg
Normal 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="M8 6H14.5C14.6326 6 14.7598 6.05268 14.8536 6.14645C14.9473 6.24021 15 6.36739 15 6.5C15 6.63261 14.9473 6.75979 14.8536 6.85355C14.7598 6.94732 14.6326 7 14.5 7H8C7.86739 7 7.74021 6.94732 7.64645 6.85355C7.55268 6.75979 7.5 6.63261 7.5 6.5C7.5 6.36739 7.55268 6.24021 7.64645 6.14645C7.74021 6.05268 7.86739 6 8 6ZM14.5 8H8C7.86739 8 7.74021 8.05268 7.64645 8.14645C7.55268 8.24021 7.5 8.36739 7.5 8.5C7.5 8.63261 7.55268 8.75979 7.64645 8.85355C7.74021 8.94732 7.86739 9 8 9H14.5C14.6326 9 14.7598 8.94732 14.8536 8.85355C14.9473 8.75979 15 8.63261 15 8.5C15 8.36739 14.9473 8.24021 14.8536 8.14645C14.7598 8.05268 14.6326 8 14.5 8ZM14.5 10H5C4.86739 10 4.74021 10.0527 4.64645 10.1464C4.55268 10.2402 4.5 10.3674 4.5 10.5C4.5 10.6326 4.55268 10.7598 4.64645 10.8536C4.74021 10.9473 4.86739 11 5 11H14.5C14.6326 11 14.7598 10.9473 14.8536 10.8536C14.9473 10.7598 15 10.6326 15 10.5C15 10.3674 14.9473 10.2402 14.8536 10.1464C14.7598 10.0527 14.6326 10 14.5 10ZM14.5 12H5C4.86739 12 4.74021 12.0527 4.64645 12.1464C4.55268 12.2402 4.5 12.3674 4.5 12.5C4.5 12.6326 4.55268 12.7598 4.64645 12.8536C4.74021 12.9473 4.86739 13 5 13H14.5C14.6326 13 14.7598 12.9473 14.8536 12.8536C14.9473 12.7598 15 12.6326 15 12.5C15 12.3674 14.9473 12.2402 14.8536 12.1464C14.7598 12.0527 14.6326 12 14.5 12ZM6 9C6.13261 9 6.25979 8.94732 6.35355 8.85355C6.44732 8.75979 6.5 8.63261 6.5 8.5C6.5 8.36739 6.44732 8.24021 6.35355 8.14645C6.25979 8.05268 6.13261 8 6 8H5.5V4H7.5V4.5C7.5 4.63261 7.55268 4.75979 7.64645 4.85355C7.74021 4.94732 7.86739 5 8 5C8.13261 5 8.25979 4.94732 8.35355 4.85355C8.44732 4.75979 8.5 4.63261 8.5 4.5V3.5C8.5 3.36739 8.44732 3.24021 8.35355 3.14645C8.25979 3.05268 8.13261 3 8 3H2C1.86739 3 1.74021 3.05268 1.64645 3.14645C1.55268 3.24021 1.5 3.36739 1.5 3.5V4.5C1.5 4.63261 1.55268 4.75979 1.64645 4.85355C1.74021 4.94732 1.86739 5 2 5C2.13261 5 2.25979 4.94732 2.35355 4.85355C2.44732 4.75979 2.5 4.63261 2.5 4.5V4H4.5V8H4C3.86739 8 3.74021 8.05268 3.64645 8.14645C3.55268 8.24021 3.5 8.36739 3.5 8.5C3.5 8.63261 3.55268 8.75979 3.64645 8.85355C3.74021 8.94732 3.86739 9 4 9H6Z" fill="#181818"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -401,7 +401,7 @@ export const strings = {
|
||||
other: `v${version} has been released`
|
||||
}),
|
||||
readReleaseNotes: {
|
||||
1: () => t`Read full release notes on `,
|
||||
1: () => t`Read full release notes on`,
|
||||
2: () => t`Github`
|
||||
},
|
||||
settings: () => t`Settings`,
|
||||
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user