diff --git a/packages/extension-polkagate/src/components/SVG/HideBalance.tsx b/packages/extension-polkagate/src/components/SVG/HideBalance.tsx index 6cac19e33..27e396e14 100644 --- a/packages/extension-polkagate/src/components/SVG/HideBalance.tsx +++ b/packages/extension-polkagate/src/components/SVG/HideBalance.tsx @@ -1,7 +1,9 @@ // Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors // SPDX-License-Identifier: Apache-2.0 -import { Grid, useTheme } from '@mui/material'; +/* eslint-disable react/jsx-max-props-per-line */ + +import { Grid, IconButton, useTheme } from '@mui/material'; import React from 'react'; import { HEADER_COMPONENT_STYLE } from '../../fullscreen/governance/FullScreenHeader'; @@ -25,7 +27,7 @@ interface IconProps { const ClosedEye = ({ color, size = 20 }: IconProps) => ( + - { hide - ? - : - } + + {hide + ? + : + } + ); diff --git a/packages/extension-polkagate/src/hooks/index.ts b/packages/extension-polkagate/src/hooks/index.ts index f49fcf665..81a1a7668 100644 --- a/packages/extension-polkagate/src/hooks/index.ts +++ b/packages/extension-polkagate/src/hooks/index.ts @@ -79,6 +79,7 @@ export { default as useNativeTokenPrice } from './useNativeTokenPrice'; export { default as useNeedsPutInFrontOf } from './useNeedsPutInFrontOf'; export { default as useNeedsRebag } from './useNeedsRebag'; export { default as useNFT } from './useNFT'; +export { default as useNotifications } from './useNotifications'; export { default as useNotifyOnChainChange } from './useNotifyOnChainChange'; export { default as useOutsideClick } from './useOutsideClick'; export { default as usePendingRewards } from './usePendingRewards'; diff --git a/packages/extension-polkagate/src/hooks/useNotifications.ts b/packages/extension-polkagate/src/hooks/useNotifications.ts new file mode 100644 index 000000000..2fc0aa63c --- /dev/null +++ b/packages/extension-polkagate/src/hooks/useNotifications.ts @@ -0,0 +1,352 @@ +// Copyright 2019-2024 @polkadot/extension-polkagate authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ReferendaStatus } from '../popup/notification/constant'; +import type { NotificationSettingType } from '../popup/notification/NotificationSettings'; +import type { DropdownOption } from '../util/types'; + +import { useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react'; + +import { AccountContext } from '../components'; +import { getStorage, setStorage } from '../components/Loading'; +import { DEFAULT_NOTIFICATION_SETTING, KUSAMA_NOTIFICATION_CHAIN, MAX_ACCOUNT_COUNT_NOTIFICATION, NOTIFICATION_SETTING_KEY, NOTIFICATIONS_KEY, SUBSCAN_SUPPORTED_CHAINS } from '../popup/notification/constant'; +import { generateReceivedFundNotifications, generateReferendaNotifications, generateStakingRewardNotifications, getPayoutsInformation, getReceivedFundsInformation, markMessagesAsRead, type PayoutsProp, type ReceivedFundInformation, type StakingRewardInformation, type TransfersProp, updateReferendas } from '../popup/notification/util'; +import { KUSAMA_GENESIS_HASH } from '../util/constants'; +import { sanitizeChainName } from '../util/utils'; +import { useWorker } from './useWorker'; +import { useGenesisHashOptions, useSelectedChains } from '.'; + +interface WorkerMessage { + functionName: string; + message: { + type: 'referenda'; + chainGenesis: string; + data: { refId: number; status: ReferendaStatus; }[]; + } +} + +export interface ReferendaNotificationType { + status?: ReferendaStatus; + refId?: number; + chainName: string; +} + +export interface NotificationMessageType { + chain?: DropdownOption; + type: 'referenda' | 'stakingReward' | 'receivedFund'; + payout?: PayoutsProp; + referenda?: ReferendaNotificationType; + receivedFund?: TransfersProp; + forAccount?: string; + extrinsicIndex?: string; + read: boolean; +} + +export interface NotificationsType { + notificationMessages: NotificationMessageType[] | undefined; + referendas: ReferendaNotificationType[] | null | undefined; + receivedFunds: ReceivedFundInformation[] | null | undefined; + stakingRewards: StakingRewardInformation[] | null | undefined; + latestLoggedIn: number | undefined; + isFirstTime: boolean | undefined; +} + +type NotificationActionType = + | { type: 'INITIALIZE'; } + | { type: 'CHECK_FIRST_TIME'; } + | { type: 'MARK_AS_READ'; } + | { type: 'LOAD_FROM_STORAGE'; payload: NotificationsType } + | { type: 'SET_REFERENDA'; payload: ReferendaNotificationType[] } + | { type: 'SET_RECEIVED_FUNDS'; payload: NotificationsType['receivedFunds'] } + | { type: 'SET_STAKING_REWARDS'; payload: NotificationsType['stakingRewards'] }; + +const initialNotificationState: NotificationsType = { + isFirstTime: undefined, + latestLoggedIn: undefined, + notificationMessages: undefined, + receivedFunds: undefined, + referendas: undefined, + stakingRewards: undefined +}; + +const notificationReducer = ( + state: NotificationsType, + action: NotificationActionType +): NotificationsType => { + switch (action.type) { + case 'INITIALIZE': + return { + isFirstTime: true, + latestLoggedIn: Math.floor(Date.now() / 1000), // timestamp must be in seconds not in milliseconds + notificationMessages: [], + receivedFunds: null, + referendas: null, + stakingRewards: null + }; + + case 'CHECK_FIRST_TIME': + return { ...state, isFirstTime: true }; + + case 'MARK_AS_READ': + return { ...state, notificationMessages: markMessagesAsRead(state.notificationMessages ?? []) }; + + case 'LOAD_FROM_STORAGE': + return action.payload; + + case 'SET_REFERENDA': { + const chainName = action.payload[0].chainName; + const anyAvailableRefs = state.referendas?.find(({ chainName: network }) => network === chainName); + + return { + ...state, + isFirstTime: false, + notificationMessages: anyAvailableRefs + ? [...generateReferendaNotifications(KUSAMA_NOTIFICATION_CHAIN, state.referendas, action.payload), ...(state.notificationMessages ?? [])] + : state.notificationMessages, + referendas: updateReferendas(state.referendas, action.payload, chainName) + }; + } + + case 'SET_RECEIVED_FUNDS': + return { + ...state, + isFirstTime: false, + notificationMessages: [...generateReceivedFundNotifications(state.latestLoggedIn ?? Math.floor(Date.now() / 1000), action.payload ?? []), ...(state.notificationMessages ?? [])], + receivedFunds: action.payload + }; + + case 'SET_STAKING_REWARDS': + return { + ...state, + isFirstTime: false, + notificationMessages: [...generateStakingRewardNotifications(state.latestLoggedIn ?? Math.floor(Date.now() / 1000), action.payload ?? []), ...(state.notificationMessages ?? [])], + stakingRewards: action.payload + }; + + default: + return state; + } +}; + +enum status { + NONE, + FETCHING, + FETCHED +} + +export default function useNotifications () { + const worker = useWorker(); + const selectedChains = useSelectedChains(); + const allChains = useGenesisHashOptions(false); + const { accounts } = useContext(AccountContext); + + const isGettingReceivedFundRef = useRef(status.NONE); // Flag to avoid duplicate calls of getReceivedFundsInformation + const isGettingPayoutsRef = useRef(status.NONE); // Flag to avoid duplicate calls of getPayoutsInformation + const initializedRef = useRef(false); // Flag to avoid duplicate initialization + const isSavingRef = useRef(false); // Flag to avoid duplicate save in the storage + + const chains = useMemo(() => { + if (!selectedChains) { + return undefined; + } + + return allChains + .filter(({ value }) => selectedChains.includes(value as string)) + .map(({ text, value }) => ({ text, value } as DropdownOption)); + }, [allChains, selectedChains]); + + const [settings, setSettings] = useState(); + const [defaultSettingFlag, setDefaultSettingFlag] = useState(false); + const [notifications, dispatchNotifications] = useReducer(notificationReducer, initialNotificationState); + + const notificationIsOff = useMemo(() => !settings || settings.enable === false || settings.accounts?.length === 0, [settings]); + + const markAsRead = useCallback(() => { + dispatchNotifications({ type: 'MARK_AS_READ' }); + }, []); + + const receivedFunds = useCallback(async () => { + if (chains && isGettingReceivedFundRef.current === status.NONE && settings?.accounts && settings.receivedFunds) { + isGettingReceivedFundRef.current = status.FETCHING; + + const filteredSupportedChains = chains.filter(({ text }) => { + const sanitized = sanitizeChainName(text)?.toLowerCase(); + + if (!sanitized) { + return false; + } + + return SUBSCAN_SUPPORTED_CHAINS.find((chainName) => chainName.toLowerCase() === sanitized); + }); + + const receivedFunds = await getReceivedFundsInformation(settings.accounts, filteredSupportedChains); + + isGettingReceivedFundRef.current = status.FETCHED; + dispatchNotifications({ + payload: receivedFunds, + type: 'SET_RECEIVED_FUNDS' + }); + } + }, [chains, settings?.accounts, settings?.receivedFunds]); + + const payoutsInfo = useCallback(async () => { + if (isGettingPayoutsRef.current === status.NONE && isGettingReceivedFundRef.current !== status.FETCHING && settings?.accounts && settings.stakingRewards && settings.stakingRewards.length !== 0) { + isGettingPayoutsRef.current = status.FETCHING; + + const payouts = await getPayoutsInformation(settings.accounts, settings.stakingRewards); + + isGettingPayoutsRef.current = status.FETCHED; + dispatchNotifications({ + payload: payouts, + type: 'SET_STAKING_REWARDS' + }); + } + }, [settings?.accounts, settings?.stakingRewards]); + + useEffect(() => { + const getSettings = async () => { + const savedSettings = await getStorage(NOTIFICATION_SETTING_KEY) as NotificationSettingType; + + if (!savedSettings) { + setDefaultSettingFlag(true); + + return; + } + + setSettings(savedSettings); + }; + + getSettings().catch(console.error); + }, []); + + useEffect(() => { + if (!defaultSettingFlag) { + return; + } + + const addresses = accounts.map(({ address }) => address).slice(0, MAX_ACCOUNT_COUNT_NOTIFICATION); + + setSettings({ + ...DEFAULT_NOTIFICATION_SETTING, // accounts is an empty array in the constant file + accounts: addresses + }); + }, [accounts, defaultSettingFlag]); + + useEffect(() => { + if (notificationIsOff || !settings || initializedRef.current) { + return; + } + + const loadSavedNotifications = async () => { + initializedRef.current = true; + + try { + const savedNotifications = await getStorage(NOTIFICATIONS_KEY) as NotificationsType | undefined; + + savedNotifications + ? dispatchNotifications({ payload: savedNotifications, type: 'LOAD_FROM_STORAGE' }) + : dispatchNotifications({ type: 'INITIALIZE' }); // will happen only for the first time + } catch (error) { + console.error('Failed to load saved notifications:', error); + } + }; + + loadSavedNotifications().catch(console.error); + }, [notificationIsOff, settings]); + + useEffect(() => { + if (notificationIsOff || settings?.governance?.length === 0) { + return; + } + + const handelMessage = (event: MessageEvent) => { + try { + if (!event.data) { + return; + } + + const parsedMessage = JSON.parse(event.data) as WorkerMessage; + + if (parsedMessage.functionName !== NOTIFICATIONS_KEY) { + return; + } + + const { message } = parsedMessage; + + if (message.type !== 'referenda') { + return; + } + + const { chainGenesis, data } = message; + + if (settings?.governance?.find(({ value }) => value === chainGenesis)) { + const chainName = chainGenesis === KUSAMA_GENESIS_HASH ? 'kusama' : 'polkadot'; + const payload = data.map((item) => ({ ...item, chainName })); + + dispatchNotifications({ + payload, + type: 'SET_REFERENDA' + }); + } + } catch (error) { + console.error('Error processing worker message:', error); + } + }; + + worker.addEventListener('message', handelMessage); + + return () => { + worker.removeEventListener('message', handelMessage); + }; + }, [notificationIsOff, settings?.governance, worker]); + + useEffect(() => { + if (notificationIsOff || !settings) { + return; + } + + if (settings.receivedFunds) { + receivedFunds().catch(console.error); + } else { + isGettingReceivedFundRef.current = status.FETCHED; + } + + if (settings.stakingRewards?.length !== 0) { + payoutsInfo().catch(console.error); + } + }, [notificationIsOff, payoutsInfo, receivedFunds, settings]); + + useEffect(() => { + const handleBeforeUnload = () => { + const notificationIsInitializing = notifications.isFirstTime && notifications.referendas?.length === 0; + + if (!isSavingRef.current && !notificationIsInitializing) { + isSavingRef.current = true; + + const dataToSave = notifications; + + dataToSave.latestLoggedIn = Math.floor(Date.now() / 1000); // timestamp must be in seconds not in milliseconds + + setStorage(NOTIFICATIONS_KEY, dataToSave) + .then(() => { + console.log('Notifications saved successfully on unload.'); + }) + .catch((error) => { + console.error('Failed to save notifications on unload:', error); + }); + } + }; + + // Add event listener for the 'beforeunload' event + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [notifications]); + + return { + markAsRead, + notifications + }; +} diff --git a/packages/extension-polkagate/src/partials/AccountMenu.tsx b/packages/extension-polkagate/src/partials/AccountMenu.tsx index 7e0c6592f..963f1484a 100644 --- a/packages/extension-polkagate/src/partials/AccountMenu.tsx +++ b/packages/extension-polkagate/src/partials/AccountMenu.tsx @@ -25,7 +25,7 @@ interface Props { address: string; } -const Transition = React.forwardRef(function Transition (props: TransitionProps & { children: React.ReactElement;}, ref: React.Ref) { +const Transition = React.forwardRef(function Transition (props: TransitionProps & { children: React.ReactElement; }, ref: React.Ref) { return ; }); @@ -98,12 +98,27 @@ function AccountMenu ({ address, isMenuOpen, setShowMenu }: Props): React.ReactE return ( - - + + diff --git a/packages/extension-polkagate/src/partials/Menu.tsx b/packages/extension-polkagate/src/partials/Menu.tsx index 3f3e1df5a..1f58e75b5 100644 --- a/packages/extension-polkagate/src/partials/Menu.tsx +++ b/packages/extension-polkagate/src/partials/Menu.tsx @@ -5,20 +5,21 @@ import type { TransitionProps } from '@mui/material/transitions'; -import { Close as CloseIcon } from '@mui/icons-material'; +import { Close as CloseIcon, Lock as LockIcon } from '@mui/icons-material'; import { Dialog, Divider, Grid, IconButton, Slide, Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import React, { useCallback, useContext, useState } from 'react'; -import { AccountContext, ActionContext, MenuItem, TwoButtons, VaadinIcon, Warning } from '../components'; -import { setStorage } from '../components/Loading'; -import { useTranslation } from '../hooks'; -import { tieAccount } from '../messaging'; -import { TEST_NETS } from '../util/constants'; +import { AccountContext, ActionContext, FullScreenIcon, Infotip2, MenuItem, TwoButtons, VaadinIcon, Warning } from '../components'; +import { setStorage, updateStorage } from '../components/Loading'; +import { useExtensionLockContext } from '../context/ExtensionLockContext'; +import ThemeChanger from '../fullscreen/governance/partials/ThemeChanger'; +import { useIsLoginEnabled, useTranslation } from '../hooks'; +import { lockExtension, tieAccount } from '../messaging'; +import { NO_PASS_PERIOD, TEST_NETS } from '../util/constants'; import ImportAccSubMenu from './ImportAccSubMenu'; import NewAccountSubMenu from './NewAccountSubMenu'; import SettingSubMenu from './SettingSubMenu'; -import TLFActions from './TLFActions'; import VersionSocial from './VersionSocial'; interface Props { @@ -33,7 +34,12 @@ enum COLLAPSIBLE_MENUS { SETTING } -const Transition = React.forwardRef(function Transition (props: TransitionProps & { children: React.ReactElement;}, ref: React.Ref) { +export enum POPUP_MENUS { + NONE, + TEST_NET +} + +const Transition = React.forwardRef(function Transition (props: TransitionProps & { children: React.ReactElement; }, ref: React.Ref) { return ; }); @@ -43,12 +49,21 @@ function Menu ({ isMenuOpen, setShowMenu }: Props): React.ReactElement { const { t } = useTranslation(); const theme = useTheme(); const onAction = useContext(ActionContext); + const isLoginEnabled = useIsLoginEnabled(); + const { setExtensionLock } = useExtensionLockContext(); const [collapsedMenu, setCollapsedMenu] = useState(COLLAPSIBLE_MENUS.SETTING); const [isTestnetEnableConfirmed, setIsTestnetEnableConfirmed] = useState(); - const [showWarning, setShowWarning] = useState(); + const [showPopup, setShowPopup] = useState(POPUP_MENUS.NONE); const { accounts } = useContext(AccountContext); + const onLockExtension = useCallback((): void => { + updateStorage('loginInfo', { lastLoginTime: Date.now() - NO_PASS_PERIOD }).then(() => { + setExtensionLock(true); + lockExtension().catch(console.error); + }).catch(console.error); + }, [setExtensionLock]); + const toggleImportSubMenu = useCallback(() => { collapsedMenu === COLLAPSIBLE_MENUS.IMPORT_ACCOUNT ? setCollapsedMenu(COLLAPSIBLE_MENUS.NONE) @@ -76,18 +91,18 @@ function Menu ({ isMenuOpen, setShowMenu }: Props): React.ReactElement { }, [onAction]); const onEnableTestnetConfirm = useCallback(() => { - setShowWarning(false); + setShowPopup(POPUP_MENUS.NONE); setIsTestnetEnableConfirmed(true); setStorage('testnet_enabled', true).catch(console.error); }, []); const onEnableTestnetReject = useCallback(() => { - setShowWarning(false); + setShowPopup(POPUP_MENUS.NONE); setIsTestnetEnableConfirmed(false); }, []); const onEnableTestNetClick = useCallback(() => { - !isTestnetEnableConfirmed && setShowWarning(true); + !isTestnetEnableConfirmed && setShowPopup(POPUP_MENUS.TEST_NET); if (isTestnetEnableConfirmed) { setStorage('testnet_enabled', false).catch(console.error); @@ -102,15 +117,65 @@ function Menu ({ isMenuOpen, setShowMenu }: Props): React.ReactElement { return ( - - - - {!showWarning + + + {showPopup === POPUP_MENUS.NONE ? <> + + + + + + {isLoginEnabled && + + + + + + + + } + <> + + + + + + + + + + + +
@@ -161,38 +226,36 @@ function Menu ({ isMenuOpen, setShowMenu }: Props): React.ReactElement { /> - : - - - {t('Warning')} - - - div': { pl: 0 }, textAlign: 'justify' }}> - - {t('Enabling testnet chains may cause instability or crashes since they\'re meant for testing. Proceed with caution. If issues arise, return here to disable the option.')} - - - - ('Confirm')} - secondaryBtnText={t('Reject')} - /> + : showPopup === POPUP_MENUS.TEST_NET && + + + + {t('Warning')} + + + div': { pl: 0 }, textAlign: 'justify' }}> + + {t('Enabling testnet chains may cause instability or crashes since they\'re meant for testing. Proceed with caution. If issues arise, return here to disable the option.')} + + + + ('Confirm')} + secondaryBtnText={t('Reject')} + /> + - } - - - ); diff --git a/packages/extension-polkagate/src/partials/SettingSubMenu.tsx b/packages/extension-polkagate/src/partials/SettingSubMenu.tsx index 69df306ca..e0f02dc77 100644 --- a/packages/extension-polkagate/src/partials/SettingSubMenu.tsx +++ b/packages/extension-polkagate/src/partials/SettingSubMenu.tsx @@ -5,6 +5,7 @@ import { faListCheck } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Notifications as NotificationsIcon } from '@mui/icons-material'; import { Collapse, Divider, Grid, useTheme } from '@mui/material'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; @@ -50,6 +51,10 @@ export default function SettingSubMenu ({ isTestnetEnabledChecked, onChange, set onAction('/login-password'); }, [onAction]); + const onManageNotifications = useCallback(() => { + onAction('/manage-notification'); + }, [onAction]); + const onChangeNotification = useCallback((value: string | number): void => { const _value = value as string; @@ -73,63 +78,63 @@ export default function SettingSubMenu ({ isTestnetEnabledChecked, onChange, set - - - - - - - - - - } - onClick={onAuthManagement} - text={t('Manage website access')} - /> - - - - } - onClick={onManageLoginPassword} - py='2px' - text={t('Manage login password')} - /> - + + + + + } + onClick={onAuthManagement} + py='0px' + text={t('Manage website access')} + /> + + } + onClick={onManageLoginPassword} + py='0px' + text={t('Manage login password')} + /> + + } + onClick={onManageNotifications} + py='0px' + text={t('Manage notifications')} + /> - - +