diff --git a/src/pages/backup/BackupResultsTree.tsx b/src/pages/backup/BackupResultsTree.tsx
index 2d5cff0..86bc738 100644
--- a/src/pages/backup/BackupResultsTree.tsx
+++ b/src/pages/backup/BackupResultsTree.tsx
@@ -20,7 +20,7 @@
results: BackupResults;
}
-const CLEAN_ID_RE = /^(?:[Ww]allet\d*|[Ff]riend\d*)-/;
+const CLEAN_ID_RE = /^(?:[Ww]allet\d*|[Ff]riend\d*|[Cc]ontact\d*)-/;
/** Maps the different types of message results (success, warning, error)
* to icons. */
@@ -65,45 +65,60 @@
return null;
}
+function getTreeItem(
+ t: TFunction, i18n: i18n,
+ results: BackupResults,
+ type: "wallets" | "contacts",
+ id: string,
+): DataNode {
+ // The IDs are the keys of the backup, which may begin with prefixes like
+ // "Wallet-"; remove those for cleanliness
+ const cleanID = id.replace(CLEAN_ID_RE, "");
+ const resultSet = results.messages[type][id];
+ const { label, messages } = resultSet;
+ const messageNodes: DataNode[] = [];
+
+ for (let i = 0; i < messages.length; i++) {
+ const { type, message, error } = messages[i];
+ const icon = getMessageIcon(type);
+ const title = getMessageTitle(t, i18n, message, error);
+
+ messageNodes.push({
+ key: `${type}-${cleanID}-${i}`,
+ title,
+ icon,
+ isLeaf: true,
+ className: "backup-results-tree-message"
+ });
+ }
+
+ return {
+ key: `${type}-${cleanID}`,
+ title: t(
+ type === "wallets"
+ ? "import.results.treeWallet"
+ : "import.results.treeContact",
+ { id: label || cleanID }
+ ),
+ children: messageNodes
+ };
+}
+
/** Converts the backup results into a tree of messages, grouped by wallet
* and contact UUID. */
function getTreeData(
t: TFunction, i18n: i18n,
results: BackupResults
): DataNode[] {
- // Add the wallet messages data
const out: DataNode[] = [];
- for (const id in results.messages.wallets) {
- // The IDs are the keys of the backup, which may begin with prefixes like
- // "Wallet-"; remove those for cleanliness
- const cleanID = id.replace(CLEAN_ID_RE, "");
- const resultSet = results.messages.wallets[id];
- const { label, messages } = resultSet;
- const messageNodes: DataNode[] = [];
+ // Add the wallet messages data
+ for (const id in results.messages.wallets)
+ out.push(getTreeItem(t, i18n, results, "wallets", id));
- for (let i = 0; i < messages.length; i++) {
- const { type, message, error } = messages[i];
- const icon = getMessageIcon(type);
- const title = getMessageTitle(t, i18n, message, error);
-
- messageNodes.push({
- key: `wallets-${cleanID}-${i}`,
- title,
- icon,
- isLeaf: true,
- className: "backup-results-tree-message"
- });
- }
-
- out.push({
- key: `wallets-${cleanID}`,
- title: t("import.results.treeWallet", { id: label || cleanID }),
- children: messageNodes
- });
- }
-
- // TODO: Add the address book contacts data
+ // Add the contact messages data
+ for (const id in results.messages.contacts)
+ out.push(getTreeItem(t, i18n, results, "contacts", id));
return out;
}
diff --git a/src/pages/backup/backupExport.ts b/src/pages/backup/backupExport.ts
index 676ca01..89f66ce 100644
--- a/src/pages/backup/backupExport.ts
+++ b/src/pages/backup/backupExport.ts
@@ -6,6 +6,7 @@
export async function backupExport(): Promise
{
const { salt, tester } = store.getState().masterPassword;
const { wallets } = store.getState().wallets;
+ const { contacts } = store.getState().contacts;
// Get the wallets, skipping those with dontSave set to true
const finalWallets = Object.fromEntries(Object.entries(wallets)
@@ -18,7 +19,7 @@
salt, tester,
wallets: finalWallets,
- contacts: {} // TODO
+ contacts
};
// Convert to base64'd JSON
diff --git a/src/pages/backup/backupFormats.ts b/src/pages/backup/backupFormats.ts
index 596cbd1..5fd3f7b 100644
--- a/src/pages/backup/backupFormats.ts
+++ b/src/pages/backup/backupFormats.ts
@@ -2,6 +2,7 @@
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { Wallet } from "@wallets";
+import { Contact } from "@contacts";
// The values here are the translation keys for the formats.
export enum BackupFormatType {
@@ -21,7 +22,7 @@
// KristWeb v1
// =============================================================================
-// https://github.com/tmpim/KristWeb/blob/696a402690cb4a317234ecd59ed85d7f03de1b70/src/js/wallet/model.js
+// https://github.com/tmpim/KristWeb/blob/696a402/src/js/wallet/model.js
export interface KristWebV1Wallet {
address?: string;
label?: string;
@@ -35,6 +36,15 @@
position?: number;
}
+// https://github.com/tmpim/KristWeb/blob/696a402/src/js/friends/model.js
+export interface KristWebV1Contact {
+ address?: string;
+ label?: string;
+ icon?: string;
+ isName?: boolean;
+ syncNode?: string;
+}
+
export interface BackupKristWebV1 extends Backup {
type: BackupFormatType.KRISTWEB_V1;
@@ -51,14 +61,14 @@
// =============================================================================
export type KristWebV2Wallet = Wallet;
+export type KristWebV2Contact = Contact;
export interface BackupKristWebV2 extends Backup {
type: BackupFormatType.KRISTWEB_V2;
version: 2;
wallets: Record;
- // TODO:
- // contacts: Record;
+ contacts: Record;
}
export const isBackupKristWebV2 = (backup: Backup): backup is BackupKristWebV2 =>
backup.type === BackupFormatType.KRISTWEB_V2;
diff --git a/src/pages/backup/backupImport.ts b/src/pages/backup/backupImport.ts
index d9d01d0..ccd7e49 100644
--- a/src/pages/backup/backupImport.ts
+++ b/src/pages/backup/backupImport.ts
@@ -91,12 +91,15 @@
// Fetch the current set of wallets from the Redux store, to ensure the limit
// isn't reached, and to handle duplication checking.
const existingWallets = store.getState().wallets.wallets;
+ const existingContacts = store.getState().contacts.contacts;
// Used to encrypt new wallets/edited wallets
const appMasterPassword = store.getState().masterPassword.masterPassword;
// Used to check if an imported v1 wallet has a custom sync node
const appSyncNode = store.getState().node.syncNode;
// Used to re-calculate the addresses
const addressPrefix = store.getState().node.currency.address_prefix;
+ // Used to verify contact addresses
+ const nameSuffix = store.getState().node.currency.name_suffix;
// The app master password is required to import wallets. The backup import
// is usually done through an authenticated action anyway.
@@ -111,13 +114,17 @@
// Attempt to add the wallets
if (isBackupKristWebV1(backup)) {
await importV1Backup(
- existingWallets, appMasterPassword, appSyncNode, addressPrefix,
+ existingWallets, existingContacts,
+ appMasterPassword, appSyncNode,
+ addressPrefix, nameSuffix,
backup, masterPassword, noOverwrite,
results
);
} else if (isBackupKristWebV2(backup)) {
await importV2Backup(
- existingWallets, appMasterPassword, addressPrefix,
+ existingWallets, existingContacts,
+ appMasterPassword,
+ addressPrefix, nameSuffix,
backup, masterPassword, noOverwrite,
results
);
diff --git a/src/pages/backup/backupImportUtils.ts b/src/pages/backup/backupImportUtils.ts
index e513214..c1d7cc3 100644
--- a/src/pages/backup/backupImportUtils.ts
+++ b/src/pages/backup/backupImportUtils.ts
@@ -1,18 +1,23 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { BackupResults, BackupWalletError, MessageType } from "./backupResults";
+import {
+ BackupResults, BackupWalletError, BackupContactError, MessageType
+} from "./backupResults";
import {
ADDRESS_LIST_LIMIT,
WALLET_FORMATS, ADVANCED_FORMATS, WalletFormatName, formatNeedsUsername,
WalletMap, Wallet, WalletNew, calculateAddress, editWalletLabel, addWallet
} from "@wallets";
+import { ContactMap, Contact, addContact, editContactLabel } from "@contacts";
+
+import { isValidAddress, getNameParts } from "@utils/currency";
import Debug from "debug";
const debug = Debug("kristweb:backup-import-utils");
-interface ShorthandsRes {
+export interface Shorthands {
warn: (message: MessageType) => void;
success: (message: MessageType) => void;
@@ -23,19 +28,21 @@
export function getShorthands(
results: BackupResults,
uuid: string,
- version: string
-): ShorthandsRes {
- const warn = results.addWarningMessage.bind(results, "wallets", uuid);
- const success = results.addSuccessMessage.bind(results, "wallets", uuid);
+ version: string,
+ type: "wallet" | "contact" = "wallet"
+): Shorthands {
+ const typePlural = type === "wallet" ? "wallets" : "contacts";
+ const warn = results.addWarningMessage.bind(results, typePlural, uuid);
+ const success = results.addSuccessMessage.bind(results, typePlural, uuid);
- // Warnings to only be added if the wallet was actually added
+ // Warnings to only be added if the wallet/contact was actually added
const importWarnings: MessageType[] = [];
const importWarn = (msg: MessageType) => {
- debug("%s wallet %s added import warning:", version, uuid, msg);
+ debug("%s %s %s added import warning:", version, type, uuid, msg);
// Prepend the i18n key if it was just a string
importWarnings.push(typeof msg === "string"
- ? "import.walletMessages." + msg
+ ? `import.${type}Messages.${msg}`
: msg);
};
@@ -55,7 +62,7 @@
/** Verifies that the wallet's format is valid, upgrade it if necessary,
* and check if it's an advanced format. */
export function checkFormat(
- { importWarn }: ShorthandsRes,
+ { importWarn }: Shorthands,
wallet: { format?: string; username?: string }
): {
format: WalletFormatName;
@@ -79,18 +86,38 @@
return { format, username };
}
+interface ValidateContactAddressRes {
+ address: string;
+ isName: boolean;
+}
+
+export function validateContactAddress(
+ addressPrefix: string,
+ nameSuffix: string,
+ contact: { address?: string }
+): ValidateContactAddressRes {
+ const { address } = contact;
+ if (!str(address)) throw new BackupContactError("errorAddressMissing");
+
+ const nameParts = getNameParts(nameSuffix, address);
+ if (!isValidAddress(addressPrefix, address) && !nameParts)
+ throw new BackupContactError("errorAddressInvalid");
+
+ return { address, isName: !!nameParts };
+}
+
// -----------------------------------------------------------------------------
// OPTIONAL PROPERTY VALIDATION
// -----------------------------------------------------------------------------
export const isLabelValid = (label?: string): boolean =>
str(label) && label.trim().length < 32;
-export function checkLabelValid({ importWarn }: ShorthandsRes, label?: string): void {
+export function checkLabelValid({ importWarn }: Shorthands, label?: string): void {
const labelValid = isLabelValid(label);
if (label && !labelValid) importWarn("warningLabelInvalid");
}
export const isCategoryValid = isLabelValid;
-export function checkCategoryValid({ importWarn }: ShorthandsRes, category?: string): void {
+export function checkCategoryValid({ importWarn }: Shorthands, category?: string): void {
const categoryValid = isCategoryValid(category);
if (category && !categoryValid) importWarn("warningCategoryInvalid");
}
@@ -153,7 +180,7 @@
appMasterPassword: string,
addressPrefix: string,
- shorthands: ShorthandsRes,
+ { warn, success, importWarnings }: Shorthands,
results: BackupResults,
noOverwrite: boolean,
@@ -162,8 +189,6 @@
password: string,
newWalletData: WalletNew
): Promise {
- const { warn, success, importWarnings } = shorthands;
-
const { label } = newWalletData;
const labelValid = isLabelValid(label);
@@ -184,7 +209,7 @@
existingWallet.label, newLabel
);
- await editWalletLabel(existingWallet, newLabel);
+ editWalletLabel(existingWallet, newLabel);
return success({ key: "import.walletMessages.successUpdated", args: { address, label: newLabel }});
}
@@ -218,3 +243,78 @@
results.importedWallets.push(newWallet); // To keep track of limits
return success("import.walletMessages.success");
}
+
+// -----------------------------------------------------------------------------
+// CONTACT IMPORT PREPARATION
+// -----------------------------------------------------------------------------
+interface CheckExistingContactRes {
+ existingContact?: Contact;
+ existingImportContact?: Contact;
+}
+
+export function checkExistingContact(
+ existingContacts: ContactMap,
+ results: BackupResults,
+ address: string
+): CheckExistingContactRes {
+ // Check if a contact already exists, either in the Redux store, or our list
+ // of imported contacts during this backup import
+ const existingContact = Object.values(existingContacts)
+ .find(c => c.address === address);
+ const existingImportContact = results.importedContacts
+ .find(c => c.address === address);
+
+ return { existingContact, existingImportContact };
+}
+
+// -----------------------------------------------------------------------------
+// CONTACT IMPORT
+// -----------------------------------------------------------------------------
+export function finalContactImport(
+ existingContacts: ContactMap,
+
+ { warn, success, importWarnings }: Shorthands,
+ results: BackupResults,
+ noOverwrite: boolean,
+
+ existingContact: Contact | undefined,
+ address: string,
+ label: string | undefined,
+ isName: boolean
+): void {
+ const labelValid = isLabelValid(label);
+
+ // Handle duplicate contacts
+ if (existingContact) {
+ if (labelValid && existingContact.label !== label!.trim()) {
+ if (noOverwrite) {
+ results.skippedContacts++;
+ return success({ key: "import.contactMessages.successSkippedNoOverwrite", args: { address }});
+ } else {
+ const newLabel = label!.trim();
+ editContactLabel(existingContact, newLabel);
+ return success({ key: "import.contactMessages.successUpdated", args: { address, label: newLabel }});
+ }
+ } else {
+ results.skippedContacts++;
+ return success({ key: "import.contactMessages.successSkipped", args: { address }});
+ }
+ }
+
+ // Verify contact limit
+ const currentContactCount =
+ Object.keys(existingContacts).length + results.importedContacts.length;
+ if (currentContactCount >= ADDRESS_LIST_LIMIT)
+ throw new BackupContactError("errorLimitReached");
+
+ importWarnings.forEach(warn);
+
+ debug("adding new contact %s", address);
+ const newContact = addContact({ address, label, isName });
+ debug("new contact %s (%s)", newContact.id, newContact.address);
+
+ // Add it to the results
+ results.newContacts++;
+ results.importedContacts.push(newContact); // To keep track of limits
+ return success("import.contactMessages.success");
+}
diff --git a/src/pages/backup/backupImportV1.ts b/src/pages/backup/backupImportV1.ts
index 9cce1e0..44e1ad1 100644
--- a/src/pages/backup/backupImportV1.ts
+++ b/src/pages/backup/backupImportV1.ts
@@ -1,15 +1,21 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { BackupKristWebV1, KristWebV1Wallet } from "./backupFormats";
-import { BackupWalletError, BackupResults } from "./backupResults";
+import {
+ BackupKristWebV1, KristWebV1Wallet, KristWebV1Contact
+} from "./backupFormats";
+import {
+ BackupError, BackupWalletError, BackupContactError, BackupResults
+} from "./backupResults";
import { backupDecryptValue } from "./backupImport";
import {
- getShorthands, str, checkFormat, checkAddress, checkLabelValid,
- finalWalletImport
+ getShorthands, Shorthands, str, checkFormat, checkAddress, checkLabelValid,
+ finalWalletImport,
+ validateContactAddress, checkExistingContact, finalContactImport
} from "./backupImportUtils";
import { WalletMap } from "@wallets";
+import { ContactMap } from "@contacts";
import { isPlainObject, memoize } from "lodash-es";
import to from "await-to-js";
@@ -24,9 +30,11 @@
export async function importV1Backup(
// Things regarding the app's existing state
existingWallets: WalletMap,
+ existingContacts: ContactMap,
appMasterPassword: string,
appSyncNode: string,
addressPrefix: string,
+ nameSuffix: string,
// Things related to the backup
backup: BackupKristWebV1,
@@ -59,9 +67,88 @@
}
}
- // TODO: Import contacts
+ // Import contacts
+ 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);
+
+ 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);
+ }
+ }
}
+/** Decrypts and validates a V1 wallet or contact. */
+async function importV1Object(
+ backup: BackupKristWebV1,
+ masterPassword: string,
+
+ uuid: string,
+ rawObject: string, // The encrypted wallet/contact
+
+ mkErr: (key: string) => BackupError // Error constructor
+): Promise {
+ const { type } = backup;
+
+ // ---------------------------------------------------------------------------
+ // DECRYPTION, BASIC VALIDATION
+ // ---------------------------------------------------------------------------
+ // Validate the type of the wallet/contact data
+ if (!str(rawObject)) {
+ debug("v1 object %s had type %s", uuid, typeof rawObject, rawObject);
+ throw mkErr("errorInvalidTypeString");
+ }
+
+ // Attempt to decrypt the wallet/contact
+ const dec = await backupDecryptValue(type, masterPassword, rawObject);
+ if (dec === false) throw mkErr("errorDecrypt");
+
+ // Parse JSON, promisify to catch syntax errors
+ const [err, obj]: [Error | null, T] =
+ await to((async () => JSON.parse(dec))());
+ if (err) throw mkErr("errorDataJSON");
+
+ // Validate the type of the decrypted wallet/contact data
+ if (!isPlainObject(obj)) {
+ debug("v1 object %s had decrypted type %s", uuid, typeof obj);
+ throw mkErr("errorInvalidTypeObject");
+ }
+
+ return obj;
+}
+
+/** Validates and cleans the sync node */
+function validateSyncNode(
+ { importWarn }: Shorthands,
+ appSyncNode: string,
+ object: { syncNode?: string }
+): void {
+ // Check if the wallet or contact was using a custom sync node (ignoring
+ // http/https)
+ const cleanAppSyncNode = _cleanSyncNode(appSyncNode);
+ const { syncNode } = object;
+ if (syncNode && _cleanSyncNode(syncNode) !== cleanAppSyncNode)
+ importWarn("warningSyncNode");
+}
+
+// =============================================================================
+// WALLET IMPORT
+// =============================================================================
+
/** Imports a single wallet in the KristWeb v1 format. */
export async function importV1Wallet(
// Things regarding the app's existing state
@@ -80,33 +167,14 @@
results: BackupResults
): Promise {
- const { type } = backup;
const shorthands = getShorthands(results, uuid, "v1");
const { success, importWarn } = shorthands;
- // ---------------------------------------------------------------------------
- // DECRYPTION, BASIC VALIDATION
- // ---------------------------------------------------------------------------
- // Validate the type of the wallet data
- if (!str(rawWallet)) {
- debug("v1 wallet %s had type %s", uuid, typeof rawWallet, rawWallet);
- throw new BackupWalletError("errorInvalidTypeString");
- }
-
- // Attempt to decrypt the wallet
- const dec = await backupDecryptValue(type, masterPassword, rawWallet);
- if (dec === false) throw new BackupWalletError("errorDecrypt");
-
- // Parse JSON, promisify to catch syntax errors
- const [err, wallet]: [Error | null, KristWebV1Wallet] =
- await to((async () => JSON.parse(dec))());
- if (err) throw new BackupWalletError("errorWalletJSON");
-
- // Validate the type of the decrypted wallet data
- if (!isPlainObject(wallet)) {
- debug("v1 wallet %s had decrypted type %s", uuid, typeof wallet);
- throw new BackupWalletError("errorInvalidTypeObject");
- }
+ // Decrypt and validate the wallet
+ const wallet = await importV1Object(
+ backup, masterPassword, uuid, rawWallet,
+ key => new BackupWalletError(key)
+ );
// ---------------------------------------------------------------------------
// REQUIRED PROPERTY VALIDATION
@@ -122,11 +190,7 @@
// OPTIONAL PROPERTY VALIDATION
// ---------------------------------------------------------------------------
// Check if the wallet was using a custom sync node (ignoring http/https)
- const cleanAppSyncNode = _cleanSyncNode(appSyncNode);
- const { syncNode } = wallet;
- if (syncNode && _cleanSyncNode(syncNode) !== cleanAppSyncNode)
- importWarn("warningSyncNode");
-
+ validateSyncNode(shorthands, appSyncNode, wallet);
// Check if the wallet was using a custom icon
if (str(wallet.icon)) importWarn("warningIcon");
@@ -160,3 +224,67 @@
{ label, username, format }
);
}
+
+// =============================================================================
+// CONTACT IMPORT
+// =============================================================================
+
+/** Imports a single contact in the KristWeb v1 format. */
+export async function importV1Contact(
+ // Things regarding the app's existing state
+ existingContacts: ContactMap,
+ appSyncNode: string,
+ addressPrefix: string,
+ nameSuffix: string,
+
+ // Things related to the backup
+ backup: BackupKristWebV1,
+ masterPassword: string,
+ noOverwrite: boolean,
+
+ uuid: string,
+ rawContact: string, // The encrypted contact
+
+ results: BackupResults
+): Promise {
+ const shorthands = getShorthands(results, uuid, "v1", "contact");
+ const { success, importWarn } = shorthands;
+
+ // Decrypt and validate the contact
+ const contact = await importV1Object(
+ backup, masterPassword, uuid, rawContact,
+ key => new BackupContactError(key)
+ );
+
+ // Validate the address, which is required
+ const { address, isName } =
+ validateContactAddress(addressPrefix, nameSuffix, contact);
+
+ results.setResultLabel("contacts", uuid, address);
+
+ // Check for unsupported properties
+ validateSyncNode(shorthands, appSyncNode, contact);
+ if (str(contact.icon)) importWarn("warningIcon");
+
+ // Check that the label is valid
+ const { label } = contact;
+ checkLabelValid(shorthands, label);
+
+ // Check for existing contacts
+ const { existingContact, existingImportContact } = checkExistingContact(
+ existingContacts, results, address);
+
+ // Skip with no additional checks or updates if this contact was already
+ // handled by this backup import
+ if (existingImportContact) {
+ results.skippedContacts++;
+ return success({ key: "import.contactMessages.successImportSkipped", args: { address }});
+ }
+
+ // Import the contact
+ finalContactImport(
+ existingContacts,
+ shorthands, results, noOverwrite,
+ existingContact, address, label, isName
+ );
+}
diff --git a/src/pages/backup/backupImportV2.ts b/src/pages/backup/backupImportV2.ts
index 07272ae..c160769 100644
--- a/src/pages/backup/backupImportV2.ts
+++ b/src/pages/backup/backupImportV2.ts
@@ -1,14 +1,20 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { BackupKristWebV2, KristWebV2Wallet } from "./backupFormats";
-import { BackupWalletError, BackupResults } from "./backupResults";
+import {
+ BackupKristWebV2, KristWebV2Wallet, KristWebV2Contact
+} from "./backupFormats";
+import {
+ BackupWalletError, BackupContactError, BackupResults
+} from "./backupResults";
import {
getShorthands, str, checkFormat, checkAddress, checkLabelValid,
- checkCategoryValid, finalWalletImport
+ checkCategoryValid, finalWalletImport,
+ validateContactAddress, checkExistingContact, finalContactImport
} from "./backupImportUtils";
import { WalletMap, decryptWallet } from "@wallets";
+import { ContactMap } from "@contacts";
import { isPlainObject } from "lodash-es";
@@ -21,8 +27,10 @@
export async function importV2Backup(
// Things regarding the app's existing state
existingWallets: WalletMap,
+ existingContacts: ContactMap,
appMasterPassword: string,
addressPrefix: string,
+ nameSuffix: string,
// Things related to the backup
backup: BackupKristWebV2,
@@ -55,7 +63,29 @@
}
}
- // TODO: Import contacts
+ // Import contacts
+ for (const uuid in backup.contacts) {
+ if (!uuid || !UUID_REGEXP.test(uuid)) {
+ // Not a contact
+ debug("skipping v2 contact key %s", uuid);
+ continue;
+ }
+
+ const rawContact = backup.contacts[uuid];
+ debug("importing v2 contact uuid %s", uuid);
+
+ try {
+ await importV2Contact(
+ existingContacts, addressPrefix, nameSuffix,
+ backup, noOverwrite,
+ uuid, rawContact,
+ results
+ );
+ } catch (err) {
+ debug("error importing v2 contact", err);
+ results.addErrorMessage("contacts", uuid, undefined, err);
+ }
+ }
}
/** Imports a single wallet in the KristWeb v2 format. */
@@ -131,3 +161,61 @@
{ label, category, username, format }
);
}
+
+// =============================================================================
+// CONTACT IMPORT
+// =============================================================================
+
+/** Imports a single contact in the KristWeb v1 format. */
+export async function importV2Contact(
+ // Things regarding the app's existing state
+ existingContacts: ContactMap,
+ addressPrefix: string,
+ nameSuffix: string,
+
+ // Things related to the backup
+ backup: BackupKristWebV2,
+ noOverwrite: boolean,
+
+ uuid: string,
+ contact: KristWebV2Contact, // The contact object as found in the backup
+
+ results: BackupResults
+): Promise {
+ const shorthands = getShorthands(results, uuid, "v2", "contact");
+ const { success, importWarn } = shorthands;
+
+ // Validate the type of the contact data
+ if (!isPlainObject(contact)) {
+ debug("v2 contact %s had type %s", uuid, typeof contact, contact);
+ throw new BackupContactError("errorInvalidTypeObject");
+ }
+
+ // Validate the address, which is required
+ const { address, isName } =
+ validateContactAddress(addressPrefix, nameSuffix, contact);
+
+ results.setResultLabel("contacts", uuid, address);
+
+ // Check that the label is valid
+ const { label } = contact;
+ checkLabelValid(shorthands, label);
+
+ // Check for existing contacts
+ const { existingContact, existingImportContact } = checkExistingContact(
+ existingContacts, results, address);
+
+ // Skip with no additional checks or updates if this contact was already
+ // handled by this backup import
+ if (existingImportContact) {
+ results.skippedContacts++;
+ return success({ key: "import.contactMessages.successImportSkipped", args: { address }});
+ }
+
+ // Import the contact
+ finalContactImport(
+ existingContacts,
+ shorthands, results, noOverwrite,
+ existingContact, address, label, isName
+ );
+}
diff --git a/src/pages/backup/backupResults.ts b/src/pages/backup/backupResults.ts
index a1ec41b..2d04402 100644
--- a/src/pages/backup/backupResults.ts
+++ b/src/pages/backup/backupResults.ts
@@ -5,6 +5,7 @@
import { TranslatedError } from "@utils/i18n";
import { Wallet } from "@wallets";
+import { Contact } from "@contacts";
import Debug from "debug";
const debug = Debug("kristweb:backup-results");
@@ -26,14 +27,17 @@
export class BackupResults {
/** Number of new wallets that were added as a result of this import. */
public newWallets = 0;
+ public newContacts = 0;
/** Number of wallets from the backup that were skipped (not imported). */
public skippedWallets = 0;
+ public skippedContacts = 0;
/** Array of wallets that were successfully imported, used to handle
* duplication checking (since the Redux state isn't guaranteed to be up to
* date). */
public importedWallets: Wallet[] = [];
+ public importedContacts: Contact[] = [];
/** For both wallets and contacts, a map of wallet/contact UUIDs containing
* all the messages (success, warning, error). */
@@ -93,3 +97,7 @@
export class BackupWalletError extends BackupError {
constructor(message: string) { super("import.walletMessages." + message); }
}
+
+export class BackupContactError extends BackupError {
+ constructor(message: string) { super("import.contactMessages." + message); }
+}
diff --git a/src/pages/contacts/ContactsPage.tsx b/src/pages/contacts/ContactsPage.tsx
new file mode 100644
index 0000000..c18197b
--- /dev/null
+++ b/src/pages/contacts/ContactsPage.tsx
@@ -0,0 +1,48 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useState } from "react";
+import { Button } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+
+import { useTFns } from "@utils/i18n";
+
+import { PageLayout } from "@layout/PageLayout";
+
+import { useContacts } from "@contacts";
+
+/** Contact count subtitle */
+function ContactsPageSubtitle(): JSX.Element {
+ const { t, tStr, tKey } = useTFns("addressBook.");
+ const { contactAddressList } = useContacts();
+
+ const count = contactAddressList.length;
+
+ return <>{count > 0
+ ? t(tKey("contactCount"), { count })
+ : tStr("contactCountEmpty")
+ }>;
+}
+
+function ContactsPageExtraButtons(): JSX.Element {
+ const { tStr } = useTFns("addressBook.");
+ const [addContactVisible, setAddContactVisible] = useState(false);
+
+ return <>
+ {/* Add contact */}
+ }>
+ {tStr("buttonAddContact")}
+
+ {/* TODO: modal */}
+ >;
+}
+
+export function ContactsPage(): JSX.Element {
+ return }
+ extra={}
+ >
+
+ ;
+}
diff --git a/src/store/actions/ContactsActions.ts b/src/store/actions/ContactsActions.ts
new file mode 100644
index 0000000..6c74565
--- /dev/null
+++ b/src/store/actions/ContactsActions.ts
@@ -0,0 +1,15 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { createAction } from "typesafe-actions";
+
+import * as constants from "../constants";
+
+import { Contact, ContactMap, ContactUpdatable } from "@contacts";
+
+export const loadContacts = createAction(constants.LOAD_CONTACTS)();
+export const addContact = createAction(constants.ADD_CONTACT)();
+export const removeContact = createAction(constants.REMOVE_CONTACT)();
+
+export interface UpdateContactPayload { id: string; contact: ContactUpdatable }
+export const updateContact = createAction(constants.UPDATE_CONTACT)();
diff --git a/src/store/actions/index.ts b/src/store/actions/index.ts
index 2ac00af..e5edf27 100644
--- a/src/store/actions/index.ts
+++ b/src/store/actions/index.ts
@@ -3,6 +3,7 @@
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import * as masterPasswordActions from "./MasterPasswordActions";
import * as walletsActions from "./WalletsActions";
+import * as contactsActions from "./ContactsActions";
import * as settingsActions from "./SettingsActions";
import * as websocketActions from "./WebsocketActions";
import * as nodeActions from "./NodeActions";
@@ -11,6 +12,7 @@
const RootAction = {
masterPassword: masterPasswordActions,
wallets: walletsActions,
+ contacts: contactsActions,
settings: settingsActions,
websocket: websocketActions,
node: nodeActions,
diff --git a/src/store/constants.ts b/src/store/constants.ts
index 8a02720..fa11174 100644
--- a/src/store/constants.ts
+++ b/src/store/constants.ts
@@ -19,6 +19,13 @@
export const RECALCULATE_WALLETS = "RECALCULATE_WALLETS";
export const SET_LAST_TX_FROM = "SET_LAST_TX_FROM";
+// Contacts
+// ---
+export const LOAD_CONTACTS = "LOAD_CONTACTS";
+export const ADD_CONTACT = "ADD_CONTACT";
+export const REMOVE_CONTACT = "REMOVE_CONTACT";
+export const UPDATE_CONTACT = "UPDATE_CONTACT";
+
// Settings
// ---
export const SET_BOOLEAN_SETTING = "SET_BOOLEAN_SETTING";
diff --git a/src/store/init.ts b/src/store/init.ts
index 29a3cf4..c894db6 100644
--- a/src/store/init.ts
+++ b/src/store/init.ts
@@ -3,6 +3,7 @@
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { getInitialMasterPasswordState } from "./reducers/MasterPasswordReducer";
import { getInitialWalletsState } from "./reducers/WalletsReducer";
+import { getInitialContactsState } from "./reducers/ContactsReducer";
import { getInitialSettingsState } from "./reducers/SettingsReducer";
import { getInitialNodeState } from "./reducers/NodeReducer";
@@ -17,6 +18,7 @@
{
masterPassword: getInitialMasterPasswordState(),
wallets: getInitialWalletsState(),
+ contacts: getInitialContactsState(),
settings: getInitialSettingsState(),
node: getInitialNodeState()
},
diff --git a/src/store/reducers/ContactsReducer.ts b/src/store/reducers/ContactsReducer.ts
new file mode 100644
index 0000000..7d15b62
--- /dev/null
+++ b/src/store/reducers/ContactsReducer.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under GPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import * as actions from "@actions/ContactsActions";
+import { createReducer } from "typesafe-actions";
+
+import {
+ Contact, ContactMap, loadContacts, CONTACT_UPDATABLE_KEYS
+} from "@contacts";
+
+export interface State {
+ readonly contacts: ContactMap;
+}
+
+export function getInitialContactsState(): State {
+ const contacts = loadContacts();
+ return { contacts };
+}
+
+function assignNewContactProperties(
+ state: State,
+ id: string,
+ partialContact: Partial,
+ allowedKeys?: (keyof Contact)[]
+) {
+ // Fetch the old contact and assign the new properties
+ const { [id]: contact } = state.contacts;
+ const newContact = allowedKeys
+ ? allowedKeys.reduce((o, key) => partialContact[key] !== undefined
+ ? { ...o, [key]: partialContact[key] }
+ : o, {})
+ : partialContact;
+
+ return {
+ ...state,
+ contacts: {
+ ...state.contacts,
+ [id]: { ...contact, ...newContact }
+ }
+ };
+}
+
+export const ContactsReducer = createReducer({ contacts: {} } as State)
+ // Load contacts
+ .handleAction(actions.loadContacts, (state, { payload }) => ({
+ ...state,
+ contacts: {
+ ...state.contacts,
+ ...payload
+ }
+ }))
+ // Add contact
+ .handleAction(actions.addContact, (state, { payload }) => ({
+ ...state,
+ contacts: {
+ ...state.contacts,
+ [payload.id]: payload
+ }
+ }))
+ // Remove contact
+ .handleAction(actions.removeContact, (state, { payload }) => {
+ // Get the contacts without the one we want to remove
+ const { [payload]: _, ...contacts } = state.contacts;
+ return { ...state, contacts };
+ })
+ // Update contact
+ .handleAction(actions.updateContact, (state, { payload }) =>
+ assignNewContactProperties(state, payload.id, payload.contact, CONTACT_UPDATABLE_KEYS));
diff --git a/src/store/reducers/RootReducer.ts b/src/store/reducers/RootReducer.ts
index 1bb3411..1fcb703 100644
--- a/src/store/reducers/RootReducer.ts
+++ b/src/store/reducers/RootReducer.ts
@@ -5,6 +5,7 @@
import { MasterPasswordReducer } from "./MasterPasswordReducer";
import { WalletsReducer } from "./WalletsReducer";
+import { ContactsReducer } from "./ContactsReducer";
import { SettingsReducer } from "./SettingsReducer";
import { WebsocketReducer } from "./WebsocketReducer";
import { NodeReducer } from "./NodeReducer";
@@ -13,6 +14,7 @@
export default combineReducers({
masterPassword: MasterPasswordReducer,
wallets: WalletsReducer,
+ contacts: ContactsReducer,
settings: SettingsReducer,
websocket: WebsocketReducer,
node: NodeReducer,
diff --git a/tsconfig.extend.json b/tsconfig.extend.json
index 4c5d57f..b34e248 100644
--- a/tsconfig.extend.json
+++ b/tsconfig.extend.json
@@ -18,6 +18,8 @@
"@api/*": ["./krist/api/*"],
"@wallets": ["./krist/wallets"],
"@wallets/*": ["./krist/wallets/*"],
+ "@contacts": ["./krist/contacts"],
+ "@contacts/*": ["./krist/contacts/*"],
"@krist/*": ["./krist/*"],
"@global/*": ["./global/*"],