diff --git a/public/locales/en.json b/public/locales/en.json
index b47691a..0d4e6f0 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -745,6 +745,9 @@
"detectedFormatKristWebV2": "KristWeb v2",
"detectedFormatInvalid": "Invalid!",
+ "progress": "Importing <1>{{count, number}}1> item...",
+ "progress_plural": "Importing <1>{{count, number}}1> items...",
+
"decodeErrors": {
"atob": "The backup could not be decoded as it is not valid base64!",
"json": "The backup could not be decoded as it is not valid JSON!",
diff --git a/src/index.tsx b/src/index.tsx
index c0ceb8b..233b61e 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,13 +1,14 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import "./utils/setup";
-import { i18nLoader } from "./utils/i18n";
+import "@utils/setup";
+import { i18nLoader } from "@utils/i18n";
+import { isLocalhost } from "@utils";
import ReactDOM from "react-dom";
import "./index.css";
-import App from "./App";
+import App from "@app";
import Debug from "debug";
const debug = Debug("kristweb:index");
@@ -15,7 +16,14 @@
// import reportWebVitals from "./reportWebVitals";
debug("============================ APP STARTING ============================");
-debug("waiting for i18n first");
+if (isLocalhost && !localStorage.getItem("status")) {
+ // Automatically enable debug logging on localhost
+ localStorage.setItem("debug", "kristweb:*");
+ localStorage.setItem("status", "LOCAL");
+ location.reload();
+}
+
+debug("waiting for i18n");
i18nLoader.then(() => {
debug("performing initial render");
ReactDOM.render(
diff --git a/src/pages/CheckStatus.tsx b/src/pages/CheckStatus.tsx
index db5910c..630cca9 100644
--- a/src/pages/CheckStatus.tsx
+++ b/src/pages/CheckStatus.tsx
@@ -6,6 +6,6 @@
import { StatusPage } from "./StatusPage";
export function CheckStatus(): JSX.Element {
- const ok = localStorage.getItem("status") === "Ok";
+ const ok = /^.?O[kC]/.test(localStorage.getItem("status") || "offline");
return ok ? : ;
}
diff --git a/src/pages/backup/ImportBackupForm.tsx b/src/pages/backup/ImportBackupForm.tsx
new file mode 100644
index 0000000..adfac8e
--- /dev/null
+++ b/src/pages/backup/ImportBackupForm.tsx
@@ -0,0 +1,201 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useState, Dispatch, SetStateAction } from "react";
+import { Form, Input, Checkbox, Typography } from "antd";
+
+import { useTFns, translateError } from "@utils/i18n";
+
+import { getMasterPasswordInput } from "@comp/auth/MasterPasswordInput";
+
+import { useBooleanSetting, setBooleanSetting } from "@utils/settings";
+
+import { ImportDetectFormat } from "./ImportDetectFormat";
+import { IncrProgressFn, InitProgressFn } from "./ImportProgress";
+import { decodeBackup } from "./backupParser";
+import { backupVerifyPassword, backupImport } from "./backupImport";
+import { BackupResults } from "./backupResults";
+
+import Debug from "debug";
+const debug = Debug("kristweb:import-backup-modal");
+
+const { Paragraph } = Typography;
+const { TextArea } = Input;
+
+interface FormValues {
+ masterPassword: string;
+ code: string;
+ overwrite: boolean;
+}
+
+interface ImportBackupFormHookRes {
+ form: JSX.Element;
+
+ resetForm: () => void;
+ triggerSubmit: () => Promise;
+
+ setCode: (code: string) => void;
+}
+
+export function useImportBackupForm(
+ setLoading: Dispatch>,
+ setResults: Dispatch>,
+
+ onProgress: IncrProgressFn,
+ initProgress: InitProgressFn
+): ImportBackupFormHookRes {
+ const { t, tStr, tKey } = useTFns("import.");
+
+ const [form] = Form.useForm();
+
+ const [code, setCode] = useState("");
+ const [decodeError, setDecodeError] = useState();
+ const [masterPasswordError, setMasterPasswordError] = useState();
+
+ const importOverwrite = useBooleanSetting("importOverwrite");
+
+ function resetForm() {
+ form.resetFields();
+ setCode("");
+ setDecodeError("");
+ setMasterPasswordError("");
+ }
+
+ function onValuesChange(changed: Partial) {
+ if (changed.code) setCode(changed.code);
+
+ // Remember the value of the 'overwrite' checkbox
+ if (changed.overwrite !== undefined) {
+ debug("updating importOverwrite to %b", changed.overwrite);
+ setBooleanSetting("importOverwrite", changed.overwrite, false);
+ }
+ }
+
+ // Detect the backup format for the final time, validate the password, and
+ // if all is well, begin the import
+ async function onFinish() {
+ const values = await form.validateFields();
+
+ const { masterPassword, code, overwrite } = values;
+ if (!masterPassword || !code) return;
+
+ setLoading(true);
+
+ try {
+ // Decode first
+ const backup = decodeBackup(code);
+ debug("detected format: %s", backup.type);
+ setDecodeError(undefined);
+
+ // Attempt to verify the master password
+ await backupVerifyPassword(backup, masterPassword);
+ setMasterPasswordError(undefined);
+
+ // Perform the import
+ const results = await backupImport(
+ backup, masterPassword, !overwrite,
+ onProgress, initProgress
+ );
+
+ setResults(results);
+ } catch (err) {
+ if (err.message === "import.masterPasswordRequired"
+ || err.message === "import.masterPasswordIncorrect") {
+ // Master password incorrect error
+ setMasterPasswordError(translateError(t, err));
+ } else {
+ // Any other decoding error
+ console.error(err);
+ setDecodeError(translateError(t, err, tKey("decodeErrors.unknown")));
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const formEl =
+ {getMasterPasswordInput({
+ placeholder: tStr("masterPasswordPlaceholder"),
+ autoFocus: true
+ })}
+
+
+ {/* Code textarea */}
+
+
+
+
+ {/* Overwrite checkbox */}
+
+
+ {tStr("overwriteCheckboxLabel")}
+
+
+ ;
+
+ return {
+ form: formEl,
+
+ resetForm,
+ triggerSubmit: onFinish,
+
+ setCode(code) {
+ setCode(code); // Triggers a format re-detection
+ form.setFieldsValue({ code });
+ }
+ };
+}
diff --git a/src/pages/backup/ImportBackupModal.tsx b/src/pages/backup/ImportBackupModal.tsx
index b52cee5..be64d9c 100644
--- a/src/pages/backup/ImportBackupModal.tsx
+++ b/src/pages/backup/ImportBackupModal.tsx
@@ -2,18 +2,13 @@
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { useState, Dispatch, SetStateAction } from "react";
-import { Modal, Form, FormInstance, Input, Checkbox, Button, Typography, notification } from "antd";
+import { Modal, Button } from "antd";
-import { useTranslation } from "react-i18next";
-import { translateError } from "@utils/i18n";
+import { useTFns } from "@utils/i18n";
-import { getMasterPasswordInput } from "@comp/auth/MasterPasswordInput";
-
-import { useBooleanSetting, setBooleanSetting } from "@utils/settings";
-
-import { ImportDetectFormat } from "./ImportDetectFormat";
-import { decodeBackup } from "./backupParser";
-import { backupVerifyPassword, backupImport } from "./backupImport";
+import { useImportBackupForm } from "./ImportBackupForm";
+import { useImportProgress } from "./ImportProgress";
+import { ImportFileButton } from "./ImportFileButton";
import { BackupResults } from "./backupResults";
import { BackupResultsSummary } from "./BackupResultsSummary";
import { BackupResultsTree } from "./BackupResultsTree";
@@ -21,37 +16,29 @@
import Debug from "debug";
const debug = Debug("kristweb:import-backup-modal");
-const { Paragraph } = Typography;
-const { TextArea } = Input;
-
-interface FormValues {
- masterPassword: string;
- code: string;
- overwrite: boolean;
-}
-
interface Props {
visible: boolean;
setVisible: Dispatch>;
}
export function ImportBackupModal({ visible, setVisible }: Props): JSX.Element {
- const { t } = useTranslation();
+ const tFns = useTFns("import.");
+ const { t, tStr } = tFns;
- const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
- const [code, setCode] = useState("");
- const [decodeError, setDecodeError] = useState();
- const [masterPasswordError, setMasterPasswordError] = useState();
const [results, setResults] = useState();
+ const { progressBar, onProgress, initProgress, resetProgress }
+ = useImportProgress(tFns);
+
+ const { form, resetForm, triggerSubmit, setCode }
+ = useImportBackupForm(setLoading, setResults, onProgress, initProgress);
+
/** Resets all the state when the modal is closed. */
function resetState() {
- form.resetFields();
+ resetForm();
+ resetProgress();
setLoading(false);
- setCode("");
- setDecodeError("");
- setMasterPasswordError("");
setResults(undefined);
}
@@ -64,94 +51,8 @@
setVisible(false);
}
- function onValuesChange(changed: Partial) {
- if (changed.code) setCode(changed.code);
-
- // Remember the value of the 'overwrite' checkbox
- if (changed.overwrite !== undefined) {
- debug("updating importOverwrite to %b", changed.overwrite);
- setBooleanSetting("importOverwrite", changed.overwrite, false);
- }
- }
-
- /** Updates the contents of the 'code' field with the given file. */
- function onFileChange(e: React.ChangeEvent) {
- const { files } = e.target;
- if (!files?.[0]) return;
- const file = files[0];
-
- debug("importing file %s", file.name);
-
- // Disallow non-plaintext files
- if (file.type !== "text/plain") {
- notification.error({
- message: t("import.fileErrorTitle"),
- description: t("import.fileErrorNotText")
- });
- return;
- }
-
- // Read the file and update the contents of the code field
- const reader = new FileReader();
- reader.readAsText(file, "UTF-8");
- reader.onload = e => {
- if (!e.target || !e.target.result) {
- debug("reader.onload target was null?!", e);
- return;
- }
-
- const contents = e.target.result.toString();
- // debug("got file contents: %s", contents);
-
- // Update the form
- setCode(contents); // Triggers a format re-detection
- form.setFieldsValue({ code: contents });
- };
- }
-
- // Detect the backup format for the final time, validate the password, and
- // if all is well, begin the import
- async function onFinish() {
- // If we're already on the results screen, close the modal instead
- if (results) return closeModal();
-
- const values = await form.validateFields();
-
- const { masterPassword, code, overwrite } = values;
- if (!masterPassword || !code) return;
-
- setLoading(true);
-
- try {
- // Decode first
- const backup = decodeBackup(code);
- debug("detected format: %s", backup.type);
- setDecodeError(undefined);
-
- // Attempt to verify the master password
- await backupVerifyPassword(backup, masterPassword);
- setMasterPasswordError(undefined);
-
- // Perform the import
- const results = await backupImport(backup, masterPassword, !overwrite);
- setResults(results);
- } catch (err) {
- if (err.message === "import.masterPasswordRequired"
- || err.message === "import.masterPasswordIncorrect") {
- // Master password incorrect error
- setMasterPasswordError(translateError(t, err));
- } else {
- // Any other decoding error
- console.error(err);
- setDecodeError(translateError(t, err, "import.decodeErrors.unknown"));
- }
- } finally {
- setLoading(false);
- }
- }
-
return
{t("dialog.close")}
- ]
- : [ // Import screen
- // "Import from file" button for import screen
-
- {/* Pretend to be an ant-design button (for some reason, nesting a
- * Button in a label just wouldn't work) */}
-
+ )
+ : <>
+ {/* Import screen */}
+ {/* "Import from file" button for import screen */}
+
- {/* ant-design's Upload/rc-upload was over 24 kB for this, and we
- * only use it for the most trivial functionality, so may as well
- * just use a bare component. It's okay that this input will
- * probably get re-rendered (and thus lose its value) every time
- * the state changes, as we only use it to update `code`'s state
- * immediately after a file is picked. */}
-
-
,
-
- // "Cancel" button for import screen
+ {/* "Cancel" button for import screen */}
,
+
- // "Import" button for import screen
+ {/* "Import" button for import screen */}
- ]}
+ >}
>
+ {/* Show the results screen, progress bar, or backup form */}
{results
? <>
- {/* Got results - show them */}
>
- : (
- // No results - show the import form
-
- )}
+ : (loading
+ ? progressBar
+ : form)}
;
}
-interface FormProps {
- form: FormInstance;
-
- code: string;
- decodeError?: string;
- setDecodeError: Dispatch>;
- masterPasswordError?: string;
-
- onValuesChange: (changed: Partial) => void;
- onFinish: () => void;
-}
-
-function ImportBackupForm({ form, code, decodeError, setDecodeError, masterPasswordError, onValuesChange, onFinish }: FormProps): JSX.Element {
- const { t } = useTranslation();
-
- const importOverwrite = useBooleanSetting("importOverwrite");
-
- return
- {getMasterPasswordInput({
- placeholder: t("import.masterPasswordPlaceholder"),
- autoFocus: true
- })}
-
-
- {/* Code textarea */}
-
-
-
-
- {/* Overwrite checkbox */}
-
-
- {t("import.overwriteCheckboxLabel")}
-
-
- ;
-}
diff --git a/src/pages/backup/ImportFileButton.tsx b/src/pages/backup/ImportFileButton.tsx
new file mode 100644
index 0000000..6c81279
--- /dev/null
+++ b/src/pages/backup/ImportFileButton.tsx
@@ -0,0 +1,76 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import React from "react";
+import { notification } from "antd";
+
+import { useTFns } from "@utils/i18n";
+
+import Debug from "debug";
+const debug = Debug("kristweb:import-file-button");
+
+interface Props {
+ setCode: (code: string) => void;
+}
+
+export function ImportFileButton({
+ setCode
+}: Props): JSX.Element {
+ const { tStr } = useTFns("import.");
+
+ /** Updates the contents of the 'code' field with the given file. */
+ function onFileChange(e: React.ChangeEvent) {
+ const { files } = e.target;
+ if (!files?.[0]) return;
+ const file = files[0];
+
+ debug("importing file %s", file.name);
+
+ // Disallow non-plaintext files
+ if (file.type !== "text/plain") {
+ notification.error({
+ message: tStr("fileErrorTitle"),
+ description: tStr("fileErrorNotText")
+ });
+ return;
+ }
+
+ // Read the file and update the contents of the code field
+ const reader = new FileReader();
+ reader.readAsText(file, "UTF-8");
+ reader.onload = e => {
+ if (!e.target || !e.target.result) {
+ debug("reader.onload target was null?!", e);
+ return;
+ }
+
+ const contents = e.target.result.toString();
+ setCode(contents);
+ };
+ }
+
+ return
+ {/* Pretend to be an ant-design button (for some reason, nesting a
+ * Button in a label just wouldn't work) */}
+
+
+ {/* ant-design's Upload/rc-upload was over 24 kB for this, and we
+ * only use it for the most trivial functionality, so may as well
+ * just use a bare component. It's okay that this input will
+ * probably get re-rendered (and thus lose its value) every time
+ * the state changes, as we only use it to update `code`'s state
+ * immediately after a file is picked. */}
+
+
;
+}
diff --git a/src/pages/backup/ImportProgress.tsx b/src/pages/backup/ImportProgress.tsx
new file mode 100644
index 0000000..0c97d64
--- /dev/null
+++ b/src/pages/backup/ImportProgress.tsx
@@ -0,0 +1,59 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useState } from "react";
+import { Progress } from "antd";
+
+import { Trans } from "react-i18next";
+import { TFns } from "@utils/i18n";
+
+export type IncrProgressFn = () => void;
+export type InitProgressFn = (total: number) => void;
+
+interface ImportProgressHookResponse {
+ progressBar: JSX.Element;
+ onProgress: IncrProgressFn;
+ initProgress: InitProgressFn;
+ resetProgress: () => void;
+}
+
+export function useImportProgress(
+ { t, tKey }: TFns
+): ImportProgressHookResponse {
+ const [progress, setProgress] = useState(0);
+ const [total, setTotal] = useState(1);
+
+ // Increment the progress bar when one of the wallets/contacts have been
+ // imported
+ const onProgress = () => setProgress(c => c + 1);
+
+ function initProgress(total: number) {
+ setProgress(0);
+ setTotal(total);
+ }
+
+ function resetProgress() {
+ setProgress(0);
+ setTotal(1);
+ }
+
+ const progressBar = <>
+ {/* Importing text */}
+
+
+ Importing {{ count: total }} items...
+
+
+
+ {/* Progress bar */}
+
+ >;
+
+ return { progressBar, onProgress, initProgress, resetProgress };
+}
diff --git a/src/pages/backup/backupImport.ts b/src/pages/backup/backupImport.ts
index 6312b8f..f75bf0c 100644
--- a/src/pages/backup/backupImport.ts
+++ b/src/pages/backup/backupImport.ts
@@ -15,6 +15,8 @@
import { importV1Backup } from "./backupImportV1";
import { importV2Backup } from "./backupImportV2";
+import { IncrProgressFn, InitProgressFn } from "./ImportProgress";
+
import Debug from "debug";
const debug = Debug("kristweb:backup-import");
@@ -82,7 +84,9 @@
export async function backupImport(
backup: Backup,
masterPassword: string,
- noOverwrite: boolean
+ noOverwrite: boolean,
+ onProgress: IncrProgressFn,
+ initProgress: InitProgressFn
): Promise {
// It is assumed at this point that the backup was already successfully
// decoded, and the master password was verified to be correct.
@@ -109,7 +113,7 @@
throw new TranslatedError("import.appMasterPasswordRequired");
// The results instance to keep track of logged messages, etc.
- const results = new BackupResults();
+ const results = new BackupResults(onProgress, initProgress);
// Attempt to add the wallets
if (isBackupKristWebV1(backup)) {
diff --git a/src/pages/backup/backupImportV1.ts b/src/pages/backup/backupImportV1.ts
index 6582028..228218d 100644
--- a/src/pages/backup/backupImportV1.ts
+++ b/src/pages/backup/backupImportV1.ts
@@ -43,6 +43,10 @@
results: BackupResults
): Promise {
+ const walletCount = Object.keys(backup.wallets).length;
+ const contactCount = Object.keys(backup.friends || {}).length;
+ results.initProgress(walletCount + contactCount);
+
// Import wallets
for (const uuid in backup.wallets) {
if (!uuid || !uuid.startsWith("Wallet-")) {
@@ -64,30 +68,36 @@
} catch (err) {
debug("error importing v1 wallet", err);
results.addErrorMessage("wallets", uuid, undefined, err);
+ } finally {
+ results.onProgress();
}
}
// Import contacts
- for (const uuid in backup.friends) {
- if (!uuid || !uuid.startsWith("Friend-")) {
- // Not a contact
- debug("skipping v1 contact key %s", uuid);
- continue;
- }
+ if (backup.friends) {
+ for (const uuid in backup.friends) {
+ if (!uuid || !uuid.startsWith("Friend-")) {
+ // Not a contact
+ debug("skipping v1 contact key %s", uuid);
+ continue;
+ }
- const rawContact = backup.friends[uuid];
- debug("importing v1 contact uuid %s", uuid);
+ const rawContact = backup.friends[uuid];
+ debug("importing v1 contact uuid %s", uuid);
- try {
- await importV1Contact(
- existingContacts, appSyncNode, addressPrefix, nameSuffix,
- backup, masterPassword, noOverwrite,
- uuid, rawContact,
- results
- );
- } catch (err) {
- debug("error importing v1 contact", err);
- results.addErrorMessage("contacts", uuid, undefined, err);
+ try {
+ await importV1Contact(
+ existingContacts, appSyncNode, addressPrefix, nameSuffix,
+ backup, masterPassword, noOverwrite,
+ uuid, rawContact,
+ results
+ );
+ } catch (err) {
+ debug("error importing v1 contact", err);
+ results.addErrorMessage("contacts", uuid, undefined, err);
+ } finally {
+ results.onProgress();
+ }
}
}
}
diff --git a/src/pages/backup/backupImportV2.ts b/src/pages/backup/backupImportV2.ts
index c61fb37..8747bf1 100644
--- a/src/pages/backup/backupImportV2.ts
+++ b/src/pages/backup/backupImportV2.ts
@@ -39,6 +39,10 @@
results: BackupResults
): Promise {
+ const walletCount = Object.keys(backup.wallets).length;
+ const contactCount = Object.keys(backup.contacts || {}).length;
+ results.initProgress(walletCount + contactCount);
+
// Import wallets
for (const uuid in backup.wallets) {
if (!uuid || !UUID_REGEXP.test(uuid)) {
@@ -60,6 +64,8 @@
} catch (err) {
debug("error importing v2 wallet", err);
results.addErrorMessage("wallets", uuid, undefined, err);
+ } finally {
+ results.onProgress();
}
}
@@ -84,6 +90,8 @@
} catch (err) {
debug("error importing v2 contact", err);
results.addErrorMessage("contacts", uuid, undefined, err);
+ } finally {
+ results.onProgress();
}
}
}
diff --git a/src/pages/backup/backupResults.ts b/src/pages/backup/backupResults.ts
index 8eb9c17..034bd70 100644
--- a/src/pages/backup/backupResults.ts
+++ b/src/pages/backup/backupResults.ts
@@ -7,6 +7,8 @@
import { Wallet } from "@wallets";
import { Contact } from "@contacts";
+import { IncrProgressFn, InitProgressFn } from "./ImportProgress";
+
import Debug from "debug";
const debug = Debug("kristweb:backup-results");
@@ -49,6 +51,11 @@
contacts: {}
};
+ constructor(
+ public onProgress: IncrProgressFn,
+ public initProgress: InitProgressFn
+ ) {}
+
/** Adds a message to the appropriate message map. */
private addMessage(src: MessageSource, uuid: string, message: BackupMessage): void {
debug("backup result msg [%s] for %s: %o", src, uuid, message);
diff --git a/src/pages/dev/DevPage.tsx b/src/pages/dev/DevPage.tsx
index b01c7fd..5ae7424 100644
--- a/src/pages/dev/DevPage.tsx
+++ b/src/pages/dev/DevPage.tsx
@@ -28,9 +28,18 @@
Delete all wallets with zero balance
+
+
{/* Delete all wallets */}
+
+
+
+ {/* Clear local storage */}
+
;
}