diff --git a/.vscode/settings.json b/.vscode/settings.json index db986d0..579c41e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -62,6 +62,7 @@ "timeago", "totalin", "totalout", + "treenode", "tsdoc", "typeahead", "uncategorised", diff --git a/public/locales/en.json b/public/locales/en.json index 1c52a50..9fabf24 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -189,6 +189,16 @@ } }, + "addressBook": { + "title": "Address book", + + "contactCount": "{{count, number}} contact", + "contactCount_plural": "{{count, number}} contacts", + "contactCountEmpty": "No contacts", + + "buttonAddContact": "Add contact" + }, + "myTransactions": { "title": "Transactions", "searchPlaceholder": "Search transactions...", @@ -712,7 +722,7 @@ "errorInvalidTypeObject": "This wallet was not an object!", "errorDecrypt": "This wallet could not be decrypted!", "errorPasswordDecrypt": "The password for this wallet could not be decrypted!", - "errorWalletJSON": "The decrypted data was not valid JSON!", + "errorDataJSON": "The decrypted data was not valid JSON!", "errorUnknownFormat": "This wallet uses an unknown or unsupported format!", "errorFormatMissing": "This wallet is missing a format!", "errorUsernameMissing": "This wallet is missing a username!", @@ -726,7 +736,24 @@ }, "contactMessages": { - "errorNYI": "The Address Book has not yet been implemented, so it was skipped." + "success": "Contact imported successfully.", + "successSkipped": "A contact with the same address ({{address}}) and settings already exists, so it was skipped.", + "successUpdated": "A contact with the same address ({{address}}) already exists. Its label was updated to \"{{label}}\"", + "successSkippedNoOverwrite": "A contact with the same address ({{address}}) already exists, and you chose not to overwrite the label, so it was skipped.", + "successImportSkipped": "A contact with the same address ({{address}}) was already imported, so it was skipped.", + + "warningSyncNode": "This contact had a custom sync node, which is not supported in KristWeb v2. The sync node was skipped.", + "warningIcon": "This contact had a custom icon, which is not supported in KristWeb v2. The icon was skipped.", + "warningLabelInvalid": "The label for this contact was invalid. The label was skipped.", + + "errorInvalidTypeString": "This contact was not a string!", + "errorInvalidTypeObject": "This contact was not an object!", + "errorDecrypt": "This contact could not be decrypted!", + "errorDataJSON": "The decrypted data was not valid JSON!", + "errorAddressMissing": "This contact is missing an address!", + "errorAddressInvalid": "This contact's address is invalid!", + "errorLimitReached": "You reached the contact limit. You currently cannot add any more contacts.", + "errorUnknown": "An unknown error occurred. See console for details." }, "results": { diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx index 6199280..7950f93 100644 --- a/src/global/AppRouter.tsx +++ b/src/global/AppRouter.tsx @@ -7,6 +7,7 @@ import { DashboardPage } from "@pages/dashboard/DashboardPage"; import { WalletsPage } from "@pages/wallets/WalletsPage"; +import { ContactsPage } from "@pages/contacts/ContactsPage"; import { SendTransactionPage } from "@pages/transactions/send/SendTransactionPage"; @@ -39,6 +40,7 @@ // My wallets, etc { path: "/wallets", name: "wallets", component: }, + { path: "/contacts", name: "contacts", component: }, { path: "/me/transactions", name: "myTransactions", component: }, { path: "/me/names", name: "myNames", diff --git a/src/global/StorageBroadcast.tsx b/src/global/StorageBroadcast.tsx index 7f6acf4..1a72ec1 100644 --- a/src/global/StorageBroadcast.tsx +++ b/src/global/StorageBroadcast.tsx @@ -3,8 +3,10 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { store } from "@app"; import * as actions from "@actions/WalletsActions"; +import * as contactActions from "@actions/ContactsActions"; import { getWalletKey, parseWallet, syncWallet } from "@wallets"; +import { getContactKey, parseContact } from "@contacts"; import Debug from "debug"; const debug = Debug("kristweb:storage-broadcast"); @@ -26,6 +28,21 @@ channel.postMessage(["deleteWallet", id]); } +export function broadcastAddContact(id: string): void { + debug("broadcasting deleteContact event for contact id %s", id); + channel.postMessage(["deleteContact", id]); +} + +export function broadcastEditContact(id: string): void { + debug("broadcasting editContact event for contact id %s", id); + channel.postMessage(["editContact", id]); +} + +export function broadcastDeleteContact(id: string): void { + debug("broadcasting deleteContact event for contact id %s", id); + channel.postMessage(["deleteContact", id]); +} + /** Component that manages a BroadcastChannel responsible for dispatching wallet * storage events (add, edit, delete) across tabs. */ export function StorageBroadcast(): JSX.Element | null { @@ -38,6 +55,9 @@ const [type, ...data] = e.data; if (type === "addWallet" || type === "editWallet") { + // --------------------------------------------------------------------- + // addWallet, editWallet + // --------------------------------------------------------------------- const id: string = data[0]; const key = getWalletKey(id); @@ -52,9 +72,34 @@ syncWallet(wallet, true); } else if (type === "deleteWallet") { + // --------------------------------------------------------------------- + // deleteWallet + // --------------------------------------------------------------------- const id: string = data[0]; debug("addWallet broadcast %s", id); store.dispatch(actions.removeWallet(id)); + } else if (type === "addContact" || type === "editContact") { + // --------------------------------------------------------------------- + // addContact, editContact + // --------------------------------------------------------------------- + const id: string = data[0]; + const key = getContactKey(id); + + // Load the contact from localStorage (the update should've been + // synchronous) + const contact = parseContact(id, localStorage.getItem(key)); + debug("%s broadcast %s", type, id); + + // Dispatch the new/updated contact to the Redux store + if (type === "addContact") store.dispatch(contactActions.addContact(contact)); + else store.dispatch(contactActions.updateContact({ id, contact })); + } else if (type === "deleteContact") { + // --------------------------------------------------------------------- + // deleteContact + // --------------------------------------------------------------------- + const id: string = data[0]; + debug("deleteContact broadcast %s", id); + store.dispatch(contactActions.removeContact(id)); } else { debug("received unknown broadcast msg type %s", type); } diff --git a/src/krist/contacts/Contact.ts b/src/krist/contacts/Contact.ts new file mode 100644 index 0000000..319ae04 --- /dev/null +++ b/src/krist/contacts/Contact.ts @@ -0,0 +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 +export interface Contact { + // UUID for this contact + id: string; + + address: string; + label?: string; + isName?: boolean; +} + +export interface ContactMap { [key: string]: Contact } + +/** Properties of Contact that are required to create a new contact. */ +export type ContactNewKeys = "address" | "label" | "isName"; +export type ContactNew = Pick; + +/** Properties of Contact that are allowed to be updated. */ +export type ContactUpdatableKeys = "address" | "label" | "isName"; +export const CONTACT_UPDATABLE_KEYS: ContactUpdatableKeys[] + = ["address", "label", "isName"]; +export type ContactUpdatable = Pick; diff --git a/src/krist/contacts/contactStorage.ts b/src/krist/contacts/contactStorage.ts new file mode 100644 index 0000000..bf11651 --- /dev/null +++ b/src/krist/contacts/contactStorage.ts @@ -0,0 +1,82 @@ +// 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 { store } from "@app"; +import * as actions from "@actions/ContactsActions"; + +import { TranslatedError } from "@utils/i18n"; + +import { Contact, ContactMap } from "."; +import { broadcastDeleteContact } from "@global/StorageBroadcast"; + +import Debug from "debug"; +const debug = Debug("kristweb:contact-storage"); + +/** Get the local storage key for a given contact. */ +export function getContactKey(contact: Contact | string): string { + const id = typeof contact === "string" ? contact : contact.id; + return `contact2-${id}`; +} + +/** Extract a contact ID from a local storage key. */ +const contactKeyRegex = /^contact2-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/; +export function extractContactKey(key: string): [string, string] | undefined { + const [, id] = contactKeyRegex.exec(key) || []; + return id ? [key, id] : undefined; +} + +export function parseContact(id: string, data: string | null): Contact { + if (data === null) // localStorage key was missing + throw new TranslatedError("masterPassword.walletStorageCorrupt"); + + try { + const contact: Contact = JSON.parse(data); + + // Validate the contact data actually makes sense + if (!contact || !contact.id || contact.id !== id) + throw new TranslatedError("masterPassword.walletStorageCorrupt"); + + return contact; + } catch (e) { + console.error(e); + + if (e.name === "SyntaxError") // Invalid JSON + throw new TranslatedError("masterPassword.errorStorageCorrupt"); + else throw e; // Unknown error + } +} + +/** Loads all available contacts from local storage. */ +export function loadContacts(): ContactMap { + // Find all `contact2` keys from local storage + const keysToLoad = Object.keys(localStorage) + .map(extractContactKey) + .filter(k => k !== undefined) as [string, string][]; + + const contacts = keysToLoad.map(([key, id]) => parseContact(id, localStorage.getItem(key))); + + // Convert to map with contact IDs + const contactMap: ContactMap = contacts.reduce((obj, c) => ({ ...obj, [c.id]: c }), {}); + + return contactMap; +} + +/** Saves a contact to local storage. */ +export function saveContact(contact: Contact): void { + const key = getContactKey(contact); + debug("saving contact key %s", key); + + const serialised = JSON.stringify(contact); + localStorage.setItem(key, serialised); +} + +/** Deletes a contact, removing it from local storage and dispatching the change + * to the Redux store. */ +export function deleteContact(contact: Contact): void { + const key = getContactKey(contact); + localStorage.removeItem(key); + + broadcastDeleteContact(contact.id); // Broadcast changes to other tabs + + store.dispatch(actions.removeContact(contact.id)); +} diff --git a/src/krist/contacts/functions/addContact.ts b/src/krist/contacts/functions/addContact.ts new file mode 100644 index 0000000..348075c --- /dev/null +++ b/src/krist/contacts/functions/addContact.ts @@ -0,0 +1,36 @@ +// 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 { v4 as uuid } from "uuid"; + +import { store } from "@app"; +import * as actions from "@actions/ContactsActions"; + +import { Contact, ContactNew, saveContact } from ".."; +import { broadcastAddContact } from "@global/StorageBroadcast"; + +/** + * Adds a new contact, saving it to locale storage, and dispatching the changes + * to the Redux store. + * + * @param contact - The information for the new contact. + */ +export function addContact(contact: ContactNew): Contact { + const id = uuid(); + + const newContact = { + id, + address: contact.address, + label: contact.label?.trim() || undefined, + isName: contact.isName + }; + + // Save the contact to local storage + saveContact(newContact); + broadcastAddContact(newContact.id); // Broadcast changes to other tabs + + // Dispatch the changes to the Redux store + store.dispatch(actions.addContact(newContact)); + + return newContact; +} diff --git a/src/krist/contacts/functions/editContact.ts b/src/krist/contacts/functions/editContact.ts new file mode 100644 index 0000000..e88f981 --- /dev/null +++ b/src/krist/contacts/functions/editContact.ts @@ -0,0 +1,64 @@ +// 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 { v4 as uuid } from "uuid"; + +import { store } from "@app"; +import * as actions from "@actions/ContactsActions"; + +import { Contact, ContactNew, saveContact } from ".."; +import { broadcastEditContact } from "@global/StorageBroadcast"; + +/** + * Edits a contact, saving it to local storage, and dispatching the changes to + * the Redux store. + * + * @param contact - The old contact information. + * @param updated - The new contact information. + */ +export function editContact( + contact: Contact, + updated: Contact +): void { + const finalContact = { + ...contact, + address: updated.address, + label: updated.label?.trim() || "", + isName: updated.isName + }; + + // Save the updated contact to local storage + saveContact(finalContact); + broadcastEditContact(contact.id); // Broadcast changes to other tabs + + // Dispatch the changes to the Redux store + store.dispatch(actions.updateContact({ id: contact.id, contact: finalContact })); +} + +/** + * Edits just a contact's label. This can be set to an empty string to be + * removed, or to `undefined` to use the existing value. + * + * @param contact - The old contact information. + * @param label - The new contact label. + */ +export function editContactLabel( + contact: Contact, + label: string | "" | undefined +): void { + const updatedLabel = label?.trim() === "" + ? undefined + : (label?.trim() || contact.label); + + const finalContact = { + ...contact, + label: updatedLabel + }; + + // Save the updated contact to local storage + saveContact(finalContact); + broadcastEditContact(contact.id); // Broadcast changes to other tabs + + // Dispatch the changes to the Redux store + store.dispatch(actions.updateContact({ id: contact.id, contact: finalContact })); +} diff --git a/src/krist/contacts/index.ts b/src/krist/contacts/index.ts new file mode 100644 index 0000000..04d9f56 --- /dev/null +++ b/src/krist/contacts/index.ts @@ -0,0 +1,8 @@ +// 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 +export * from "./Contact"; +export * from "./functions/addContact"; +export * from "./functions/editContact"; +export * from "./contactStorage"; +export * from "./utils"; diff --git a/src/krist/contacts/utils.ts b/src/krist/contacts/utils.ts new file mode 100644 index 0000000..cf9aa47 --- /dev/null +++ b/src/krist/contacts/utils.ts @@ -0,0 +1,32 @@ +// 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 { useSelector, shallowEqual } from "react-redux"; +import { RootState } from "@store"; + +import { Contact, ContactMap } from "."; + +export type ContactAddressMap = Record; + +export interface ContactsHookResponse { + contacts: ContactMap; + contactAddressMap: ContactAddressMap; + + contactAddressList: string[]; + joinedContactAddressList: string; +} + +/** Hook that fetches the contacts from the Redux store. */ +export function useContacts(): ContactsHookResponse { + const contacts = useSelector((s: RootState) => s.contacts.contacts, shallowEqual); + const contactAddressMap = Object.values(contacts) + .reduce((o, contact) => ({ ...o, [contact.address]: contact }), {}); + + const contactAddressList = Object.keys(contactAddressMap); + const joinedContactAddressList = contactAddressList.join(","); + + return { + contacts, contactAddressMap, + contactAddressList, joinedContactAddressList + }; +} diff --git a/src/krist/wallets/functions/editWallet.ts b/src/krist/wallets/functions/editWallet.ts index a75ea14..c3ee80b 100644 --- a/src/krist/wallets/functions/editWallet.ts +++ b/src/krist/wallets/functions/editWallet.ts @@ -65,11 +65,11 @@ * @param label - The new wallet label. * @param category - The new wallet category. */ -export async function editWalletLabel( +export function editWalletLabel( wallet: Wallet, label: string | "" | undefined, category?: string | "" | undefined -): Promise { +): void { const updatedLabel = label?.trim() === "" ? undefined : (label?.trim() || wallet.label); diff --git a/src/layout/sidebar/Sidebar.tsx b/src/layout/sidebar/Sidebar.tsx index 9369732..c3854d1 100644 --- a/src/layout/sidebar/Sidebar.tsx +++ b/src/layout/sidebar/Sidebar.tsx @@ -30,7 +30,7 @@ const sidebarItems: SidebarItemProps[] = [ { icon: , name: "dashboard", to: "/" }, { icon: , name: "myWallets", to: "/wallets" }, - { icon: , name: "addressBook", to: "/contacts", nyi: true }, + { icon: , name: "addressBook", to: "/contacts" }, { icon: , name: "transactions", to: "/me/transactions" }, { icon: , name: "names", to: "/me/names" }, { icon: , name: "mining", to: "/mining", nyi: true }, diff --git a/src/pages/backup/BackupResultsSummary.less b/src/pages/backup/BackupResultsSummary.less index 9195671..604ed66 100644 --- a/src/pages/backup/BackupResultsSummary.less +++ b/src/pages/backup/BackupResultsSummary.less @@ -4,7 +4,7 @@ @import (reference) "../../App.less"; .backup-results-summary { - .summary-wallets-imported .positive { + .summary-wallets-imported .positive, .summary-contacts-imported .positive { color: @kw-green; font-weight: bold; } diff --git a/src/pages/backup/BackupResultsSummary.tsx b/src/pages/backup/BackupResultsSummary.tsx index c984d91..1ae53b1 100644 --- a/src/pages/backup/BackupResultsSummary.tsx +++ b/src/pages/backup/BackupResultsSummary.tsx @@ -5,23 +5,34 @@ import { useTranslation, Trans } from "react-i18next"; -import { BackupResults } from "./backupResults"; +import { BackupResults, ResultType } from "./backupResults"; import "./BackupResultsSummary.less"; const { Paragraph } = Typography; +function getMessageCountByType( + results: BackupResults, + type: ResultType +): number { + let acc = 0; + + acc += Object.values(results.messages.wallets) + .reduce((acc, r) => acc + r.messages.filter(m => m.type === type).length, 0); + acc += Object.values(results.messages.contacts) + .reduce((acc, r) => acc + r.messages.filter(m => m.type === type).length, 0); + + return acc; +} + /** Provides a paragraph summarising the results of the backup import (e.g. the * amount of wallets imported, the amount of errors, etc.). */ export function BackupResultsSummary({ results }: { results: BackupResults }): JSX.Element { const { t } = useTranslation(); - // TODO: do this for contacts too - const { newWallets, skippedWallets } = results; - const warningCount = Object.values(results.messages.wallets) - .reduce((acc, r) => acc + r.messages.filter(m => m.type === "warning").length, 0); - const errorCount = Object.values(results.messages.wallets) - .reduce((acc, r) => acc + r.messages.filter(m => m.type === "error").length, 0); + const { newWallets, skippedWallets, newContacts, skippedContacts } = results; + const warningCount = getMessageCountByType(results, "warning"); + const errorCount = getMessageCountByType(results, "error"); return {/* New wallets imported count */} @@ -29,9 +40,7 @@ {newWallets > 0 ? ( - - {{ count: newWallets }} new wallet - + {{ count: newWallets }} new wallet was imported. ) @@ -45,7 +54,20 @@ } - {/* TODO: Show contact counts too (only if >0) */} + {/* New contacts imported count */} + {newContacts > 0 &&
+ + {{ count: newContacts }} new contact + was imported. + +
} + + {/* Skipped contacts count */} + {skippedContacts > 0 &&
+ + {{ count: skippedContacts }} contact was skipped. + +
} {/* Errors */} {errorCount > 0 &&
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 */} + + {/* 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/*"],