diff --git a/src/app/__tests__/__snapshots__/index.test.tsx.snap b/src/app/__tests__/__snapshots__/index.test.tsx.snap
index 00d2482a64..c5c4c86d5e 100644
--- a/src/app/__tests__/__snapshots__/index.test.tsx.snap
+++ b/src/app/__tests__/__snapshots__/index.test.tsx.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[` should render and match the snapshot 1`] = `
-
+
should render and match the snapshot 1`] = `
-
+
+
`;
diff --git a/src/app/components/AddEscrowForm/__tests__/index.test.tsx b/src/app/components/AddEscrowForm/__tests__/index.test.tsx
index fbe3955cec..8c72486ee5 100644
--- a/src/app/components/AddEscrowForm/__tests__/index.test.tsx
+++ b/src/app/components/AddEscrowForm/__tests__/index.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { ModalProvider } from 'app/components/Modal'
+import { ModalContainer } from 'app/components/Modal'
import { Validator } from 'app/state/staking/types'
import * as React from 'react'
import { Provider } from 'react-redux'
@@ -12,9 +12,8 @@ import { AddEscrowForm } from '..'
const renderComponent = (store: any, address: string, validatorStatus: Validator['status']) =>
render(
-
-
-
+
+
,
)
diff --git a/src/app/components/AddEscrowForm/index.tsx b/src/app/components/AddEscrowForm/index.tsx
index 54447266dc..b9738bf736 100644
--- a/src/app/components/AddEscrowForm/index.tsx
+++ b/src/app/components/AddEscrowForm/index.tsx
@@ -3,7 +3,8 @@
* AddEscrowForm
*
*/
-import { useModal } from 'app/components/Modal'
+import { Modal } from '../Modal/slice/types'
+import { modalActions } from '../Modal/slice'
import { parseRoseStringToBaseUnitString } from 'app/lib/helpers'
import { selectMinStaking } from 'app/state/network/selectors'
import { Validator } from 'app/state/staking/types'
@@ -24,7 +25,7 @@ interface Props {
export const AddEscrowForm = memo((props: Props) => {
const { t } = useTranslation()
- const { launchModal } = useModal()
+ const launchModal = (modal: Modal) => dispatch(modalActions.launch(modal))
const { error, success } = useSelector(selectTransaction)
const isTop20 = props.validatorRank <= 20
const [showNotice, setShowNotice] = useState(isTop20)
diff --git a/src/app/components/Modal/index.tsx b/src/app/components/Modal/index.tsx
index 31ba5f9275..4182a9915f 100644
--- a/src/app/components/Modal/index.tsx
+++ b/src/app/components/Modal/index.tsx
@@ -1,44 +1,79 @@
-import { createContext, useCallback, useContext, useState } from 'react'
+import { useEffect, useState } from 'react'
import { Box, Button, Layer, Paragraph } from 'grommet'
import { useTranslation } from 'react-i18next'
-import { Alert, Checkmark, Close } from 'grommet-icons'
+import { Alert, Checkmark, Close, Configure } from 'grommet-icons'
import { ModalHeader } from 'app/components/Header'
+import { AlertBox } from '../AlertBox'
+import { selectAllowDangerousSetting } from '../SettingsDialog/slice/selectors'
+import { useDispatch, useSelector } from 'react-redux'
+import { settingsActions } from '../SettingsDialog/slice'
+import { selectCurrentModal } from './slice/selectors'
+import { modalActions } from './slice'
-interface Modal {
- title: string
- description: string
- handleConfirm: () => void
- isDangerous: boolean
-}
-
-interface ModalContainerProps {
- modal: Modal
- closeModal: () => void
-}
-
-interface ModalContextProps {
- launchModal: (modal: Modal) => void
- closeModal: () => void
-}
+const ModalContainer = () => {
+ const dispatch = useDispatch()
+ const { t } = useTranslation()
+ const modal = useSelector(selectCurrentModal)
+ const allowDangerous = useSelector(selectAllowDangerousSetting)
+ const [secsLeft, setSecsLeft] = useState(0)
+ const closeModal = () => dispatch(modalActions.close())
+ const confirm = () => {
+ modal?.handleConfirm()
+ dispatch(modalActions.close())
+ }
+ const { isDangerous, mustWaitSecs } = modal || {}
-interface ModalProviderProps {
- children: React.ReactNode
-}
+ const forbidden = isDangerous && !allowDangerous
+ const waitingTime = forbidden
+ ? 0 // If the action is forbidden, there is nothing to wait for
+ : isDangerous
+ ? mustWaitSecs ?? 10 // For dangerous actions, we require 10 seconds of waiting, unless specified otherwise.
+ : mustWaitSecs ?? 0 // For normal, non-dangerous operations, just use what was specified
-const ModalContext = createContext({} as ModalContextProps)
+ const openSettings = () => {
+ dispatch(modalActions.stash())
+ dispatch(settingsActions.setOpen(true))
+ }
-const ModalContainer = ({ modal, closeModal }: ModalContainerProps) => {
- const { t } = useTranslation()
- const confirm = useCallback(() => {
- modal.handleConfirm()
- closeModal()
- }, [closeModal, modal])
+ useEffect(() => {
+ if (waitingTime) {
+ setSecsLeft(waitingTime)
+ const stopCounting = () => window.clearInterval(interval)
+ const interval = window.setInterval(
+ () =>
+ setSecsLeft(seconds => {
+ const remains = seconds - 1
+ if (!remains) stopCounting()
+ return remains
+ }),
+ 1000,
+ )
+ return stopCounting
+ }
+ }, [waitingTime])
+ if (!modal) return null
return (
{modal.title}
{modal.description}
+ {forbidden && (
+
+ {t(
+ 'dangerMode.youDontWantThis',
+ "You most probably don't want to do this, so I won't allow it. If you really do, then please enable the 'dangerous mode' in wallet settings, and try again.",
+ )}
+
+ )}
+ {isDangerous && allowDangerous && (
+
+ {t(
+ 'dangerMode.youCanButDoYouWant',
+ "You most probably shouldn't do this, but since you have specifically enabled 'dangerous mode' in wallet settings, we won't stop you.",
+ )}
+
+ )}
)
}
-const ModalProvider = (props: ModalProviderProps) => {
- const [modal, setModal] = useState(null)
- const closeModal = useCallback(() => {
- setModal(null)
- }, [])
-
- return (
-
- {props.children}
- {modal && }
-
- )
-}
-
-const useModal = () => {
- const context = useContext(ModalContext)
- if (context === undefined) {
- throw new Error('useModal must be used within a ModalProvider')
- }
-
- return context
-}
-
-export { ModalProvider, useModal }
+export { ModalContainer }
diff --git a/src/app/components/Modal/slice/index.ts b/src/app/components/Modal/slice/index.ts
new file mode 100644
index 0000000000..5305be0f5f
--- /dev/null
+++ b/src/app/components/Modal/slice/index.ts
@@ -0,0 +1,63 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { createSlice } from 'utils/@reduxjs/toolkit'
+
+import { Modal, ModalState } from './types'
+
+export const initialState: ModalState = {
+ current: null,
+ stash: [],
+}
+
+const slice = createSlice({
+ name: 'modal',
+ initialState,
+ reducers: {
+ /**
+ * Show a new modal
+ */
+ launch(state, action: PayloadAction) {
+ if (state.current) {
+ state.stash.push(state.current)
+ }
+ state.current = action.payload
+ },
+
+ /**
+ * Close the current modal
+ */
+ close(state) {
+ state.current = state.stash.pop() || null
+ },
+
+ /**
+ * Hide the current modal (with the intention of showing in again later)
+ *
+ * The semantics is the same as with git stash.
+ */
+ stash(state) {
+ if (!state.current) {
+ throw new Error("You can't call hideModal if no model is shown!")
+ }
+ state.stash.push(state.current)
+ state.current = null
+ },
+
+ /**
+ * Show the previously hidden modal again.
+ *
+ * The semantics is the same as with `git stash pop`.
+ */
+ stashPop(state) {
+ if (state.current) {
+ throw new Error("You can't call showModal when a modal is already visible!")
+ }
+ const latest = state.stash.pop()
+ if (!latest) return
+ state.current = latest
+ },
+ },
+})
+
+export const { actions: modalActions } = slice
+
+export default slice.reducer
diff --git a/src/app/components/Modal/slice/selectors.ts b/src/app/components/Modal/slice/selectors.ts
new file mode 100644
index 0000000000..46aa32cb33
--- /dev/null
+++ b/src/app/components/Modal/slice/selectors.ts
@@ -0,0 +1,8 @@
+import { createSelector } from '@reduxjs/toolkit'
+
+import { RootState } from 'types'
+import { initialState } from '.'
+
+const selectSlice = (state: RootState) => state.modal || initialState
+
+export const selectCurrentModal = createSelector([selectSlice], settings => settings.current)
diff --git a/src/app/components/Modal/slice/types.ts b/src/app/components/Modal/slice/types.ts
new file mode 100644
index 0000000000..fe56e5c438
--- /dev/null
+++ b/src/app/components/Modal/slice/types.ts
@@ -0,0 +1,24 @@
+export interface Modal {
+ title: string
+ description: string
+ handleConfirm: () => void
+
+ /**
+ * Is this a dangerous operation?
+ *
+ * If marked as such, it will only be possible to execute it if the wallet is configured to run in dangerous mode.
+ *
+ * It also automatically implies a mandatory waiting time of 10 sec, unless specified otherwise.
+ */
+ isDangerous: boolean
+
+ /**
+ * How long does the user have to wait before he can actually confirm the action?
+ */
+ mustWaitSecs?: number
+}
+
+export interface ModalState {
+ current: Modal | null
+ stash: Modal[]
+}
diff --git a/src/app/components/SettingsButton/index.tsx b/src/app/components/SettingsButton/index.tsx
new file mode 100644
index 0000000000..08defcd70e
--- /dev/null
+++ b/src/app/components/SettingsButton/index.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import { SidebarButton } from '../Sidebar'
+import { Configure } from 'grommet-icons'
+import { useTranslation } from 'react-i18next'
+import { SettingsDialog } from '../SettingsDialog'
+import { selectIsSettingsDialogOpen } from '../SettingsDialog/slice/selectors'
+import { useDispatch, useSelector } from 'react-redux'
+import { settingsActions } from '../SettingsDialog/slice'
+import { modalActions } from '../Modal/slice'
+
+export const SettingsButton = () => {
+ const dispatch = useDispatch()
+ const layerVisibility = useSelector(selectIsSettingsDialogOpen)
+ const { t } = useTranslation()
+ const setLayerVisibility = (value: boolean) => dispatch(settingsActions.setOpen(value))
+ const closeSettings = () => {
+ dispatch(settingsActions.setOpen(false))
+ dispatch(modalActions.stashPop())
+ }
+ return (
+ <>
+ }
+ label={t('menu.settings', 'Settings')}
+ onClick={() => setLayerVisibility(true)}
+ />
+ {layerVisibility && }
+ >
+ )
+}
diff --git a/src/app/components/SettingsDialog/index.tsx b/src/app/components/SettingsDialog/index.tsx
new file mode 100644
index 0000000000..dc5c48017c
--- /dev/null
+++ b/src/app/components/SettingsDialog/index.tsx
@@ -0,0 +1,80 @@
+import React, { useContext } from 'react'
+import { useTranslation } from 'react-i18next'
+import { ResponsiveLayer } from '../ResponsiveLayer'
+import { Box, Button, Heading, Paragraph, RadioButtonGroup, ResponsiveContext } from 'grommet'
+import { useDispatch, useSelector } from 'react-redux'
+import { selectAllowDangerousSetting } from './slice/selectors'
+import { Threats } from 'grommet-icons'
+import { settingsActions } from './slice'
+
+interface SettingsDialogProps {
+ closeHandler: () => void
+}
+
+export const SettingsDialog = (props: SettingsDialogProps) => {
+ const { t } = useTranslation()
+ const size = useContext(ResponsiveContext)
+
+ const dispatch = useDispatch()
+ const dangerousMode = useSelector(selectAllowDangerousSetting)
+
+ return (
+
+
+
+ {t('settings.dialog.title', 'Wallet settings')}
+
+
+ {t(
+ 'settings.dialog.description',
+ 'This is where you can configure the behavior of the Oasis Wallet.',
+ )}
+
+
+
+
+ {t(
+ 'dangerMode.description',
+ 'Dangerous mode: should the wallet let the user shoot himself in the foot?',
+ )}
+
+
+
+ {t('dangerMode.on', "On - Allow executing nonsensical actions. Don't blame Oasis!")}{' '}
+
+
+ ),
+ },
+ ]}
+ value={dangerousMode}
+ onChange={event => dispatch(settingsActions.setAllowDangerous(event.target.value === 'true'))}
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/components/SettingsDialog/slice/index.ts b/src/app/components/SettingsDialog/slice/index.ts
new file mode 100644
index 0000000000..3ff7f8c237
--- /dev/null
+++ b/src/app/components/SettingsDialog/slice/index.ts
@@ -0,0 +1,26 @@
+import { PayloadAction } from '@reduxjs/toolkit'
+import { createSlice } from 'utils/@reduxjs/toolkit'
+
+import { SettingsState } from './types'
+
+export const initialState: SettingsState = {
+ dialogOpen: false,
+ allowDangerous: false,
+}
+
+const slice = createSlice({
+ name: 'settings',
+ initialState,
+ reducers: {
+ setOpen(state, action: PayloadAction) {
+ state.dialogOpen = action.payload
+ },
+ setAllowDangerous(state, action: PayloadAction) {
+ state.allowDangerous = action.payload
+ },
+ },
+})
+
+export const { actions: settingsActions } = slice
+
+export default slice.reducer
diff --git a/src/app/components/SettingsDialog/slice/selectors.ts b/src/app/components/SettingsDialog/slice/selectors.ts
new file mode 100644
index 0000000000..cfd4a7af81
--- /dev/null
+++ b/src/app/components/SettingsDialog/slice/selectors.ts
@@ -0,0 +1,9 @@
+import { createSelector } from '@reduxjs/toolkit'
+
+import { RootState } from 'types'
+import { initialState } from '.'
+
+const selectSlice = (state: RootState) => state.settings || initialState
+
+export const selectAllowDangerousSetting = createSelector([selectSlice], settings => settings.allowDangerous)
+export const selectIsSettingsDialogOpen = createSelector([selectSlice], settings => settings.dialogOpen)
diff --git a/src/app/components/SettingsDialog/slice/types.ts b/src/app/components/SettingsDialog/slice/types.ts
new file mode 100644
index 0000000000..e4394c488e
--- /dev/null
+++ b/src/app/components/SettingsDialog/slice/types.ts
@@ -0,0 +1,4 @@
+export interface SettingsState {
+ dialogOpen: boolean
+ allowDangerous: boolean
+}
diff --git a/src/app/components/Sidebar/__tests__/__snapshots__/index.test.tsx.snap b/src/app/components/Sidebar/__tests__/__snapshots__/index.test.tsx.snap
index 2e2290d5d6..1350c30fe0 100644
--- a/src/app/components/Sidebar/__tests__/__snapshots__/index.test.tsx.snap
+++ b/src/app/components/Sidebar/__tests__/__snapshots__/index.test.tsx.snap
@@ -685,6 +685,39 @@ exports[` should match snapshot 1`] = `
+
+
+
+
+
+ menu.settings
+
+
+
+
{
const size = useContext(ResponsiveContext)
@@ -211,6 +212,7 @@ const SidebarFooter = (props: SidebarFooterProps) => {
+
}
label="GitHub"
diff --git a/src/app/index.tsx b/src/app/index.tsx
index 20fb36d776..62c0f6efff 100644
--- a/src/app/index.tsx
+++ b/src/app/index.tsx
@@ -20,7 +20,7 @@ import { AccountPage } from './pages/AccountPage'
import { CreateWalletPage } from './pages/CreateWalletPage'
import { HomePage } from './pages/HomePage'
import { OpenWalletPage } from './pages/OpenWalletPage'
-import { ModalProvider } from './components/Modal'
+import { ModalContainer } from './components/Modal'
import { useRouteRedirects } from './useRouteRedirects'
const AppMain = styled(Main)`
@@ -33,7 +33,7 @@ export function App() {
const size = useContext(ResponsiveContext)
return (
-
+ <>
-
+
+ >
)
}
diff --git a/src/app/pages/AccountPage/Features/SendTransaction/index.tsx b/src/app/pages/AccountPage/Features/SendTransaction/index.tsx
index 3a2b2d1a12..2ac2cc17c7 100644
--- a/src/app/pages/AccountPage/Features/SendTransaction/index.tsx
+++ b/src/app/pages/AccountPage/Features/SendTransaction/index.tsx
@@ -1,5 +1,5 @@
import { TransactionStatus } from 'app/components/TransactionStatus'
-import { useModal } from 'app/components/Modal'
+import { modalActions } from '../../../../components/Modal/slice'
import { transactionActions } from 'app/state/transaction'
import { selectTransaction } from 'app/state/transaction/selectors'
import { selectValidators } from 'app/state/staking/selectors'
@@ -9,6 +9,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { parseRoseStringToBaseUnitString } from 'app/lib/helpers'
+import { Modal } from '../../../../components/Modal/slice/types'
export interface SendTransactionProps {
isAddressInWallet: boolean
@@ -21,7 +22,7 @@ export function SendTransaction(props: SendTransactionProps) {
const { t } = useTranslation()
const dispatch = useDispatch()
- const { launchModal } = useModal()
+ const launchModal = (modal: Modal) => dispatch(modalActions.launch(modal))
const { error, success } = useSelector(selectTransaction)
const validators = useSelector(selectValidators)
const [recipient, setRecipient] = useState('')
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 74123c7725..469999d02a 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -121,6 +121,13 @@
"newMnemonic": "Generate a new mnemonic",
"thisIsYourPhrase": "This is your mnemonic"
},
+ "dangerMode": {
+ "description": "Dangerous mode: should the wallet let the user shoot himself in the foot?",
+ "off": "Off - Refuse to execute nonsensical actions",
+ "on": "On - Allow executing nonsensical actions. Don't blame Oasis!",
+ "youCanButDoYouWant": "You most probably shouldn't do this, but since you have specifically enabled 'dangerous mode' in wallet settings, we won't stop you.",
+ "youDontWantThis": "You most probably don't want to do this, so I won't allow it. If you really do, then please enable the 'dangerous mode' in wallet settings, and try again."
+ },
"delegations": {
"activeDelegations": "Active delegations",
"debondingDelegations": "Debonding delegations",
@@ -198,6 +205,7 @@
"menu": {
"closeWallet": "Close wallet",
"home": "Home",
+ "settings": "Settings",
"stake": "Stake",
"wallet": "Wallet"
},
@@ -234,6 +242,13 @@
"showPrivateKey": "Show private key"
}
},
+ "settings": {
+ "dialog": {
+ "close": "Close",
+ "description": "This is where you can configure the behavior of the Oasis Wallet.",
+ "title": "Wallet settings"
+ }
+ },
"theme": {
"darkMode": "Dark mode",
"lightMode": "Light mode"
diff --git a/src/store/configureStore.ts b/src/store/configureStore.ts
index 88a5a710a0..ede5a0e2b0 100644
--- a/src/store/configureStore.ts
+++ b/src/store/configureStore.ts
@@ -24,7 +24,13 @@ export function configureAppStore(state?: Partial) {
const store = configureStore({
reducer: createReducer(),
- middleware: getDefaultMiddleware => getDefaultMiddleware().concat(middlewares),
+ middleware: getDefaultMiddleware =>
+ getDefaultMiddleware({
+ serializableCheck: {
+ ignoredActionPaths: ['payload.handleConfirm'],
+ ignoredPaths: ['modal.current.handleConfirm', 'modal.stash'],
+ },
+ }).concat(middlewares),
devTools:
/* istanbul ignore next line */
process.env.NODE_ENV !== 'production',
diff --git a/src/store/reducers.ts b/src/store/reducers.ts
index eb5435e3cb..ccbd57890e 100644
--- a/src/store/reducers.ts
+++ b/src/store/reducers.ts
@@ -12,6 +12,8 @@ import stakingReducer from 'app/state/staking'
import transactionReducer from 'app/state/transaction'
import walletReducer from 'app/state/wallet'
import themeReducer from 'styles/theme/slice'
+import modalReducer from 'app/components/Modal/slice'
+import settingReducer from 'app/components/SettingsDialog/slice'
export function createReducer() {
const rootReducer = combineReducers({
@@ -24,6 +26,8 @@ export function createReducer() {
theme: themeReducer,
transaction: transactionReducer,
wallet: walletReducer,
+ modal: modalReducer,
+ settings: settingReducer,
})
return rootReducer
diff --git a/src/types/RootState.ts b/src/types/RootState.ts
index f3e356a3f7..ed31727099 100644
--- a/src/types/RootState.ts
+++ b/src/types/RootState.ts
@@ -8,6 +8,8 @@ import { TransactionState } from 'app/state/transaction/types'
import { ImportAccountsState } from 'app/state/importaccounts/types'
import { StakingState } from 'app/state/staking/types'
import { FatalErrorState } from 'app/state/fatalerror/types'
+import { SettingsState } from '../app/components/SettingsDialog/slice/types'
+import { ModalState } from '../app/components/Modal/slice/types'
// [IMPORT NEW CONTAINERSTATE ABOVE] < Needed for generating containers seamlessly
export interface RootState {
@@ -20,6 +22,8 @@ export interface RootState {
importAccounts: ImportAccountsState
staking: StakingState
fatalError: FatalErrorState
+ modal: ModalState
+ settings: SettingsState
// [INSERT NEW REDUCER KEY ABOVE] < Needed for generating containers seamlessly
}