diff --git a/src/app/components/Toolbar/Features/Account/Account.tsx b/src/app/components/Toolbar/Features/Account/Account.tsx
index e48eda4ec5..f82e301cfc 100644
--- a/src/app/components/Toolbar/Features/Account/Account.tsx
+++ b/src/app/components/Toolbar/Features/Account/Account.tsx
@@ -18,12 +18,14 @@ interface AccountProps {
onClick: (address: string) => void
path?: number[]
isActive: boolean
+ displayBalance: boolean
displayCheckbox?: boolean
displayAccountNumber?: boolean
displayDerivation?: DerivationFormatterProps
displayManageButton?: {
onClickManage: (address: string) => void
}
+ name?: string
}
export const Account = memo((props: AccountProps) => {
@@ -64,7 +66,13 @@ export const Account = memo((props: AccountProps) => {
)}
+
+ {props.name && (
+
+ {props.name}
+
+ )}
{address}
@@ -83,9 +91,11 @@ export const Account = memo((props: AccountProps) => {
)}
-
- {props.balance ? : }
-
+ {props.displayBalance && (
+
+ {props.balance ? : }
+
+ )}
diff --git a/src/app/components/Toolbar/Features/Account/ImportableAccount.tsx b/src/app/components/Toolbar/Features/Account/ImportableAccount.tsx
index 322f32f111..0665b02ea7 100644
--- a/src/app/components/Toolbar/Features/Account/ImportableAccount.tsx
+++ b/src/app/components/Toolbar/Features/Account/ImportableAccount.tsx
@@ -14,6 +14,7 @@ export const ImportableAccount = ({
balance={account.balance}
onClick={onClick}
isActive={account.selected}
+ displayBalance={true}
displayCheckbox={true}
displayAccountNumber={true}
displayDerivation={{
diff --git a/src/app/components/Toolbar/Features/Account/ManageableAccount.tsx b/src/app/components/Toolbar/Features/Account/ManageableAccount.tsx
index c040b85946..969a8a494a 100644
--- a/src/app/components/Toolbar/Features/Account/ManageableAccount.tsx
+++ b/src/app/components/Toolbar/Features/Account/ManageableAccount.tsx
@@ -33,6 +33,7 @@ export const ManageableAccount = ({
onClick={onClick}
isActive={isActive}
path={wallet.path}
+ displayBalance={true}
displayManageButton={{
onClickManage: () => setLayerVisibility(true),
}}
diff --git a/src/app/components/Toolbar/Features/Account/__tests__/Account.test.tsx b/src/app/components/Toolbar/Features/Account/__tests__/Account.test.tsx
index 5f74eda567..b271a5ac05 100644
--- a/src/app/components/Toolbar/Features/Account/__tests__/Account.test.tsx
+++ b/src/app/components/Toolbar/Features/Account/__tests__/Account.test.tsx
@@ -16,6 +16,7 @@ const renderComponent = (store: any) =>
balance={{ available: '200', debonding: '0', delegations: '800', total: '1000' }}
onClick={() => {}}
isActive={false}
+ displayBalance={true}
displayCheckbox={true}
displayAccountNumber={true}
path={[44, 474, 0, 0, 0]}
diff --git a/src/app/components/Toolbar/Features/Contacts/AddContact.tsx b/src/app/components/Toolbar/Features/Contacts/AddContact.tsx
new file mode 100644
index 0000000000..f6ab275995
--- /dev/null
+++ b/src/app/components/Toolbar/Features/Contacts/AddContact.tsx
@@ -0,0 +1,56 @@
+import { useContext } from 'react'
+import { useDispatch } from 'react-redux'
+import { useTranslation } from 'react-i18next'
+import { Box } from 'grommet/es6/components/Box'
+import { Tabs } from 'grommet/es6/components/Tabs'
+import { Tab } from 'grommet/es6/components/Tab'
+import { ResponsiveContext } from 'grommet/es6/contexts/ResponsiveContext'
+import { contactsActions } from 'app/state/contacts'
+import { Contact } from 'app/state/contacts/types'
+import { ResponsiveLayer } from '../../../ResponsiveLayer'
+import { ContactAccountForm } from './ContactAccountForm'
+import { layerOverlayMinHeight } from './layer'
+
+interface AddContactProps {
+ setLayerVisibility: (isVisible: boolean) => void
+}
+
+export const AddContact = ({ setLayerVisibility }: AddContactProps) => {
+ const { t } = useTranslation()
+ const isMobile = useContext(ResponsiveContext) === 'small'
+ const dispatch = useDispatch()
+ const submitHandler = (contact: Contact) => dispatch(contactsActions.add(contact))
+
+ return (
+ setLayerVisibility(false)}
+ onEsc={() => setLayerVisibility(false)}
+ animation="none"
+ background="background-front"
+ modal
+ position="top"
+ margin={isMobile ? 'none' : 'xlarge'}
+ >
+
+
+
+
+ setLayerVisibility(false)}
+ onSave={contract => {
+ submitHandler(contract)
+ setLayerVisibility(false)
+ }}
+ />
+
+
+
+
+
+ )
+}
diff --git a/src/app/components/Toolbar/Features/Contacts/ContactAccount.tsx b/src/app/components/Toolbar/Features/Contacts/ContactAccount.tsx
new file mode 100644
index 0000000000..7add98e756
--- /dev/null
+++ b/src/app/components/Toolbar/Features/Contacts/ContactAccount.tsx
@@ -0,0 +1,80 @@
+import { useContext, useState } from 'react'
+import { useDispatch } from 'react-redux'
+import { useTranslation } from 'react-i18next'
+import { Box } from 'grommet/es6/components/Box'
+import { ResponsiveContext } from 'grommet/es6/contexts/ResponsiveContext'
+import { Tabs } from 'grommet/es6/components/Tabs'
+import { Tab } from 'grommet/es6/components/Tab'
+import { contactsActions } from 'app/state/contacts'
+import { Contact } from 'app/state/contacts/types'
+import { Account } from '../Account/Account'
+import { ResponsiveLayer } from '../../../ResponsiveLayer'
+import { ContactAccountForm } from './ContactAccountForm'
+import { layerOverlayMinHeight } from './layer'
+
+interface ContactAccountProps {
+ contact: Contact
+}
+
+export const ContactAccount = ({ contact }: ContactAccountProps) => {
+ const { t } = useTranslation()
+ const [layerVisibility, setLayerVisibility] = useState(false)
+ const isMobile = useContext(ResponsiveContext) === 'small'
+ const dispatch = useDispatch()
+ const submitHandler = (contact: Contact) => dispatch(contactsActions.update(contact))
+ const deleteHandler = (address: string) => dispatch(contactsActions.delete(address))
+
+ return (
+
+ setLayerVisibility(true),
+ }}
+ isActive={false}
+ key={contact.address}
+ name={contact.name}
+ onClick={() => setLayerVisibility(true)}
+ />
+ {layerVisibility && (
+ setLayerVisibility(false)}
+ onEsc={() => setLayerVisibility(false)}
+ animation="none"
+ background="background-front"
+ modal
+ position="top"
+ margin={isMobile ? 'none' : 'xlarge'}
+ >
+
+
+
+
+ {
+ deleteHandler(address)
+ setLayerVisibility(false)
+ }}
+ onCancel={() => setLayerVisibility(false)}
+ onSave={contract => {
+ submitHandler(contract)
+ setLayerVisibility(false)
+ }}
+ />
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/components/Toolbar/Features/Contacts/ContactAccountForm.tsx b/src/app/components/Toolbar/Features/Contacts/ContactAccountForm.tsx
new file mode 100644
index 0000000000..e24b1037cb
--- /dev/null
+++ b/src/app/components/Toolbar/Features/Contacts/ContactAccountForm.tsx
@@ -0,0 +1,111 @@
+import { useState } from 'react'
+import { useSelector } from 'react-redux'
+import { useTranslation } from 'react-i18next'
+import { Box } from 'grommet/es6/components/Box'
+import { Button } from 'grommet/es6/components/Button'
+import { Form } from 'grommet/es6/components/Form'
+import { FormField } from 'grommet/es6/components/FormField'
+import { TextInput } from 'grommet/es6/components/TextInput'
+import { TextArea } from 'grommet/es6/components/TextArea'
+import { selectContactsList } from 'app/state/contacts/selectors'
+import { isValidAddress } from 'app/lib/helpers'
+import { Contact } from 'app/state/contacts/types'
+import { DeleteContact } from './DeleteContact'
+
+interface ContactAccountFormProps {
+ contact?: Contact
+ onDelete?: (address: string) => void
+ onCancel: () => void
+ onSave: (contact: Contact) => void
+}
+
+interface FormValue {
+ address: string
+ name: string
+}
+
+export const ContactAccountForm = ({ contact, onDelete, onCancel, onSave }: ContactAccountFormProps) => {
+ const { t } = useTranslation()
+ const [deleteLayerVisibility, setDeleteLayerVisibility] = useState(false)
+ const [value, setValue] = useState({ name: contact?.name || '', address: contact?.address || '' })
+ const contacts = useSelector(selectContactsList)
+
+ return (
+
+ )
+}
diff --git a/src/app/components/Toolbar/Features/Contacts/DeleteContact.tsx b/src/app/components/Toolbar/Features/Contacts/DeleteContact.tsx
new file mode 100644
index 0000000000..721f39c559
--- /dev/null
+++ b/src/app/components/Toolbar/Features/Contacts/DeleteContact.tsx
@@ -0,0 +1,48 @@
+import { useContext } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Box } from 'grommet/es6/components/Box'
+import { Button } from 'grommet/es6/components/Button'
+import { ResponsiveContext } from 'grommet/es6/contexts/ResponsiveContext'
+import { Text } from 'grommet/es6/components/Text'
+import { ResponsiveLayer } from '../../../ResponsiveLayer'
+
+interface DeleteContactProps {
+ onDelete: () => void
+ onCancel: () => void
+}
+
+export const DeleteContact = ({ onCancel, onDelete }: DeleteContactProps) => {
+ const { t } = useTranslation()
+ const isMobile = useContext(ResponsiveContext) === 'small'
+
+ return (
+
+
+
+
+ {t('toolbar.contacts.delete.title', 'Delete Contact')}
+
+
+ {t('toolbar.contacts.delete.description', 'Are you sure you want to delete this contact?')}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/components/Toolbar/Features/Contacts/index.tsx b/src/app/components/Toolbar/Features/Contacts/index.tsx
new file mode 100644
index 0000000000..76d7e3a0e4
--- /dev/null
+++ b/src/app/components/Toolbar/Features/Contacts/index.tsx
@@ -0,0 +1,59 @@
+import { useState, useContext } from 'react'
+import { useSelector } from 'react-redux'
+import { useTranslation } from 'react-i18next'
+import { Box } from 'grommet/es6/components/Box'
+import { Button } from 'grommet/es6/components/Button'
+import { ResponsiveContext } from 'grommet/es6/contexts/ResponsiveContext'
+import { Inbox } from 'grommet-icons/es6/icons/Inbox'
+import { selectContactsList } from 'app/state/contacts/selectors'
+import { ContactAccount } from './ContactAccount'
+import { AddContact } from './AddContact'
+import { layerScrollableAreaHeight } from './layer'
+
+const ContactsListEmptyState = () => {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {t('toolbar.contacts.emptyList', 'You have no contacts yet.')}
+
+ )
+}
+
+export const Contacts = () => {
+ const { t } = useTranslation()
+ const [layerVisibility, setLayerVisibility] = useState(false)
+ const contacts = useSelector(selectContactsList)
+ const isMobile = useContext(ResponsiveContext) === 'small'
+
+ return (
+ <>
+ {!contacts.length && (
+
+
+
+ )}
+ {!!contacts.length && (
+
+ {contacts.map(contact => (
+
+ ))}
+
+ )}
+
+
+ {layerVisibility && }
+ >
+ )
+}
diff --git a/src/app/components/Toolbar/Features/Contacts/layer.ts b/src/app/components/Toolbar/Features/Contacts/layer.ts
new file mode 100644
index 0000000000..ab2316903f
--- /dev/null
+++ b/src/app/components/Toolbar/Features/Contacts/layer.ts
@@ -0,0 +1,2 @@
+export const layerScrollableAreaHeight = '400px'
+export const layerOverlayMinHeight = '435px' // Keep child modals height in sync with parent modal
diff --git a/src/app/components/Toolbar/Features/SettingsButton/index.tsx b/src/app/components/Toolbar/Features/SettingsButton/index.tsx
index 8f2334225e..739f85ba4d 100644
--- a/src/app/components/Toolbar/Features/SettingsButton/index.tsx
+++ b/src/app/components/Toolbar/Features/SettingsButton/index.tsx
@@ -19,6 +19,7 @@ import { ResponsiveLayer } from '../../../ResponsiveLayer'
import { Tabs } from 'grommet/es6/components/Tabs'
import { Tab } from 'grommet/es6/components/Tab'
import { useTranslation } from 'react-i18next'
+import { Contacts } from '../Contacts'
export const SettingsButton = memo(() => {
const { t } = useTranslation()
@@ -58,6 +59,9 @@ export const SettingsButton = memo(() => {
setLayerVisibility(false)} />
+
+
+
diff --git a/src/app/pages/AccountPage/Features/SendTransaction/index.tsx b/src/app/pages/AccountPage/Features/SendTransaction/index.tsx
index 60e82ee1cf..e1296816db 100644
--- a/src/app/pages/AccountPage/Features/SendTransaction/index.tsx
+++ b/src/app/pages/AccountPage/Features/SendTransaction/index.tsx
@@ -3,6 +3,7 @@ import { useModal } from 'app/components/Modal'
import { transactionActions } from 'app/state/transaction'
import { selectTransaction } from 'app/state/transaction/selectors'
import { selectValidators } from 'app/state/staking/selectors'
+import { selectContactsList } from 'app/state/contacts/selectors'
import { Box } from 'grommet/es6/components/Box'
import { Button } from 'grommet/es6/components/Button'
import { Form } from 'grommet/es6/components/Form'
@@ -28,6 +29,7 @@ export function SendTransaction(props: SendTransactionProps) {
const { launchModal } = useModal()
const { error, success } = useSelector(selectTransaction)
const validators = useSelector(selectValidators)
+ const contacts = useSelector(selectContactsList)
const [recipient, setRecipient] = useState('')
const [amount, setAmount] = useState('')
const sendTransaction = () =>
@@ -83,6 +85,10 @@ export function SendTransaction(props: SendTransactionProps) {
>
contact.name)}
+ onSuggestionSelect={event =>
+ setRecipient(contacts.find(contact => contact.name === event.suggestion)?.address || '')
+ }
name="recipient"
value={recipient}
placeholder={t('account.sendTransaction.enterAddress', 'Enter an address')}
diff --git a/src/app/pages/AccountPage/__tests__/__snapshots__/index.test.tsx.snap b/src/app/pages/AccountPage/__tests__/__snapshots__/index.test.tsx.snap
index 9d56869da6..a0facbc39b 100644
--- a/src/app/pages/AccountPage/__tests__/__snapshots__/index.test.tsx.snap
+++ b/src/app/pages/AccountPage/__tests__/__snapshots__/index.test.tsx.snap
@@ -1211,12 +1211,15 @@ exports[` should match snapshot 1`] = `
class="c30"
>
diff --git a/src/app/state/contacts/index.ts b/src/app/state/contacts/index.ts
new file mode 100644
index 0000000000..940ad26800
--- /dev/null
+++ b/src/app/state/contacts/index.ts
@@ -0,0 +1,25 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { createSlice } from 'utils/@reduxjs/toolkit'
+import { ContactsState, Contact } from './types'
+
+export const initialState: ContactsState = {}
+
+const slice = createSlice({
+ name: 'contacts',
+ initialState,
+ reducers: {
+ add(state, action: PayloadAction) {
+ state[action.payload.address] = action.payload
+ },
+ update(state, action: PayloadAction) {
+ state[action.payload.address] = action.payload
+ },
+ delete(state, action: PayloadAction) {
+ delete state[action.payload]
+ },
+ },
+})
+
+export const { actions: contactsActions } = slice
+
+export default slice.reducer
diff --git a/src/app/state/contacts/selectors.ts b/src/app/state/contacts/selectors.ts
new file mode 100644
index 0000000000..69dc849027
--- /dev/null
+++ b/src/app/state/contacts/selectors.ts
@@ -0,0 +1,8 @@
+import { createSelector } from '@reduxjs/toolkit'
+
+import { RootState } from 'types'
+import { initialState } from '.'
+
+const selectSlice = (state: RootState) => state.contacts || initialState
+
+export const selectContactsList = createSelector([selectSlice], contacts => Object.values(contacts))
diff --git a/src/app/state/contacts/types.ts b/src/app/state/contacts/types.ts
new file mode 100644
index 0000000000..7250956e48
--- /dev/null
+++ b/src/app/state/contacts/types.ts
@@ -0,0 +1,9 @@
+export interface Contact {
+ address: string
+ name: string
+}
+
+/* --- STATE --- */
+export interface ContactsState {
+ [address: string]: Contact
+}
diff --git a/src/app/state/persist/index.ts b/src/app/state/persist/index.ts
index d45ee5ea31..5565626689 100644
--- a/src/app/state/persist/index.ts
+++ b/src/app/state/persist/index.ts
@@ -101,6 +101,7 @@ export function receivePersistedRootState(
): RootState {
return {
...prevState,
+ contacts: persistedRootState.contacts,
theme: persistedRootState.theme,
wallet: persistedRootState.wallet,
network: persistedRootState.network,
diff --git a/src/app/state/persist/saga.ts b/src/app/state/persist/saga.ts
index 46ecc7ec38..fb870d8220 100644
--- a/src/app/state/persist/saga.ts
+++ b/src/app/state/persist/saga.ts
@@ -116,6 +116,7 @@ function* resetRootState(action: ReturnType {
const persistedRootState: PersistedRootState = {
+ contacts: state.contacts,
theme: state.theme,
wallet: state.wallet,
network: state.network,
diff --git a/src/app/state/persist/syncTabs.ts b/src/app/state/persist/syncTabs.ts
index cdbc027aef..2c8a93a419 100644
--- a/src/app/state/persist/syncTabs.ts
+++ b/src/app/state/persist/syncTabs.ts
@@ -9,6 +9,7 @@ import {
import { networkActions } from 'app/state/network'
import { isSyncingTabsSupported, needsSyncingTabs, persistActions } from 'app/state/persist'
import { walletActions } from 'app/state/wallet'
+import { contactsActions } from 'app/state/contacts'
import { themeActions } from 'styles/theme/slice'
import {
createStateSyncMiddleware,
@@ -42,6 +43,9 @@ export function receiveInitialTabSyncState(
* before {@link receiveInitialTabSyncState}!
*/
export const whitelistTabSyncActions = [
+ contactsActions.add.type,
+ contactsActions.update.type,
+ contactsActions.delete.type,
themeActions.changeTheme.type,
walletActions.walletOpened.type,
walletActions.updateBalance.type,
@@ -59,7 +63,13 @@ const stateSyncConfig: StateSyncConfig = {
},
whitelist: whitelistTabSyncActions,
prepareState: (state: RootState): SyncedRootState => {
- return { theme: state.theme, wallet: state.wallet, network: state.network, persist: state.persist }
+ return {
+ contacts: state.contacts,
+ theme: state.theme,
+ wallet: state.wallet,
+ network: state.network,
+ persist: state.persist,
+ }
},
}
diff --git a/src/app/state/persist/types.ts b/src/app/state/persist/types.ts
index 337c9e8d5c..813ec52220 100644
--- a/src/app/state/persist/types.ts
+++ b/src/app/state/persist/types.ts
@@ -33,5 +33,6 @@ export interface PersistState {
enteredWrongPassword: boolean
}
-export interface PersistedRootState extends Pick {}
-export interface SyncedRootState extends Pick {}
+export interface PersistedRootState extends Pick {}
+export interface SyncedRootState
+ extends Pick {}
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 69c4b7e9f3..cb7e4aebd8 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -384,6 +384,27 @@
"lightMode": "Light mode"
},
"toolbar": {
+ "contacts": {
+ "add": "Add Contact",
+ "address": "Address",
+ "cancel": "Cancel",
+ "delete": {
+ "button": "Delete contact",
+ "confirm": "Yes, delete",
+ "description": "Are you sure you want to delete this contact?",
+ "title": "Delete Contact"
+ },
+ "emptyList": "You have no contacts yet.",
+ "manage": "Manage Contact",
+ "name": "Name",
+ "save": "Save",
+ "validation": {
+ "addressError": "Please enter a valid wallet address",
+ "addressNotUniqueError": "Address already exists",
+ "nameLengthError": "No more than 16 characters",
+ "required": "Field is required"
+ }
+ },
"networks": {
"local": "Local",
"mainnet": "Mainnet",
@@ -391,6 +412,7 @@
},
"settings": {
"cancel": "Cancel",
+ "contacts": "Contacts",
"exportPrivateKey": "Export Private Key",
"manageAccount": "Manage",
"myAccountsTab": "My Accounts"
diff --git a/src/store/reducers.ts b/src/store/reducers.ts
index 8c0262efe2..c0937a1093 100644
--- a/src/store/reducers.ts
+++ b/src/store/reducers.ts
@@ -11,6 +11,7 @@ import importAccountsReducer from 'app/state/importaccounts'
import networkReducer from 'app/state/network'
import paraTimesReducer from 'app/state/paratimes'
import stakingReducer from 'app/state/staking'
+import contactsReducer from 'app/state/contacts'
import transactionReducer from 'app/state/transaction'
import walletReducer from 'app/state/wallet'
import themeReducer from 'styles/theme/slice'
@@ -20,6 +21,7 @@ import { RootState } from 'types'
function createRootReducer() {
const rootReducer = combineReducers({
account: accountReducer,
+ contacts: contactsReducer,
createWallet: createWalletReducer,
fiatOnramp: fiatOnrampReducer,
fatalError: fatalErrorReducer,
diff --git a/src/types/RootState.ts b/src/types/RootState.ts
index 5be40ff836..534871977c 100644
--- a/src/types/RootState.ts
+++ b/src/types/RootState.ts
@@ -4,6 +4,7 @@ import { WalletState } from 'app/state/wallet/types'
import { CreateWalletState } from 'app/pages/CreateWalletPage/slice/types'
import { FiatOnrampState } from 'app/pages/FiatOnrampPage/slice/types'
import { AccountState } from 'app/state/account/types'
+import { ContactsState } from 'app/state/contacts/types'
import { NetworkState } from 'app/state/network/types'
import { TransactionState } from 'app/state/transaction/types'
import { ImportAccountsState } from 'app/state/importaccounts/types'
@@ -20,6 +21,7 @@ import { receiveInitialTabSyncState, whitelistTabSyncActions } from 'app/state/p
export interface RootState {
/** Stored slices, see {@link receivePersistedRootState} */
+ contacts: ContactsState
theme: ThemeState
wallet: WalletState
network: NetworkState