diff --git a/packages/extension-base/src/defaults.ts b/packages/extension-base/src/defaults.ts
index 7f831a9a8..a02b7bdf7 100644
--- a/packages/extension-base/src/defaults.ts
+++ b/packages/extension-base/src/defaults.ts
@@ -17,7 +17,8 @@ const START_WITH_PATH = [
'/send/',
'/settingsfs/',
'/stake/',
- '/socialRecovery/'
+ '/socialRecovery/',
+ '/notification/'
] as const;
const ROOT_PATH = [
diff --git a/packages/extension-polkagate/src/components/ActionButton.tsx b/packages/extension-polkagate/src/components/ActionButton.tsx
index ea6037cc6..b5f9f990a 100644
--- a/packages/extension-polkagate/src/components/ActionButton.tsx
+++ b/packages/extension-polkagate/src/components/ActionButton.tsx
@@ -11,8 +11,43 @@ import { noop } from '@polkadot/util';
import { useIsDark, useIsExtensionPopup, useIsHovered } from '../hooks';
import TwoToneText from './TwoToneText';
+interface IconElementProp {
+ iconVariant?: 'Bulk' | 'Broken' | 'TwoTone' | 'Outline' | 'Linear' | 'Bold';
+ iconVariantOnHover?: 'Bulk' | 'Broken' | 'TwoTone' | 'Outline' | 'Linear' | 'Bold';
+ IconName: Icon | undefined;
+ disabled?: boolean;
+ isBlueish?: boolean;
+ iconSize?: number;
+ iconAlwaysBold?: boolean;
+ hovered: boolean;
+}
+
+const IconElement = ({ IconName, disabled, hovered, iconAlwaysBold, iconSize, iconVariant, iconVariantOnHover, isBlueish }: IconElementProp) => {
+ const theme = useTheme();
+
+ return (IconName
+ ? (
+ )
+ : undefined);
+};
+
export interface ActionButtonProps {
StartIcon?: Icon;
+ EndIcon?: Icon;
iconVariant?: 'Bulk' | 'Broken' | 'TwoTone' | 'Outline' | 'Linear' | 'Bold';
iconVariantOnHover?: 'Bulk' | 'Broken' | 'TwoTone' | 'Outline' | 'Linear' | 'Bold';
contentPlacement?: 'start' | 'center' | 'end';
@@ -27,7 +62,7 @@ export interface ActionButtonProps {
variant?: 'text' | 'contained' | 'outlined';
}
-export default function ActionButton ({ StartIcon, contentPlacement = 'start', disabled, iconAlwaysBold, iconSize = 20, iconVariant, iconVariantOnHover, isBlueish, isBusy, onClick, style, text, variant }: ActionButtonProps): React.ReactElement {
+export default function ActionButton ({ EndIcon, StartIcon, contentPlacement = 'start', disabled, iconAlwaysBold, iconSize = 20, iconVariant, iconVariantOnHover, isBlueish, isBusy, onClick, style, text, variant }: ActionButtonProps): React.ReactElement {
const theme = useTheme();
const isDark = useIsDark();
const containerRef = useRef(null);
@@ -66,6 +101,16 @@ export default function ActionButton ({ StartIcon, contentPlacement = 'start', d
}
};
+ const EndIconStyle = {
+ '& .MuiButton-endIcon': {
+ marginLeft: '10px',
+ marginRight: 0
+ },
+ '& .MuiButton-endIcon svg': {
+ color: disabled ? '#BEAAD84D' : '#BEAAD8'
+ }
+ };
+
const renderText = useMemo(() => {
if (typeof text === 'string') {
return
@@ -88,32 +133,37 @@ export default function ActionButton ({ StartIcon, contentPlacement = 'start', d
return (
}
onClick={onClick ?? noop}
ref={containerRef}
- startIcon={StartIcon
- ? (
- )
- : undefined}
+ startIcon={
+ }
sx={{
'&.Mui-disabled': {
backgroundColor: '#2D1E4A4D'
},
...GeneralButtonStyle,
...StartIconStyle,
+ ...EndIconStyle,
...style
}}
variant={variant}
diff --git a/packages/extension-polkagate/src/components/ActionCard.tsx b/packages/extension-polkagate/src/components/ActionCard.tsx
index 53157f495..5df2341c2 100644
--- a/packages/extension-polkagate/src/components/ActionCard.tsx
+++ b/packages/extension-polkagate/src/components/ActionCard.tsx
@@ -19,9 +19,12 @@ interface Props {
onClick: () => void;
style?: SxProps;
title: string;
+ children?: React.ReactNode;
+ showColorBall?: boolean;
+ showChevron?: boolean;
}
-function ActionCard ({ Icon, description, iconColor = '#AA83DC', iconSize = 30, iconWithBackground, iconWithoutTransform, logoIcon, onClick, style, title }: Props): React.ReactElement {
+function ActionCard ({ Icon, children, description, iconColor = '#AA83DC', iconSize = 30, iconWithBackground, iconWithoutTransform, logoIcon, onClick, showChevron = true, showColorBall = true, style, title }: Props): React.ReactElement {
const theme = useTheme();
const isDark = useIsDark();
const containerRef = useRef(null);
@@ -44,6 +47,7 @@ function ActionCard ({ Icon, description, iconColor = '#AA83DC', iconSize = 30,
const colorBallStyle: React.CSSProperties = {
backgroundColor: '#CC429D',
borderRadius: '50%',
+ display: showColorBall ? 'initial' : 'none',
filter: 'blur(28px)', // Glow effect
height: '42px',
left: '1px',
@@ -87,17 +91,18 @@ function ActionCard ({ Icon, description, iconColor = '#AA83DC', iconSize = 30,
sx={{ ...IconStyle, height: '34px', p: '2px', width: '34px' }}
/>
}
-
+
{title}
-
+ {showChevron && }
{description}
+ {children}
);
diff --git a/packages/extension-polkagate/src/components/TwoToneText.tsx b/packages/extension-polkagate/src/components/TwoToneText.tsx
index 3b7afab2e..f082b84d0 100644
--- a/packages/extension-polkagate/src/components/TwoToneText.tsx
+++ b/packages/extension-polkagate/src/components/TwoToneText.tsx
@@ -13,7 +13,7 @@ interface Props {
function TwoToneText ({ backgroundColor, color = '#BEAAD8', style = {}, text, textPartInColor = '' }: Props): React.ReactElement {
if (!textPartInColor) {
- return {text};
+ return {text};
}
return (
diff --git a/packages/extension-polkagate/src/fullscreen/components/layout/Notifications.tsx b/packages/extension-polkagate/src/fullscreen/components/layout/Notifications.tsx
index 96b8fe9ac..389dc9c56 100644
--- a/packages/extension-polkagate/src/fullscreen/components/layout/Notifications.tsx
+++ b/packages/extension-polkagate/src/fullscreen/components/layout/Notifications.tsx
@@ -1,69 +1,94 @@
// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0
+/* eslint-disable react/jsx-first-prop-new-line */
+
import { Box, Grid } from '@mui/material';
-import { Notification } from 'iconsax-react';
-import React, { useCallback } from 'react';
+import { Notification as NotificationIcon } from 'iconsax-react';
+import React, { useCallback, useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { useIsExtensionPopup, useNotifications } from '@polkadot/extension-polkagate/src/hooks';
+import { ExtensionPopups } from '@polkadot/extension-polkagate/src/util/constants';
+import { useExtensionPopups } from '@polkadot/extension-polkagate/src/util/handleExtensionPopup';
+
+import Notification from '../../notification';
-import { useAlerts, useTranslation } from '@polkadot/extension-polkagate/src/hooks';
+const NotificationButton = ({ hasNewNotification, onClick }: { hasNewNotification: boolean; onClick: () => void; }) => (
+
+
+
+
+);
function Notifications (): React.ReactElement {
- const { notify } = useAlerts();
- const { t } = useTranslation();
+ const isExtension = useIsExtensionPopup();
+ const navigate = useNavigate();
+ const { extensionPopup, extensionPopupCloser, extensionPopupOpener } = useExtensionPopups();
+ const { notificationItems } = useNotifications();
- const onClick = useCallback(() => {
- notify(t('Coming Soon!'), 'info');
- }, [notify, t]);
+ const hasNewNotification = useMemo(() => {
+ if (!notificationItems) {
+ return false;
+ }
- return (
- !read);
+ }, [notificationItems]);
- backdropFilter: 'blur(20px)',
- background: '#2D1E4A80',
- borderRadius: '12px',
- boxShadow: '0px 0px 24px 8px #4E2B7259 inset',
- cursor: 'pointer',
- height: '32px',
- position: 'relative',
- transition: 'all 250ms ease-out',
- width: '32px'
+ const onClick = useCallback(() => {
+ if (isExtension) {
+ navigate('/notification') as void;
- }}
- >
-
-
+
-
+ {extensionPopup === ExtensionPopups.NOTIFICATION &&
+ }
+ >
);
}
diff --git a/packages/extension-polkagate/src/fullscreen/notification/NotificationSettingsFS.tsx b/packages/extension-polkagate/src/fullscreen/notification/NotificationSettingsFS.tsx
new file mode 100644
index 000000000..f648177b6
--- /dev/null
+++ b/packages/extension-polkagate/src/fullscreen/notification/NotificationSettingsFS.tsx
@@ -0,0 +1,192 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { Stack, Typography } from '@mui/material';
+import { ArrowCircleDown2, BuyCrypto, Notification as NotificationIcon, Record, UserOctagon } from 'iconsax-react';
+import React, { useMemo } from 'react';
+
+import { ActionCard, MySwitch } from '@polkadot/extension-polkagate/src/components';
+import { useTranslation } from '@polkadot/extension-polkagate/src/hooks';
+import { SUPPORTED_GOVERNANCE_NOTIFICATION_CHAIN, SUPPORTED_STAKING_NOTIFICATION_CHAIN } from '@polkadot/extension-polkagate/src/popup/notification/constant';
+import useNotificationSettings, { type NotificationSettingType, Popups } from '@polkadot/extension-polkagate/src/popup/notification/hook/useNotificationSettings';
+
+import { DraggableModal } from '../components/DraggableModal';
+import SelectAccount from './partials/SelectAccount';
+import SelectChain from './partials/SelectChain';
+
+const CARD_STYLE = { alignItems: 'center', borderColor: '#2D1E4A', height: '64px' };
+
+interface SettingUIProps {
+ openPopup: (popup: Popups) => () => void;
+ toggleNotification: () => void;
+ notificationSetting: NotificationSettingType;
+ toggleReceivedFunds: () => void;
+}
+
+const SettingUI = ({ notificationSetting, openPopup, toggleNotification, toggleReceivedFunds }: SettingUIProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {notificationSetting.accounts?.length}
+
+
+
+
+
+ );
+};
+
+interface Props {
+ handleClose: () => void;
+}
+
+function NotificationSettingsFS ({ handleClose }: Props) {
+ const { t } = useTranslation();
+
+ const { closePopup,
+ notificationSetting,
+ openPopup,
+ popups,
+ setAccounts,
+ setGovernanceChains,
+ setStakingRewardsChains,
+ toggleNotification,
+ toggleReceivedFunds } = useNotificationSettings();
+
+ const ui = useMemo(() => {
+ switch (popups) {
+ case Popups.NONE:
+ return (
+ );
+
+ case Popups.ACCOUNTS:
+ return (
+ );
+
+ case Popups.GOVERNANCE:
+ return (
+ );
+
+ case Popups.STAKING_REWARDS:
+ return (
+ );
+ }
+ }, [notificationSetting, openPopup, popups, setAccounts, setGovernanceChains, setStakingRewardsChains, toggleNotification, toggleReceivedFunds]);
+
+ const { onClose, title }: { onClose: () => void, title: string } = useMemo(() => {
+ switch (popups) {
+ case Popups.ACCOUNTS:
+ return {
+ onClose: closePopup,
+ title: t('Select Accounts')
+ };
+
+ case Popups.GOVERNANCE:
+ return {
+ onClose: closePopup,
+ title: t('Select Chains')
+ };
+
+ case Popups.STAKING_REWARDS:
+ return {
+ onClose: closePopup,
+ title: t('Select Chains')
+ };
+
+ default:
+ return {
+ onClose: handleClose,
+ title: t('Notification Settings')
+ };
+ }
+ }, [closePopup, handleClose, popups, t]);
+
+ return (
+
+ {ui}
+
+ );
+}
+
+export default NotificationSettingsFS;
diff --git a/packages/extension-polkagate/src/fullscreen/notification/index.tsx b/packages/extension-polkagate/src/fullscreen/notification/index.tsx
new file mode 100644
index 000000000..c826dbd31
--- /dev/null
+++ b/packages/extension-polkagate/src/fullscreen/notification/index.tsx
@@ -0,0 +1,87 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { Container } from '@mui/material';
+import React, { useCallback, useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { FadeOnScroll } from '@polkadot/extension-polkagate/src/components';
+import { useNotifications, useTranslation } from '@polkadot/extension-polkagate/src/hooks';
+import NotificationGroup from '@polkadot/extension-polkagate/src/popup/notification/partials/NotificationGroup';
+import { ColdStartNotification, NoNotificationYet, NotificationLoading, OffNotificationMessage } from '@polkadot/extension-polkagate/src/popup/notification/partials/Partial';
+
+import { DraggableModal } from '../components/DraggableModal';
+
+interface Props {
+ handleClose: () => void;
+}
+
+function Notification ({ handleClose }: Props) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const refContainer = useRef(null);
+ const { markAsRead, notificationItems, status } = useNotifications();
+
+ useEffect(() => markAsRead(), [markAsRead]);
+
+ const openSettings = useCallback(() => {
+ navigate('/settingsfs/account') as void;
+ handleClose();
+ }, [handleClose, navigate]);
+
+ return (
+
+ <>
+
+ {notificationItems && Object.entries(notificationItems).map(([dateKey, items]) => (
+
+ ))}
+ {status.isNotificationOff &&
+ }
+ {status.isFirstTime &&
+ }
+ {status.noNotificationYet &&
+ }
+ {status.loading &&
+ }
+
+
+ >
+
+ );
+}
+
+export default Notification;
diff --git a/packages/extension-polkagate/src/fullscreen/notification/partials/SelectAccount.tsx b/packages/extension-polkagate/src/fullscreen/notification/partials/SelectAccount.tsx
new file mode 100644
index 000000000..3ad6c3db9
--- /dev/null
+++ b/packages/extension-polkagate/src/fullscreen/notification/partials/SelectAccount.tsx
@@ -0,0 +1,89 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { Stack, Typography } from '@mui/material';
+import React, { useCallback, useContext, useEffect, useState } from 'react';
+
+import { MAX_ACCOUNT_COUNT_NOTIFICATION } from '@polkadot/extension-polkagate/src/popup/notification/constant';
+import AccountToggle from '@polkadot/extension-polkagate/src/popup/notification/partials/AccountToggle';
+
+import { AccountContext, GradientButton, GradientDivider, Motion } from '../../../components';
+import { useTranslation } from '../../../hooks';
+
+interface Props {
+ onAccounts: (addresses: string[]) => () => void;
+ previousState: string[] | undefined;
+}
+
+/**
+ * A component for selecting an account. It allows the user to choose
+ * which accounts to see their notifications for.
+ *
+ * Only has been used in extension mode!
+ */
+function SelectAccount ({ onAccounts, previousState }: Props): React.ReactElement {
+ const { t } = useTranslation();
+ const { accounts } = useContext(AccountContext);
+
+ const [selectedAccounts, setSelectedAccounts] = useState(previousState ?? []);
+
+ // Ensure state updates when previousState changes
+ useEffect(() => {
+ if (previousState) {
+ setSelectedAccounts(previousState);
+ }
+ }, [previousState]);
+
+ // Handles selecting or deselecting an account
+ const handleSelect = useCallback((newSelect: string) => {
+ setSelectedAccounts((prev) => {
+ const alreadySelected = prev.includes(newSelect);
+
+ if (alreadySelected) {
+ // If the account is already selected, remove it
+ return prev.filter((address) => address !== newSelect);
+ }
+
+ // Prevent adding more than the max allowed
+ if (prev.length >= MAX_ACCOUNT_COUNT_NOTIFICATION) {
+ return prev; // return unchanged state
+ }
+
+ // Otherwise, add the new account
+ return [...prev, newSelect];
+ });
+ }, [setSelectedAccounts]);
+
+ return (
+
+
+
+ {t('Select up to 3 accounts to be notified when account activity')}
+
+
+
+ {accounts.map(({ address }) => {
+ const isSelected = selectedAccounts.includes(address);
+
+ return (
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export default React.memo(SelectAccount);
diff --git a/packages/extension-polkagate/src/fullscreen/notification/partials/SelectChain.tsx b/packages/extension-polkagate/src/fullscreen/notification/partials/SelectChain.tsx
new file mode 100644
index 000000000..661911afc
--- /dev/null
+++ b/packages/extension-polkagate/src/fullscreen/notification/partials/SelectChain.tsx
@@ -0,0 +1,71 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { TextValuePair } from '@polkadot/extension-polkagate/src/popup/notification/NotificationSettings';
+
+import { Stack } from '@mui/material';
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { GradientButton, Motion } from '@polkadot/extension-polkagate/src/components';
+import { useTranslation } from '@polkadot/extension-polkagate/src/hooks';
+import ChainToggle from '@polkadot/extension-polkagate/src/popup/notification/partials/ChainToggle';
+import { sanitizeChainName } from '@polkadot/extension-polkagate/src/util';
+
+interface Props {
+ onChains: (addresses: string[]) => () => void;
+ previousState: string[] | undefined;
+ options: TextValuePair[];
+}
+
+export default function SelectChain ({ onChains, options, previousState }: Props) {
+ const { t } = useTranslation();
+
+ const [selectedChains, setSelectedChains] = useState(previousState ?? []);
+
+ // Ensure state updates when previousState changes
+ useEffect(() => {
+ if (previousState) {
+ setSelectedChains(previousState);
+ }
+ }, [previousState]);
+
+ // Handles selecting or deselecting
+ const handleSelect = useCallback((newSelect: string) => {
+ setSelectedChains((prev) => {
+ const alreadySelected = prev.includes(newSelect);
+
+ if (alreadySelected) {
+ // If is already selected, remove it
+ return prev.filter((chain) => chain !== newSelect);
+ }
+
+ // add
+ return [...prev, newSelect];
+ });
+ }, []);
+
+ return (
+
+
+ {options.map(({ text, value }) => {
+ const isSelected = selectedChains.includes(value);
+
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/packages/extension-polkagate/src/fullscreen/settings/AccountSettings.tsx b/packages/extension-polkagate/src/fullscreen/settings/AccountSettings.tsx
index 5a626b739..33fb08a47 100644
--- a/packages/extension-polkagate/src/fullscreen/settings/AccountSettings.tsx
+++ b/packages/extension-polkagate/src/fullscreen/settings/AccountSettings.tsx
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { Stack, Typography } from '@mui/material';
-import { Broom, Edit2, ExportCurve, type Icon, ImportCurve, LogoutCurve, ShieldSecurity } from 'iconsax-react';
+import { Broom, Edit2, ExportCurve, type Icon, ImportCurve, LogoutCurve, Notification as NotificationIcon, ShieldSecurity } from 'iconsax-react';
import React, { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -17,6 +17,7 @@ import { VelvetBox } from '../../style';
import DeriveAccount from '../home/DeriveAccount';
import ExportAllAccounts from '../home/ExportAllAccounts';
import RenameAccount from '../home/RenameAccount';
+import NotificationSettingsFS from '../notification/NotificationSettingsFS';
interface ActionBoxProps {
Icon: Icon;
@@ -51,7 +52,14 @@ function AccountSettings (): React.ReactElement {
const popups = useMemo(() => {
switch (extensionPopup) {
- case ExtensionPopups.RENAME:
+ case ExtensionPopups.NOTIFICATION:
+ return (
+
+ );
+
+ case ExtensionPopups.RENAME:
return (
+
-
+
{t('Networks to view assets')}
diff --git a/packages/extension-polkagate/src/hooks/index.ts b/packages/extension-polkagate/src/hooks/index.ts
index 096fddb63..9b64af9fa 100644
--- a/packages/extension-polkagate/src/hooks/index.ts
+++ b/packages/extension-polkagate/src/hooks/index.ts
@@ -60,6 +60,7 @@ export { default as useMetadataProof } from './useMetadataProof';
export { default as useMyAccountIdentity } from './useMyAccountIdentity';
export { default as useNativeAssetBalances } from './useNativeAssetBalances';
export { default as useNFT } from './useNFT';
+export { default as useNotifications } from './useNotifications';
export { default as usePendingRewards } from './usePendingRewards';
export { default as usePeopleChain } from './usePeopleChain';
export { default as usePool } from './usePool';
diff --git a/packages/extension-polkagate/src/hooks/useChainInfo.ts b/packages/extension-polkagate/src/hooks/useChainInfo.ts
index 878d88830..8a94c5f1e 100644
--- a/packages/extension-polkagate/src/hooks/useChainInfo.ts
+++ b/packages/extension-polkagate/src/hooks/useChainInfo.ts
@@ -22,7 +22,7 @@ import useMetadata from './useMetadata';
* @property {number | undefined} decimal - The number of decimals for the blockchain's token. Can be undefined.
* @property {string | undefined} token - The symbol for the blockchain's token. Can be undefined.
*/
-interface ChainInfo {
+export interface ChainInfo {
api: ApiPromise | undefined;
chain: Chain | null | undefined;
chainName: string | undefined;
diff --git a/packages/extension-polkagate/src/hooks/useNotifications.ts b/packages/extension-polkagate/src/hooks/useNotifications.ts
new file mode 100644
index 000000000..4f9d55d6f
--- /dev/null
+++ b/packages/extension-polkagate/src/hooks/useNotifications.ts
@@ -0,0 +1,308 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { NotificationActionType, NotificationsType } from '../popup/notification/types';
+import type { DropdownOption } from '../util/types';
+
+import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
+
+import { getStorage, setStorage } from '../components/Loading';
+import { AUTO_MARK_AS_READ_DELAY, SUBSCAN_SUPPORTED_CHAINS } from '../popup/notification/constant';
+import { getPayoutsInformation, getReceivedFundsInformation, getReferendasInformation } from '../popup/notification/helpers';
+import useNotificationSettings from '../popup/notification/hook/useNotificationSettings';
+import { generateReceivedFundNotifications, generateReferendaNotifications, generateStakingRewardNotifications, groupNotificationsByDay, markMessagesAsRead, updateReferendas } from '../popup/notification/util';
+import { sanitizeChainName } from '../util';
+import { STORAGE_KEY } from '../util/constants';
+import { useGenesisHashOptions, useSelectedChains } from '.';
+
+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':
+ // Initialize notifications for the first time
+ return {
+ isFirstTime: true,
+ latestLoggedIn: Math.floor(Date.now() / 1000), // timestamp in seconds
+ notificationMessages: [],
+ receivedFunds: undefined,
+ referendas: undefined,
+ stakingRewards: undefined
+ };
+
+ case 'MARK_AS_READ':
+ // Mark all messages as read
+ return { ...state, notificationMessages: markMessagesAsRead(state.notificationMessages ?? []) };
+
+ case 'LOAD_FROM_STORAGE':
+ return action.payload;
+
+ case 'SET_REFERENDA': {
+ return {
+ ...state,
+ isFirstTime: false,
+ notificationMessages: [...generateReferendaNotifications(state.latestLoggedIn ?? Math.floor(Date.now() / 1000), state.referendas, action.payload), ...(state.notificationMessages ?? [])],
+ referendas: updateReferendas(state.referendas, action.payload)
+ };
+ }
+
+ 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
+}
+
+/**
+ * React hook for managing notification settings and state.
+ *
+ * This hook handles:
+ * - Loading and saving notification settings from storage.
+ * - Initializing notification state and loading saved notifications.
+ * - Fetching received funds and staking rewards notifications.
+ * - Listening for governance-related notifications via a web worker.
+ * - Marking notifications as read.
+ * - Persisting notifications on window unload.
+ *
+ * @param [justLoadData=true] - If true the hook will only read the local storage and return it (fetching won't happen)
+ *
+ * @returns An object containing:
+ * - `notifications`: The current notifications state.
+ * - `notificationItems`: The current notification messages state.
+ * - `settings`: The current notifications settings.
+ *
+ * @remarks
+ * This hook uses several internal flags and refs to avoid duplicate network calls and redundant state updates.
+ */
+export default function useNotifications (justLoadData = true) {
+ const { notificationSetting } = useNotificationSettings(justLoadData);
+ const { accounts, enable: isNotificationEnable, governance: governanceChains, receivedFunds: isReceivedFundsEnable, stakingRewards: stakingRewardChains } = notificationSetting;
+
+ const selectedChains = useSelectedChains();
+ const allChains = useGenesisHashOptions(false);
+
+ // Refs to avoid duplicate network calls and redundant state updates
+ const { current: fetchRefs } = useRef({
+ receivedFundsRef: status.NONE, // Flag to avoid duplicate calls of getReceivedFundsInformation
+ referendaRef: status.NONE, // Flag to avoid duplicate calls of getPayoutsInformation
+ stakingRewardsRef: status.NONE // Flag to avoid duplicate calls of getNotificationsInformation
+ });
+ const initializedRef = useRef(false); // Flag to avoid duplicate initialization
+ const isSavingRef = useRef(false); // Flag to avoid duplicate save in the storage
+ const saveQueue = useRef>(Promise.resolve()); // Saving to the local storage queue
+
+ // Memoized list of selected chain options
+ 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 [notifications, dispatchNotifications] = useReducer(notificationReducer, initialNotificationState);
+
+ // Whether notifications are turned off
+ const notificationIsOff = useMemo(() => isNotificationEnable === false || accounts?.length === 0, [accounts?.length, isNotificationEnable]);
+
+ useEffect(() => {
+ // Don't save if notifications haven't been initialized yet
+ if (notifications.isFirstTime === undefined) {
+ return;
+ }
+
+ // Don't save if notifications are turned off
+ if (notificationIsOff) {
+ return;
+ }
+
+ // Queue saves to ensure they happen sequentially
+ saveQueue.current = saveQueue.current.then(async () => {
+ if (isSavingRef.current) {
+ return;
+ }
+
+ isSavingRef.current = true;
+ const dataToSave = {
+ ...notifications,
+ latestLoggedIn: Math.floor(Date.now() / 1000)
+ };
+
+ try {
+ await setStorage(STORAGE_KEY.NOTIFICATIONS, dataToSave);
+ console.log('✅ Notifications saved to storage');
+ } catch (error) {
+ console.error('❌ Failed to save notifications:', error);
+ } finally {
+ isSavingRef.current = false;
+ }
+ });
+ }, [notifications, notificationIsOff]);
+
+ // Mark all notifications as read
+ const markAsRead = useCallback(() => {
+ const timer = setTimeout(() => {
+ // ✅ This runs only after the component has been mounted for 5 seconds
+ dispatchNotifications({ type: 'MARK_AS_READ' });
+ }, AUTO_MARK_AS_READ_DELAY);
+
+ return () => clearTimeout(timer); // return cleanup function if needed
+ }, []);
+
+ // Fetch received funds notifications
+ const receivedFundsInfo = useCallback(async () => {
+ if (chains && fetchRefs.receivedFundsRef === status.NONE && accounts && isReceivedFundsEnable) {
+ fetchRefs.receivedFundsRef = status.FETCHING;
+
+ // Filter supported chains for Subscan
+ const filteredSupportedChains = chains.filter(({ text }) => {
+ const sanitized = sanitizeChainName(text)?.toLowerCase();
+
+ if (!sanitized) {
+ return false;
+ }
+
+ return SUBSCAN_SUPPORTED_CHAINS.find((chainName) => chainName.toLowerCase() === sanitized);
+ }).map(({ value }) => value as string);
+
+ const receivedFunds = await getReceivedFundsInformation(accounts, filteredSupportedChains);
+
+ fetchRefs.receivedFundsRef = status.FETCHED;
+ dispatchNotifications({
+ payload: receivedFunds,
+ type: 'SET_RECEIVED_FUNDS'
+ });
+ }
+ }, [accounts, chains, fetchRefs, isReceivedFundsEnable]);
+
+ // Fetch staking rewards notifications
+ const payoutsInfo = useCallback(async () => {
+ if (fetchRefs.stakingRewardsRef === status.NONE && accounts && stakingRewardChains && stakingRewardChains.length !== 0) {
+ fetchRefs.stakingRewardsRef = status.FETCHING;
+
+ const payouts = await getPayoutsInformation(accounts, stakingRewardChains);
+
+ fetchRefs.stakingRewardsRef = status.FETCHED;
+ dispatchNotifications({
+ payload: payouts,
+ type: 'SET_STAKING_REWARDS'
+ });
+ }
+ }, [accounts, fetchRefs, stakingRewardChains]);
+
+ // Fetch referenda notifications
+ const referendasInfo = useCallback(async () => {
+ if (fetchRefs.referendaRef === status.NONE && accounts && governanceChains && governanceChains.length !== 0) {
+ fetchRefs.referendaRef = status.FETCHING;
+
+ const referendas = await getReferendasInformation(governanceChains);
+
+ fetchRefs.referendaRef = status.FETCHED;
+ dispatchNotifications({
+ payload: referendas,
+ type: 'SET_REFERENDA'
+ });
+ }
+ }, [accounts, fetchRefs, governanceChains]);
+
+ // Load notifications from storage or initialize if first time
+ useEffect(() => {
+ if (notificationIsOff || initializedRef.current) {
+ return;
+ }
+
+ const loadSavedNotifications = async () => {
+ initializedRef.current = true;
+
+ try {
+ const savedNotifications = await getStorage(STORAGE_KEY.NOTIFICATIONS) 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]);
+
+ // Fetch received funds, referendas and staking rewards notifications
+ useEffect(() => {
+ if (notificationIsOff || justLoadData) {
+ return;
+ }
+
+ if (isReceivedFundsEnable) {
+ receivedFundsInfo().catch(console.error);
+ }
+
+ if (stakingRewardChains?.length !== 0) {
+ payoutsInfo().catch(console.error);
+ }
+
+ if (governanceChains?.length !== 0) {
+ referendasInfo().catch(console.error);
+ }
+ }, [governanceChains?.length, isReceivedFundsEnable, justLoadData, notificationIsOff, payoutsInfo, receivedFundsInfo, referendasInfo, stakingRewardChains?.length]);
+
+ const notificationItems = useMemo(() => groupNotificationsByDay(notifications.notificationMessages), [notifications.notificationMessages]);
+
+ const isNotificationOff = useMemo(() => !notificationSetting.enable && !notifications.isFirstTime, [notificationSetting.enable, notifications.isFirstTime]);
+ const isFirstTime = useMemo(() => !notificationSetting.enable && notifications.isFirstTime, [notificationSetting.enable, notifications.isFirstTime]);
+ const noNotificationYet = useMemo(() => notificationSetting.enable && !notifications.isFirstTime && notifications.notificationMessages?.length === 0, [notificationSetting.enable, notifications.isFirstTime, notifications.notificationMessages?.length]);
+
+ const loading = useMemo(() => {
+ if (isNotificationOff || isFirstTime || (notificationItems && Object.entries(notificationItems).length > 0) || noNotificationYet) {
+ return false;
+ }
+
+ return true;
+ }, [isFirstTime, isNotificationOff, noNotificationYet, notificationItems]);
+
+ return {
+ markAsRead,
+ notificationItems,
+ notificationSetting,
+ notifications,
+ status: {
+ isFirstTime,
+ isNotificationOff,
+ loading,
+ noNotificationYet
+ }
+ };
+}
diff --git a/packages/extension-polkagate/src/hooks/useTokenPriceBySymbol.ts b/packages/extension-polkagate/src/hooks/useTokenPriceBySymbol.ts
index 761beb965..930d17b36 100644
--- a/packages/extension-polkagate/src/hooks/useTokenPriceBySymbol.ts
+++ b/packages/extension-polkagate/src/hooks/useTokenPriceBySymbol.ts
@@ -14,7 +14,7 @@ const DEFAULT_PRICE = {
priceDate: undefined
};
-interface Price {
+export interface Price {
price: number | undefined,
priceDate: number | undefined;
}
diff --git a/packages/extension-polkagate/src/partials/UserDashboardHeader.tsx b/packages/extension-polkagate/src/partials/UserDashboardHeader.tsx
index 439302bae..cb06f2a10 100644
--- a/packages/extension-polkagate/src/partials/UserDashboardHeader.tsx
+++ b/packages/extension-polkagate/src/partials/UserDashboardHeader.tsx
@@ -3,10 +3,11 @@
import type { SignerInformation } from '../components/SelectedProxy';
-import { Container, Grid } from '@mui/material';
+import { Container, Grid, Stack } from '@mui/material';
import React, { useMemo } from 'react';
import { HomeButton, SelectedProxy } from '../components';
+import Notifications from '../fullscreen/components/layout/Notifications';
import AccountSelection from '../popup/home/partial/AccountSelection';
import FullscreenModeButton from './FullscreenModeButton';
import { ConnectedDapp } from '.';
@@ -41,7 +42,10 @@ function UserDashboardHeader ({ fullscreenURL, genesisHash, homeType, noSelectio
}
-
+
+
+
+
);
}
diff --git a/packages/extension-polkagate/src/popup/notification/NotificationSettings.tsx b/packages/extension-polkagate/src/popup/notification/NotificationSettings.tsx
new file mode 100644
index 000000000..2eb49c0f2
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/NotificationSettings.tsx
@@ -0,0 +1,147 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+/* eslint-disable react/jsx-max-props-per-line */
+/* eslint-disable react/jsx-no-bind */
+
+import { Grid, Stack, Typography } from '@mui/material';
+import { ArrowCircleDown2, BuyCrypto, Notification as NotificationIcon, Record, UserOctagon } from 'iconsax-react';
+import React, { useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { ActionCard, BackWithLabel, Motion, MySwitch } from '../../components';
+import { useSelectedAccount, useTranslation } from '../../hooks';
+import { HomeMenu, UserDashboardHeader } from '../../partials';
+import { SETTING_PAGES } from '../settings';
+import useNotificationSettings, { Popups } from './hook/useNotificationSettings';
+import SelectAccount from './partials/SelectAccount';
+import SelectChain from './partials/SelectChain';
+import { SUPPORTED_GOVERNANCE_NOTIFICATION_CHAIN, SUPPORTED_STAKING_NOTIFICATION_CHAIN } from './constant';
+
+export interface TextValuePair {
+ text: string;
+ value: string;
+}
+
+const CARD_STYLE = { alignItems: 'center', height: '64px' };
+
+export default function NotificationSettings () {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const selectedAddress = useSelectedAccount()?.address;
+
+ const { closePopup,
+ notificationSetting,
+ openPopup,
+ popups,
+ setAccounts,
+ setGovernanceChains,
+ setStakingRewardsChains,
+ toggleNotification,
+ toggleReceivedFunds } = useNotificationSettings();
+
+ const onNotificationCancel = useCallback(() => navigate(`/settings-${SETTING_PAGES.ACCOUNT}/${selectedAddress}`) as void, [navigate, selectedAddress]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {notificationSetting.accounts?.length}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/packages/extension-polkagate/src/popup/notification/constant.ts b/packages/extension-polkagate/src/popup/notification/constant.ts
new file mode 100644
index 000000000..c19020d2d
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/constant.ts
@@ -0,0 +1,81 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { KUSAMA_GENESIS_HASH, PASEO_GENESIS_HASH, POLKADOT_GENESIS_HASH, WESTEND_GENESIS_HASH } from '../../util/constants';
+
+export type ReferendaStatus = 'ongoing' | 'approved' | 'timeout' | 'rejected' | 'cancelled';
+
+export const NOTIFICATION_GOVERNANCE_CHAINS = ['kusama', 'polkadot'];
+
+export const RECEIVED_FUNDS_THRESHOLD = 15;
+export const RECEIVED_REWARDS_THRESHOLD = 10;
+export const REFERENDA_COUNT_TO_TRACK_DOT = 50;
+export const REFERENDA_COUNT_TO_TRACK_KSM = 10;
+export const REFERENDA_STATUS = ['ongoing', 'approved', 'timedOut', 'rejected', 'cancelled'];
+
+export const NOT_READ_BGCOLOR = '#ECF6FE';
+export const READ_BGCOLOR = '#f0e6ea';
+export const MAX_RETRIES = 5;
+export const BATCH_SIZE = 2;
+export const NOTIFICATION_TIMESTAMP_OFFSET = 15 * 60; // 15 minutes in seconds
+
+export const MAX_ACCOUNT_COUNT_NOTIFICATION = 3;
+
+export const POLKADOT_NOTIFICATION_CHAIN = { text: 'Polkadot Relay Chain', value: POLKADOT_GENESIS_HASH };
+export const KUSAMA_NOTIFICATION_CHAIN = { text: 'Kusama Relay Chain', value: KUSAMA_GENESIS_HASH };
+export const WESTEND_NOTIFICATION_CHAIN = { text: 'Westend Relay Chain', value: WESTEND_GENESIS_HASH };
+export const PASEO_NOTIFICATION_CHAIN = { text: 'Paseo Relay Chain', value: PASEO_GENESIS_HASH };
+
+export const SET_UP_SUPPORTED_CHAINS = [POLKADOT_NOTIFICATION_CHAIN.value, KUSAMA_NOTIFICATION_CHAIN.value];
+export const SUPPORTED_CHAINS = [POLKADOT_NOTIFICATION_CHAIN, KUSAMA_NOTIFICATION_CHAIN, WESTEND_NOTIFICATION_CHAIN, PASEO_NOTIFICATION_CHAIN];
+
+export const AUTO_MARK_AS_READ_DELAY = 5000; // 5 seconds
+
+export const DEFAULT_NOTIFICATION_SETTING = {
+ accounts: [],
+ enable: false,
+ governance: [],
+ receivedFunds: false,
+ stakingRewards: []
+};
+
+export const SET_UP_NOTIFICATION_SETTING = {
+ accounts: [],
+ enable: true,
+ governance: SET_UP_SUPPORTED_CHAINS,
+ receivedFunds: true,
+ stakingRewards: SET_UP_SUPPORTED_CHAINS
+};
+
+export const SUPPORTED_GOVERNANCE_NOTIFICATION_CHAIN = SUPPORTED_CHAINS;
+export const SUPPORTED_STAKING_NOTIFICATION_CHAIN = SUPPORTED_CHAINS;
+
+export const SUBSCAN_SUPPORTED_CHAINS = [
+ 'Polkadot',
+ 'Kusama',
+ 'PolkadotAssethub',
+ 'KusamaAssethub',
+ 'WestendAssethub',
+ 'PaseoAssethub',
+ 'Acala',
+ 'Ajuna',
+ 'Astar',
+ 'Basilisk',
+ 'Bifrost',
+ 'Calamari',
+ 'Centrifuge',
+ 'Composable',
+ 'Darwinia',
+ 'HydraDX',
+ 'IntegriTEE',
+ 'Karura',
+ 'Nodle',
+ 'Paseo',
+ 'Phala',
+ 'Picasso',
+ 'Polymesh',
+ 'SORA',
+ 'Vara',
+ 'Westend',
+ 'Zeitgeist'
+];
diff --git a/packages/extension-polkagate/src/popup/notification/helpers.ts b/packages/extension-polkagate/src/popup/notification/helpers.ts
new file mode 100644
index 000000000..2a5772b3f
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/helpers.ts
@@ -0,0 +1,344 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { DropdownOption } from '@polkadot/extension-polkagate/src/util/types';
+import type { ApiResponse, PayoutsProp, PayoutSubscan, ReceivedFundInformation, ReferendaInformation, ReferendaProp, ReferendaSubscan, StakingRewardInformation, TransfersProp, TransferSubscan } from './types';
+
+import { getSubscanChainName, getSubstrateAddress } from '@polkadot/extension-polkagate/src/util';
+import { postData } from '@polkadot/extension-polkagate/src/util/api';
+import { KUSAMA_GENESIS_HASH, POLKADOT_GENESIS_HASH } from '@polkadot/extension-polkagate/src/util/constants';
+import getChainName from '@polkadot/extension-polkagate/src/util/getChainName';
+import { isMigratedRelay } from '@polkadot/extension-polkagate/src/util/migrateHubUtils';
+
+import { BATCH_SIZE, MAX_RETRIES, RECEIVED_FUNDS_THRESHOLD, RECEIVED_REWARDS_THRESHOLD, REFERENDA_COUNT_TO_TRACK_DOT, REFERENDA_COUNT_TO_TRACK_KSM, type ReferendaStatus } from './constant';
+import { timestampToDate } from './util';
+
+const transformTransfers = (address: string, transfers: TransferSubscan[], network: DropdownOption) => {
+ // Initialize the accumulator for the reduce function
+ const initialAccumulator = {
+ address,
+ data: [] as TransfersProp[],
+ network
+ };
+
+ // Sanitize each transfer item and accumulate results
+ const result = transfers.reduce((accumulator, transfer) => {
+ if (getSubstrateAddress(transfer.to) !== address) {
+ return accumulator;
+ }
+
+ const sanitizedTransfer = {
+ amount: transfer.amount,
+ assetSymbol: transfer.asset_symbol,
+ currencyAmount: transfer.currency_amount,
+ date: timestampToDate(transfer.block_timestamp),
+ from: transfer.from,
+ fromAccountDisplay: transfer.from_account_display,
+ timestamp: transfer.block_timestamp,
+ toAccountId: transfer.to_account_display
+ };
+
+ accumulator.data.push(sanitizedTransfer);
+
+ return accumulator;
+ }, initialAccumulator);
+
+ return result;
+};
+
+const transformPayouts = (address: string, payouts: PayoutSubscan[], network: DropdownOption) => {
+ // Initialize the accumulator for the reduce function
+ const initialAccumulator = {
+ address,
+ data: [] as PayoutsProp[],
+ network
+ };
+
+ // Sanitize each transfer item and accumulate results
+ const result = payouts.reduce((accumulator, payout) => {
+ const sanitizedTransfer = {
+ amount: payout.amount,
+ date: timestampToDate(payout.block_timestamp),
+ era: payout.era,
+ timestamp: payout.block_timestamp
+ } as PayoutsProp;
+
+ accumulator.data.push(sanitizedTransfer);
+
+ return accumulator;
+ }, initialAccumulator);
+
+ return result;
+};
+
+const transformReferendas = (referendas: ReferendaSubscan[], network: DropdownOption) => {
+ // Initialize the accumulator for the reduce function
+ const initialAccumulator = {
+ data: [] as ReferendaProp[],
+ network
+ };
+
+ const filtered = referendas.filter(({ status }) => status);
+
+ // Sanitize each transfer item and accumulate results
+ const result = filtered.reduce((accumulator, referenda) => {
+ const sanitizedReferenda = {
+ account: referenda.account,
+ callModule: referenda.call_module,
+ chainName: network.text,
+ createdTimestamp: referenda.created_block_timestamp,
+ latestTimestamp: referenda.latest_block_timestamp,
+ origins: referenda.origins,
+ originsId: referenda.origins_id,
+ referendumIndex: referenda.referendum_index,
+ status: referenda.status.toLowerCase() as ReferendaStatus,
+ title: referenda.title
+ };
+
+ accumulator.data.push(sanitizedReferenda);
+
+ return accumulator;
+ }, initialAccumulator);
+
+ return result;
+};
+
+/**
+ * Fetches transfers information from subscan for the given addresses on the given chains
+ * @param addresses - An array of addresses for which payout information fetch
+ * @param chains - genesishash of the blockchain network
+ * @returns Array of payouts information
+ */
+export const getReceivedFundsInformation = async (addresses: string[], chains: string[]): Promise => {
+ const results: ReceivedFundInformation[] = [];
+ const networks = chains.map((value) => {
+ // If the network is a migrated relay chain then there's no need to fetch received fund information on
+ const isMigrateRelayChain = isMigratedRelay(value);
+
+ if (isMigrateRelayChain) {
+ return undefined;
+ }
+
+ const chainName = getChainName(value);
+
+ return ({ text: getSubscanChainName(chainName), value }) as DropdownOption;
+ }).filter((item) => !!item);
+
+ // Process each address
+ for (const address of addresses) {
+ // Process network in batches of BATCH_SIZE
+ for (let i = 0; i < networks.length; i += BATCH_SIZE) {
+ // Take a batch of BATCH_SIZE networks (or remaining networks if less than BATCH_SIZE)
+ const networkBatch = networks.slice(i, i + BATCH_SIZE);
+
+ // Create promises for this batch of networks with retry mechanism
+ const batchPromises = networkBatch.map(async (network) => {
+ let lastError: unknown = null;
+
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ const receivedInfo = await postData(`https://${network.text}.api.subscan.io/api/v2/scan/transfers`, {
+ address,
+ row: RECEIVED_FUNDS_THRESHOLD
+ }) as ApiResponse<{
+ transfers: TransferSubscan[] | null
+ }>;
+
+ if (receivedInfo.code !== 0) {
+ throw new Error('Not a expected status code');
+ }
+
+ if (!receivedInfo.data.transfers) {
+ return null; // account doesn't have any history
+ }
+
+ return transformTransfers(address, receivedInfo.data.transfers, network);
+ } catch (error) {
+ lastError = error;
+ console.warn(`Attempt ${attempt} failed for ${network.text} and address ${address} (RECEIVED). Retrying...`);
+
+ // Exponential backoff
+ await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
+ }
+ }
+
+ // If all retries fail, log the final error
+ console.error(`(RECEIVED) Failed to fetch data for ${network.text} and address ${address} after ${MAX_RETRIES} attempts`, lastError);
+
+ return null;
+ });
+
+ // Wait for all address requests in this batch
+ const batchResults = await Promise.all(batchPromises);
+
+ // Add non-null results to overall results
+ results.push(...batchResults.filter((result) => result !== null));
+
+ // console.log('results:', results);
+
+ // If not the last batch, wait for 1 second
+ if (i + BATCH_SIZE < addresses.length) {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+ }
+ }
+
+ return results;
+};
+
+/**
+ * Fetches payouts information from subscan for the given addresses on the given chains
+ * @param addresses - An array of addresses for which payout information fetch
+ * @param chains - genesishash of the blockchain network
+ * @returns Array of payouts information
+ */
+export const getPayoutsInformation = async (addresses: string[], chains: string[]): Promise => {
+ const results: StakingRewardInformation[] = [];
+ const networks = chains.map((value) => {
+ const chainName = getChainName(value);
+
+ return ({ text: getSubscanChainName(chainName), value }) as DropdownOption;
+ });
+
+ // Process each address
+ for (const address of addresses) {
+ // Process networks in batches of BATCH_SIZE
+ for (let i = 0; i < networks.length; i += BATCH_SIZE) {
+ // Take a batch of BATCH_SIZE networks (or remaining networks if less than BATCH_SIZE)
+ const networkBatch = networks.slice(i, i + BATCH_SIZE);
+
+ // Create promises for this batch of networks with retry mechanism
+ const batchPromises = networkBatch.map(async (network) => {
+ let lastError: unknown = null;
+
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ const soloPayoutInfo = await postData(`https://${network.text}.api.subscan.io/api/v2/scan/account/reward_slash`, {
+ address,
+ category: 'Reward',
+ row: RECEIVED_REWARDS_THRESHOLD
+ }) as ApiResponse<{
+ list: PayoutSubscan[]
+ }>;
+
+ const poolPayoutInfo = await postData(`https://${network.text}.api.subscan.io/api/scan/nomination_pool/rewards`, {
+ address,
+ category: 'Reward',
+ row: RECEIVED_REWARDS_THRESHOLD
+ }) as ApiResponse<{
+ list: PayoutSubscan[]
+ }>;
+
+ if (poolPayoutInfo.code !== 0 && soloPayoutInfo.code !== 0) {
+ throw new Error('Not a expected status code');
+ }
+
+ const payoutInfo = [...(soloPayoutInfo?.data?.list ?? []), ...(poolPayoutInfo?.data?.list ?? [])];
+
+ if (!payoutInfo) {
+ return null; // account doesn't have any history
+ }
+
+ return transformPayouts(address, payoutInfo, network);
+ } catch (error) {
+ lastError = error;
+ console.warn(`Attempt ${attempt} failed for ${network.text} and address ${address} (PAYOUT). Retrying...`);
+
+ // Exponential backoff
+ await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
+ }
+ }
+
+ // If all retries fail, log the final error
+ console.error(`(PAYOUT) Failed to fetch data for ${network.text} and address ${address} after ${MAX_RETRIES} attempts`, lastError);
+
+ return null;
+ });
+
+ // Wait for all address requests in this batch
+ const batchResults = await Promise.all(batchPromises);
+
+ // Add non-null results to overall results
+ results.push(...batchResults.filter((result) => result !== null));
+
+ // console.log('results:', results);
+
+ // If not the last batch, wait for 1 second
+ if (i + BATCH_SIZE < addresses.length) {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+ }
+ }
+
+ return results;
+};
+
+/**
+ * Fetches referendas information from subscan for the given chains
+ * @param chains - genesishash of the blockchain network
+ * @returns Array of payouts information
+ */
+export const getReferendasInformation = async (chains: string[]): Promise => {
+ const results: ReferendaInformation[] = [];
+ const networks = chains.map((value) => {
+ const chainName = getChainName(value);
+
+ return ({ text: getSubscanChainName(chainName), value }) as DropdownOption;
+ });
+
+ for (const network of networks) {
+ let REFERENDA_COUNT_TO_TRACK = 10; // default for testnets is 10
+
+ if (network.value === POLKADOT_GENESIS_HASH) {
+ REFERENDA_COUNT_TO_TRACK = REFERENDA_COUNT_TO_TRACK_DOT;
+ } else if (network.value === KUSAMA_GENESIS_HASH) {
+ REFERENDA_COUNT_TO_TRACK = REFERENDA_COUNT_TO_TRACK_KSM;
+ }
+
+ const promise = async () => {
+ let lastError: unknown = null;
+
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ const referendaInfo = await postData(`https://${network.text}.api.subscan.io/api/scan/referenda/referendums`, {
+ row: REFERENDA_COUNT_TO_TRACK
+ }) as ApiResponse<{
+ list: ReferendaSubscan[] | null
+ }>;
+
+ if (referendaInfo.code !== 0) {
+ throw new Error('Not a expected status code');
+ }
+
+ if (!referendaInfo.data.list) {
+ return null; // no referenda found
+ }
+
+ return transformReferendas(referendaInfo.data.list, network);
+ } catch (error) {
+ lastError = error;
+ console.warn(`Attempt ${attempt} failed for ${network.text} (REFERENDA). Retrying...`);
+
+ // Exponential backoff
+ await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
+ }
+ }
+
+ // If all retries fail, log the final error
+ console.error(`(REFERENDA) Failed to fetch data for ${network.text} after ${MAX_RETRIES} attempts`, lastError);
+
+ return null;
+ };
+
+ const result = await promise();
+
+ if (result) {
+ // Add non-null results to overall results
+ results.push(result);
+ }
+
+ // console.log('results:', results);
+ }
+
+ return results;
+};
diff --git a/packages/extension-polkagate/src/popup/notification/hook/useNotificationSettings.ts b/packages/extension-polkagate/src/popup/notification/hook/useNotificationSettings.ts
new file mode 100644
index 000000000..33c7804cc
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/hook/useNotificationSettings.ts
@@ -0,0 +1,197 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react';
+
+import { AccountContext } from '@polkadot/extension-polkagate/src/components';
+import { getStorage, setStorage, watchStorage } from '@polkadot/extension-polkagate/src/util';
+import { STORAGE_KEY } from '@polkadot/extension-polkagate/src/util/constants';
+
+import { DEFAULT_NOTIFICATION_SETTING, MAX_ACCOUNT_COUNT_NOTIFICATION, SET_UP_NOTIFICATION_SETTING } from '../constant';
+
+export interface NotificationSettingType {
+ accounts: string[] | undefined; // substrate addresses
+ enable: boolean | undefined;
+ receivedFunds: boolean | undefined;
+ governance: string[] | undefined; // genesisHashes
+ stakingRewards: string[] | undefined; // genesisHashes
+}
+
+type NotificationSettingsActionType =
+ | { type: 'INITIAL'; payload: NotificationSettingType }
+ | { type: 'TOGGLE_ENABLE'; }
+ | { type: 'TOGGLE_RECEIVED_FUNDS'; }
+ | { type: 'SET_ACCOUNTS'; payload: string[] }
+ | { type: 'SET_GOVERNANCE'; payload: string[] }
+ | { type: 'SET_STAKING_REWARDS'; payload: string[] };
+
+const initialNotificationState: NotificationSettingType = {
+ accounts: undefined,
+ enable: undefined,
+ governance: undefined,
+ receivedFunds: undefined,
+ stakingRewards: undefined
+};
+
+const notificationSettingReducer = (
+ state: NotificationSettingType,
+ action: NotificationSettingsActionType
+): NotificationSettingType => {
+ switch (action.type) {
+ case 'INITIAL':
+ return action.payload;
+ case 'TOGGLE_ENABLE':
+ return { ...state, enable: !state.enable, receivedFunds: state.enable ? false : state.receivedFunds };
+ case 'TOGGLE_RECEIVED_FUNDS':
+ return { ...state, receivedFunds: !state.receivedFunds };
+ case 'SET_GOVERNANCE':
+ return { ...state, governance: action.payload };
+ case 'SET_ACCOUNTS':
+ return { ...state, accounts: action.payload };
+ case 'SET_STAKING_REWARDS':
+ return { ...state, stakingRewards: action.payload };
+ default:
+ return state;
+ }
+};
+
+export enum Popups {
+ NONE,
+ ACCOUNTS,
+ GOVERNANCE,
+ STAKING_REWARDS
+}
+
+export default function useNotificationSettings (justLoadInfo = false) {
+ const { accounts } = useContext(AccountContext);
+ const [notificationSetting, dispatch] = useReducer(notificationSettingReducer, initialNotificationState);
+ const [popups, setPopup] = useState(Popups.NONE);
+
+ const notificationSettingRef = useRef(notificationSetting);
+
+ useEffect(() => {
+ // Update the ref whenever notificationSetting changes
+ notificationSettingRef.current = notificationSetting;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [JSON.stringify(notificationSetting)]); // Deep-watch for changes
+
+ useEffect(() => {
+ const loadNotificationSettings = async () => {
+ try {
+ const storedSettings = await getStorage(STORAGE_KEY.NOTIFICATION_SETTINGS);
+
+ if (!storedSettings) {
+ dispatch({
+ payload: DEFAULT_NOTIFICATION_SETTING,
+ type: 'INITIAL'
+ });
+
+ return;
+ }
+
+ dispatch({
+ payload: storedSettings as NotificationSettingType,
+ type: 'INITIAL'
+ });
+ } catch (error) {
+ console.error('Failed to load notification settings:', error);
+
+ dispatch({
+ payload: DEFAULT_NOTIFICATION_SETTING,
+ type: 'INITIAL'
+ });
+ }
+ };
+
+ loadNotificationSettings().catch(console.error);
+ }, []);
+
+ useEffect(() => {
+ if (justLoadInfo) {
+ const unsubscribe = watchStorage(STORAGE_KEY.NOTIFICATION_SETTINGS, (stored: NotificationSettingType) => {
+ dispatch({
+ payload: stored,
+ type: 'INITIAL'
+ });
+ });
+
+ return () => {
+ unsubscribe();
+ };
+ }
+
+ return undefined;
+ }, [justLoadInfo]);
+
+ const handleChainsChanges = useCallback((setting: NotificationSettingType) => {
+ setStorage(STORAGE_KEY.NOTIFICATION_SETTINGS, setting).catch(console.error);
+ }, []);
+
+ useEffect(() => {
+ if (justLoadInfo) {
+ return;
+ }
+
+ // Apply notification setting changes function that runs on unmount
+ return () => {
+ console.log('apply notification setting changes function that runs on unmount');
+ handleChainsChanges(notificationSettingRef.current);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [justLoadInfo]);
+
+ const toggleNotification = useCallback(() => {
+ const isFirstTime = [...(notificationSetting.governance ?? []), ...(notificationSetting.stakingRewards ?? []), ...(notificationSetting.accounts ?? [])].length === 0;
+
+ if (isFirstTime) {
+ const addresses = accounts.map(({ address }) => address).slice(0, MAX_ACCOUNT_COUNT_NOTIFICATION);
+
+ dispatch({
+ payload: {
+ ...SET_UP_NOTIFICATION_SETTING, // accounts is an empty array in the constant file
+ accounts: addresses // This line fills the empty accounts array with random address from the extension
+ },
+ type: 'INITIAL'
+ });
+
+ return;
+ }
+
+ dispatch({ type: 'TOGGLE_ENABLE' });
+ }, [accounts, notificationSetting.accounts, notificationSetting.governance, notificationSetting.stakingRewards]);
+
+ const toggleReceivedFunds = useCallback(() => {
+ dispatch({ type: 'TOGGLE_RECEIVED_FUNDS' });
+ }, []);
+
+ const closePopup = useCallback(() => setPopup(Popups.NONE), []);
+
+ const setGovernanceChains = useCallback((chains: string[]) => () => {
+ dispatch({ payload: chains, type: 'SET_GOVERNANCE' });
+ closePopup();
+ }, [closePopup]);
+
+ const setStakingRewardsChains = useCallback((chains: string[]) => () => {
+ dispatch({ payload: chains, type: 'SET_STAKING_REWARDS' });
+ closePopup();
+ }, [closePopup]);
+
+ const setAccounts = useCallback((addresses: string[]) => () => {
+ dispatch({ payload: addresses, type: 'SET_ACCOUNTS' });
+ closePopup();
+ }, [closePopup]);
+
+ const openPopup = useCallback((popup: Popups) => () => setPopup(popup), []);
+
+ return {
+ closePopup,
+ notificationSetting,
+ openPopup,
+ popups,
+ setAccounts,
+ setGovernanceChains,
+ setStakingRewardsChains,
+ toggleNotification,
+ toggleReceivedFunds
+ };
+}
diff --git a/packages/extension-polkagate/src/popup/notification/index.tsx b/packages/extension-polkagate/src/popup/notification/index.tsx
new file mode 100644
index 000000000..26408a9f2
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/index.tsx
@@ -0,0 +1,71 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { Container, Grid } from '@mui/material';
+import React, { useCallback, useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { BackWithLabel, FadeOnScroll, Motion } from '@polkadot/extension-polkagate/src/components';
+import { useBackground, useTranslation } from '@polkadot/extension-polkagate/src/hooks';
+import useNotifications from '@polkadot/extension-polkagate/src/hooks/useNotifications';
+import { HomeMenu, UserDashboardHeader, WhatsNew } from '@polkadot/extension-polkagate/src/partials';
+import { VelvetBox } from '@polkadot/extension-polkagate/src/style';
+
+import NotificationGroup from './partials/NotificationGroup';
+import { ColdStartNotification, NoNotificationYet, NotificationLoading, OffNotificationMessage } from './partials/Partial';
+
+function Notification () {
+ useBackground('default');
+
+ const refContainer = useRef(null);
+ const { markAsRead, notificationItems, status } = useNotifications();
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ useEffect(() => markAsRead(), [markAsRead]);
+
+ const openSettings = useCallback(() => navigate('/notification/settings') as void, [navigate]);
+ const backHome = useCallback(() => navigate('/') as void, [navigate]);
+
+ return (
+
+
+
+
+
+
+ {notificationItems && Object.entries(notificationItems).map(([dateKey, items]) => (
+
+ ))}
+ {status.isNotificationOff &&
+ }
+ {status.isFirstTime &&
+ }
+ {status.noNotificationYet &&
+ }
+ {status.loading &&
+ }
+
+
+
+
+
+
+
+ );
+}
+
+export default Notification;
diff --git a/packages/extension-polkagate/src/popup/notification/partials/AccountToggle.tsx b/packages/extension-polkagate/src/popup/notification/partials/AccountToggle.tsx
new file mode 100644
index 000000000..8f184fc9c
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/partials/AccountToggle.tsx
@@ -0,0 +1,50 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { Stack } from '@mui/material';
+import React, { type ChangeEvent, memo, useCallback } from 'react';
+
+import { GradientDivider, Identity2, MySwitch } from '@polkadot/extension-polkagate/src/components';
+import { POLKADOT_GENESIS_HASH } from '@polkadot/extension-polkagate/src/util/constants';
+
+interface Props {
+ address: string | undefined;
+ checked: boolean;
+ onSelect: (newSelect: string) => void;
+ withDivider?: boolean;
+}
+
+function AccountToggle ({ address, checked, onSelect, withDivider = true }: Props) {
+ const handleSelect = useCallback((event: ChangeEvent, _checked: boolean) => {
+ const selected = event.target.value;
+
+ onSelect(selected);
+ }, [onSelect]);
+
+ return (
+ <>
+
+
+
+
+ {withDivider && }
+ >
+ );
+}
+
+export default memo(AccountToggle);
diff --git a/packages/extension-polkagate/src/popup/notification/partials/ChainToggle.tsx b/packages/extension-polkagate/src/popup/notification/partials/ChainToggle.tsx
new file mode 100644
index 000000000..7ffadf075
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/partials/ChainToggle.tsx
@@ -0,0 +1,40 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { Stack, Typography } from '@mui/material';
+import React, { type ChangeEvent, memo, useCallback } from 'react';
+
+import { ChainLogo, MySwitch } from '@polkadot/extension-polkagate/src/components';
+
+interface Props {
+ checked: boolean;
+ genesis: string | undefined;
+ text: string | undefined;
+ onSelect: (newSelect: string) => void;
+}
+
+function ChainToggle ({ checked, genesis, onSelect, text }: Props) {
+ const handleSelect = useCallback((event: ChangeEvent, _checked: boolean) => {
+ const selected = event.target.value;
+
+ onSelect(selected);
+ }, [onSelect]);
+
+ return (
+
+
+
+
+ {text}
+
+
+
+
+ );
+}
+
+export default memo(ChainToggle);
diff --git a/packages/extension-polkagate/src/popup/notification/partials/NotificationGroup.tsx b/packages/extension-polkagate/src/popup/notification/partials/NotificationGroup.tsx
new file mode 100644
index 000000000..f309581cf
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/partials/NotificationGroup.tsx
@@ -0,0 +1,113 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { NotificationMessageType } from '../types';
+
+import { Grid, Stack, Typography, useTheme } from '@mui/material';
+import React, { Fragment, useContext } from 'react';
+
+import { CurrencyContext, GradientDivider, ScrollingTextBox, TwoToneText } from '@polkadot/extension-polkagate/src/components';
+import { useAccount, useChainInfo, useTokenPriceBySymbol, useTranslation } from '@polkadot/extension-polkagate/src/hooks';
+import { toShortAddress } from '@polkadot/extension-polkagate/src/util';
+
+import { getNotificationDescription, getNotificationIcon, getNotificationItemTitle, getTimeOfDay, isToday } from '../util';
+
+function ItemDate ({ date }: { date: string; }) {
+ const theme = useTheme();
+ const isTodayDate = isToday(date);
+
+ return (
+
+ {date}
+
+ );
+}
+
+function TitleTime ({ address, noName, read, time, title }: { address: string | undefined; read: boolean; time: string; title: string; noName: boolean }) {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const account = useAccount(address);
+
+ return (
+
+
+
+ {title}
+
+ {!noName &&
+ }
+ {!read && }
+
+
+ {time}
+
+
+ );
+}
+
+function NotificationItem ({ item }: { item: NotificationMessageType; }) {
+ const theme = useTheme();
+ const { t } = useTranslation();
+ const { currency } = useContext(CurrencyContext);
+
+ const genesisHash = item.chain?.value as string ?? '';
+
+ const chainInfo = useChainInfo(genesisHash, true);
+ const price = useTokenPriceBySymbol(chainInfo.token, genesisHash);
+
+ const title = getNotificationItemTitle(t, item.type, item.referenda);
+ const time = getTimeOfDay(item.payout?.timestamp ?? item.receivedFund?.timestamp ?? item.referenda?.latestTimestamp ?? Date.now());
+ const { text, textInColor } = getNotificationDescription(item, t, chainInfo, price, currency);
+ const { ItemIcon, bgcolor, borderColor, color } = getNotificationIcon(item);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function NotificationGroup ({ group: [dateKey, items] }: { group: [string, NotificationMessageType[]]; }) {
+ return (
+
+
+ {items.map((item, index) => (
+
+
+ {items.length > index + 1 &&
+
+ }
+
+ ))}
+
+ );
+}
+
+export default NotificationGroup;
diff --git a/packages/extension-polkagate/src/popup/notification/partials/Partial.tsx b/packages/extension-polkagate/src/popup/notification/partials/Partial.tsx
new file mode 100644
index 000000000..b22881a32
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/partials/Partial.tsx
@@ -0,0 +1,110 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import { Grid, Stack, type SxProps, type Theme, Typography } from '@mui/material';
+import { Like1, Setting2 } from 'iconsax-react';
+import React from 'react';
+
+import { ActionButton, MySkeleton } from '@polkadot/extension-polkagate/src/components';
+import { useTranslation } from '@polkadot/extension-polkagate/src/hooks';
+import { EXTENSION_NAME } from '@polkadot/extension-polkagate/src/util/constants';
+
+const OffNotificationMessage = ({ onClick, style }: { onClick: () => void; style?: SxProps; }) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('You’ve turned off notifications. Enable them anytime to get updates on your accounts, governance, and staking rewards!')}
+
+
+
+ );
+};
+
+const ColdStartNotification = ({ onClick, style }: { onClick: () => void; style?: SxProps; }) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {`${t('Introducing notifications')}!`}
+
+
+ {t('{{extensionName}} now has notifications! Important warnings and updates will be delivered to you as notifications, so make sure you don\'t miss any.',
+ { replace: { extensionName: EXTENSION_NAME } })}
+
+
+ {t('Fine-tune your notification experience in Settings')}
+
+
+
+ );
+};
+
+const NotificationLoading = ({ count = 3 }: { count?: number }) => {
+ return (
+
+ {Array(count).fill(1).map((item, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+};
+
+const NoNotificationYet = ({ onClick, style }: { onClick: () => void; style?: SxProps; }) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('Everything’s calm, you don’t have any notifications yet')}.
+
+
+
+ );
+};
+
+export { ColdStartNotification, NoNotificationYet, NotificationLoading, OffNotificationMessage };
diff --git a/packages/extension-polkagate/src/popup/notification/partials/SelectAccount.tsx b/packages/extension-polkagate/src/popup/notification/partials/SelectAccount.tsx
new file mode 100644
index 000000000..67db31dd4
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/partials/SelectAccount.tsx
@@ -0,0 +1,102 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ExtensionPopupCloser } from '../../../util/handleExtensionPopup';
+
+import { Stack, Typography } from '@mui/material';
+import { UserOctagon } from 'iconsax-react';
+import React, { useCallback, useContext, useEffect, useState } from 'react';
+
+import { AccountContext, ExtensionPopup, GradientButton, GradientDivider } from '../../../components';
+import { useTranslation } from '../../../hooks';
+import { MAX_ACCOUNT_COUNT_NOTIFICATION } from '../constant';
+import AccountToggle from './AccountToggle';
+
+interface Props {
+ onClose: ExtensionPopupCloser;
+ open: boolean;
+ onAccounts: (addresses: string[]) => () => void;
+ previousState: string[] | undefined;
+}
+
+/**
+ * A component for selecting an account. It allows the user to choose
+ * which accounts to see their notifications for.
+ *
+ * Only has been used in extension mode!
+ */
+function SelectAccount ({ onAccounts, onClose, open, previousState }: Props): React.ReactElement {
+ const { t } = useTranslation();
+ const { accounts } = useContext(AccountContext);
+
+ const [selectedAccounts, setSelectedAccounts] = useState(previousState ?? []);
+
+ // Ensure state updates when previousState changes
+ useEffect(() => {
+ if (previousState) {
+ setSelectedAccounts(previousState);
+ }
+ }, [previousState]);
+
+ // Handles selecting or deselecting an account
+ const handleSelect = useCallback((newSelect: string) => {
+ setSelectedAccounts((prev) => {
+ const alreadySelected = prev.includes(newSelect);
+
+ if (alreadySelected) {
+ // If the account is already selected, remove it
+ return prev.filter((address) => address !== newSelect);
+ }
+
+ // Prevent adding more than the max allowed
+ if (prev.length >= MAX_ACCOUNT_COUNT_NOTIFICATION) {
+ return prev; // return unchanged state
+ }
+
+ // Otherwise, add the new account
+ return [...prev, newSelect];
+ });
+ }, [setSelectedAccounts]);
+
+ return (
+ div#container': { pt: '8px' } }}
+ title={t('Accounts')}
+ withoutTopBorder
+ >
+
+
+ {t('Select up to 3 accounts to be notified when account activity')}
+
+
+
+ {accounts.map(({ address }) => {
+ const isSelected = selectedAccounts.includes(address);
+
+ return (
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export default React.memo(SelectAccount);
diff --git a/packages/extension-polkagate/src/popup/notification/partials/SelectChain.tsx b/packages/extension-polkagate/src/popup/notification/partials/SelectChain.tsx
new file mode 100644
index 000000000..2359b2be5
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/partials/SelectChain.tsx
@@ -0,0 +1,97 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ExtensionPopupCloser } from '../../../util/handleExtensionPopup';
+import type { TextValuePair } from '../NotificationSettings';
+
+import { Stack } from '@mui/material';
+import { UserOctagon } from 'iconsax-react';
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { sanitizeChainName } from '@polkadot/extension-polkagate/src/util';
+
+import { ExtensionPopup, GradientButton, GradientDivider } from '../../../components';
+import { useTranslation } from '../../../hooks';
+import ChainToggle from './ChainToggle';
+
+interface Props {
+ onClose: ExtensionPopupCloser;
+ open: boolean;
+ onChains: (addresses: string[]) => () => void;
+ previousState: string[] | undefined;
+ options: TextValuePair[];
+ title: string;
+}
+
+/**
+ * A component for selecting chains. It allows the user to choose
+ * on which chains see their notifications.
+ *
+ * Only has been used in extension mode!
+ */
+function SelectChain ({ onChains, onClose, open, options, previousState, title }: Props): React.ReactElement {
+ const { t } = useTranslation();
+
+ const [selectedChains, setSelectedChains] = useState(previousState ?? []);
+
+ // Ensure state updates when previousState changes
+ useEffect(() => {
+ if (previousState) {
+ setSelectedChains(previousState);
+ }
+ }, [previousState]);
+
+ // Handles selecting or deselecting
+ const handleSelect = useCallback((newSelect: string) => {
+ setSelectedChains((prev) => {
+ const alreadySelected = prev.includes(newSelect);
+
+ if (alreadySelected) {
+ // If is already selected, remove it
+ return prev.filter((chain) => chain !== newSelect);
+ }
+
+ // add
+ return [...prev, newSelect];
+ });
+ }, []);
+
+ return (
+ div#container': { pt: '8px' } }}
+ title={title}
+ withoutTopBorder
+ >
+
+
+
+ {options.map(({ text, value }) => {
+ const isSelected = selectedChains.includes(value);
+
+ return (
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export default React.memo(SelectChain);
diff --git a/packages/extension-polkagate/src/popup/notification/types.ts b/packages/extension-polkagate/src/popup/notification/types.ts
new file mode 100644
index 000000000..29ed5d452
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/types.ts
@@ -0,0 +1,161 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+import type { DropdownOption } from '@polkadot/extension-polkagate/src/util/types';
+import type { ReferendaStatus } from './constant';
+
+export interface ApiResponse {
+ code: number;
+ message: string;
+ generated_at: number;
+ data: T;
+}
+
+export interface TransferSubscan {
+ transfer_id: number;
+ from: string;
+ from_account_display: AccountDisplay;
+ to: string;
+ to_account_display: AccountDisplayWithMerkle;
+ extrinsic_index: string;
+ success: boolean;
+ hash: string;
+ block_num: number;
+ block_timestamp: number;
+ module: string;
+ amount: string;
+ amount_v2: string;
+ current_currency_amount: string;
+ currency_amount: string;
+ fee: string;
+ nonce: number;
+ asset_symbol: string;
+ asset_unique_id: string;
+ asset_type: string;
+ item_id: string | null;
+ event_idx: number;
+ is_lock: boolean;
+}
+
+export interface PayoutSubscan {
+ era: number;
+ stash: string;
+ account: string;
+ validator_stash: string;
+ extrinsic_index: string;
+ amount: string;
+ block_timestamp: number;
+ module_id: string;
+ event_id: string;
+}
+
+export interface ReferendaSubscan {
+ referendum_index: number;
+ created_block_timestamp: number;
+ origins_id: number;
+ origins: string;
+ call_module: string;
+ status: string;
+ latest_block_timestamp: number;
+ account: AccountDisplay;
+ title: string;
+}
+
+interface AccountDisplay {
+ address: string;
+ people: Record;
+}
+
+interface AccountDisplayWithMerkle extends AccountDisplay {
+ merkle?: {
+ address_type: string;
+ tag_type: string;
+ tag_subtype: string;
+ tag_name: string;
+ };
+}
+
+export interface TransfersProp {
+ amount: string;
+ assetSymbol: string;
+ currencyAmount: string;
+ date: string;
+ from: string;
+ fromAccountDisplay: AccountDisplay;
+ toAccountId: AccountDisplay;
+ timestamp: number;
+}
+
+export interface PayoutsProp {
+ era: number;
+ amount: string;
+ date: string;
+ timestamp: number;
+}
+
+export interface ReferendaProp {
+ referendumIndex: number;
+ createdTimestamp: number;
+ chainName: string;
+ originsId: number;
+ origins: string;
+ callModule: string;
+ status: ReferendaStatus;
+ latestTimestamp: number;
+ account: AccountDisplay;
+ title: string;
+}
+
+export interface ReceivedFundInformation {
+ address: string;
+ data: TransfersProp[];
+ network: DropdownOption;
+}
+
+export interface StakingRewardInformation {
+ address: string;
+ data: PayoutsProp[];
+ network: DropdownOption;
+}
+
+export interface ReferendaInformation {
+ data: ReferendaProp[];
+ network: DropdownOption;
+}
+
+// export interface ReferendaNotificationType {
+// chainName: string;
+// latestTimestamp: number;
+// status?: ReferendaStatus;
+// referendumIndex?: number;
+// }
+
+export type NotificationType = 'referenda' | 'stakingReward' | 'receivedFund';
+
+export interface NotificationMessageType {
+ chain?: DropdownOption;
+ type: NotificationType;
+ payout?: PayoutsProp;
+ referenda?: ReferendaProp;
+ receivedFund?: TransfersProp;
+ forAccount?: string;
+ extrinsicIndex?: string;
+ read: boolean;
+}
+
+export interface NotificationsType {
+ notificationMessages: NotificationMessageType[] | undefined;
+ referendas: ReferendaInformation[] | null | undefined;
+ receivedFunds: ReceivedFundInformation[] | null | undefined;
+ stakingRewards: StakingRewardInformation[] | null | undefined;
+ latestLoggedIn: number | undefined;
+ isFirstTime: boolean | undefined;
+}
+
+export type NotificationActionType =
+ | { type: 'INITIALIZE'; }
+ | { type: 'MARK_AS_READ'; }
+ | { type: 'LOAD_FROM_STORAGE'; payload: NotificationsType }
+ | { type: 'SET_REFERENDA'; payload: ReferendaInformation[] }
+ | { type: 'SET_RECEIVED_FUNDS'; payload: NotificationsType['receivedFunds'] }
+ | { type: 'SET_STAKING_REWARDS'; payload: NotificationsType['stakingRewards'] };
diff --git a/packages/extension-polkagate/src/popup/notification/util.ts b/packages/extension-polkagate/src/popup/notification/util.ts
new file mode 100644
index 000000000..fe277610d
--- /dev/null
+++ b/packages/extension-polkagate/src/popup/notification/util.ts
@@ -0,0 +1,589 @@
+// Copyright 2019-2025 @polkadot/extension-polkagate authors & contributors
+// SPDX-License-Identifier: Apache-2.0
+
+/* eslint-disable no-template-curly-in-string */
+
+import type { TFunction } from '@polkagate/apps-config/types';
+import type { CurrencyItemType } from '@polkadot/extension-polkagate/src/fullscreen/home/partials/type';
+import type { ChainInfo } from '@polkadot/extension-polkagate/src/hooks/useChainInfo';
+import type { Price } from '@polkadot/extension-polkagate/src/hooks/useTokenPriceBySymbol';
+import type { NotificationMessageType, NotificationType, ReceivedFundInformation, ReferendaInformation, ReferendaProp, StakingRewardInformation } from './types';
+
+import { ArrowDown3, Award, Receipt2 } from 'iconsax-react';
+
+import { NOTIFICATION_TIMESTAMP_OFFSET } from './constant';
+
+export function timestampToDate (timestamp: number | string, format: 'full' | 'short' | 'relative' = 'full'): string {
+ // Ensure timestamp is a number and convert if it's a string
+ const timestampNum = Number(timestamp);
+
+ // Check if timestamp is valid
+ if (isNaN(timestampNum)) {
+ return 'Invalid Timestamp';
+ }
+
+ // Create a Date object (multiply by 1000 if it's a Unix timestamp in seconds)
+ const date = new Date(timestampNum * 1000);
+
+ // Different formatting options
+ switch (format) {
+ case 'full':
+ return date.toLocaleString('en-US', {
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ month: 'long',
+ second: '2-digit'
+ });
+
+ case 'short':
+ return date.toLocaleString('en-US', {
+ day: 'numeric',
+ month: 'short'
+ });
+
+ case 'relative':
+ return getRelativeTime(date);
+
+ default:
+ return date.toLocaleString();
+ }
+}
+
+// Helper function to get relative time
+function getRelativeTime (date: Date): string {
+ const now = new Date();
+ const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
+
+ const units = [
+ { name: 'year', seconds: 31536000 },
+ { name: 'month', seconds: 2592000 },
+ { name: 'week', seconds: 604800 },
+ { name: 'day', seconds: 86400 },
+ { name: 'hour', seconds: 3600 },
+ { name: 'minute', seconds: 60 }
+ ];
+
+ for (const unit of units) {
+ const value = Math.floor(diffInSeconds / unit.seconds);
+
+ if (value >= 1) {
+ return value === 1
+ ? `1 ${unit.name} ago`
+ : `${value} ${unit.name}s ago`;
+ }
+ }
+
+ return diffInSeconds <= 0 ? 'just now' : `${diffInSeconds} seconds ago`;
+}
+
+/**
+ * Generates notifications for new or updated referenda
+ * @param previousRefs - Previous state of referenda (by network)
+ * @param newRefs - Current state of referenda (by network)
+ * @returns Array of new notification messages
+ */
+export const generateReferendaNotifications = (
+ latestLoggedIn: number,
+ previousRefs: ReferendaInformation[] | null | undefined,
+ newRefs: ReferendaInformation[]
+): NotificationMessageType[] => {
+ const newMessages: NotificationMessageType[] = [];
+
+ for (const currentNetworkData of newRefs) {
+ let { data: currentReferenda, network } = currentNetworkData;
+
+ currentReferenda = currentReferenda.filter(({ latestTimestamp }) => latestTimestamp >= (latestLoggedIn - NOTIFICATION_TIMESTAMP_OFFSET));
+
+ const prevNetworkData = previousRefs?.find(
+ (p) => p.network.value === network.value
+ );
+
+ const previousReferenda = prevNetworkData?.data ?? [];
+
+ // Find new referenda (not in previous state)
+ const newReferenda = currentReferenda.filter((current) =>
+ !previousReferenda.some((prev) => prev.referendumIndex === current.referendumIndex)
+ );
+
+ // Find referenda with status changes
+ const updatedReferenda = currentReferenda.filter(
+ (current) =>
+ previousReferenda.some(
+ (prev) =>
+ prev.referendumIndex === current.referendumIndex &&
+ prev.status !== current.status
+ )
+ );
+
+ // Generate notifications for new referenda
+ for (const ref of newReferenda) {
+ newMessages.push({
+ chain: network,
+ read: false,
+ referenda: ref,
+ type: 'referenda'
+ });
+ }
+
+ // Generate notifications for referenda with status changes
+ for (const ref of updatedReferenda) {
+ newMessages.push({
+ chain: network,
+ read: false,
+ referenda: ref,
+ type: 'referenda'
+ });
+ }
+ }
+
+ return newMessages;
+};
+
+/**
+ * Generates notifications for new staking rewards
+ * @param currentReferenda - Current state of referenda
+ * @returns Array of new notification messages
+ */
+export const generateStakingRewardNotifications = (
+ latestLoggedIn: number,
+ payouts: StakingRewardInformation[]
+): NotificationMessageType[] => {
+ const newMessages: NotificationMessageType[] = [];
+
+ payouts.forEach(({ address, data, network }) => {
+ data
+ .filter(({ timestamp }) => timestamp >= latestLoggedIn)
+ .forEach((payout) => {
+ newMessages.push({
+ chain: network,
+ forAccount: address,
+ payout,
+ read: false,
+ type: 'stakingReward'
+ });
+ });
+ });
+
+ return newMessages;
+};
+
+/**
+ * Generates notifications for new staking rewards
+ * @param currentReferenda - Current state of referenda
+ * @returns Array of new notification messages
+ */
+export const generateReceivedFundNotifications = (
+ latestLoggedIn: number,
+ transfers: ReceivedFundInformation[]
+): NotificationMessageType[] => {
+ const newMessages: NotificationMessageType[] = [];
+
+ transfers.forEach(({ address, data, network }) => {
+ data
+ .filter(({ timestamp }) => timestamp >= latestLoggedIn)
+ .forEach((receivedFund) => {
+ newMessages.push({
+ chain: network,
+ forAccount: address,
+ read: false,
+ receivedFund,
+ type: 'receivedFund'
+ });
+ });
+ });
+
+ return newMessages;
+};
+
+/**
+ * Marks messages as read
+ * @param messages - Notification messages
+ * @returns Array of new notification messages
+ */
+export const markMessagesAsRead = (messages: NotificationMessageType[]) => {
+ return messages.map((message) => (
+ {
+ ...message,
+ read: true
+ }
+ ));
+};
+
+/**
+ * Merges two arrays of ReferendaInformation without duplicating existing referenda.
+ * - Keeps previous referenda data
+ * - Adds new referenda from the new state
+ * - Preserves per-network separation
+ */
+export const updateReferendas = (preciousRefs: ReferendaInformation[] | null | undefined, newRefs: ReferendaInformation[]) => {
+ if (!preciousRefs) {
+ return newRefs;
+ }
+
+ const resultMap = new Map();
+
+ // Copy all previous data
+ for (const prev of preciousRefs) {
+ resultMap.set(prev.network.value, {
+ data: [...prev.data],
+ network: prev.network
+ });
+ }
+
+ // Merge new data
+ for (const current of newRefs) {
+ const existing = resultMap.get(current.network.value);
+
+ if (!existing) {
+ // Entirely new network — just add it
+ resultMap.set(current.network.value, current);
+ continue;
+ }
+
+ const updatedData = [...existing.data];
+ const existingIndexes = new Map(
+ existing.data.map((r) => [r.referendumIndex, r])
+ );
+
+ for (const newRef of current.data) {
+ const existingRef = existingIndexes.get(newRef.referendumIndex);
+
+ if (!existingRef) {
+ // New referendum
+ updatedData.push(newRef);
+ } else if (existingRef.status !== newRef.status) {
+ // Status updated → replace the old one
+ const idx = updatedData.findIndex(
+ (r) => r.referendumIndex === newRef.referendumIndex
+ );
+
+ if (idx !== -1) {
+ updatedData[idx] = newRef;
+ }
+ }
+ }
+
+ resultMap.set(current.network.value, {
+ data: updatedData,
+ network: current.network
+ });
+ }
+
+ return Array.from(resultMap.values());
+};
+
+// Utility to get date string like "15 Dec 2025"
+function getDayKey (timestamp: number): string {
+ const date = new Date(timestamp * 1000); // convert seconds → ms
+
+ // Format: "15 Dec 2025"
+ return date.toLocaleDateString('en-GB', {
+ day: '2-digit',
+ month: 'short',
+ year: 'numeric'
+ });
+}
+
+export function groupNotificationsByDay (
+ notifications: NotificationMessageType[] | undefined
+): Record | undefined {
+ const seen = new Set(); // to avoid duplicates globally
+
+ if (!notifications) {
+ return;
+ }
+
+ const grouped = notifications.reduce>((acc, item) => {
+ let timestamp: number | undefined;
+ let uniqueKey = '';
+
+ switch (item.type) {
+ case 'stakingReward':
+ timestamp = item.payout?.timestamp;
+ uniqueKey = JSON.stringify(item.payout ?? '');
+
+ break;
+ case 'receivedFund':
+ timestamp = item.receivedFund?.timestamp;
+ uniqueKey = JSON.stringify(item.receivedFund ?? '');
+
+ break;
+
+ case 'referenda':
+ timestamp = item.referenda?.latestTimestamp;
+ uniqueKey = JSON.stringify(item.referenda ?? '');
+
+ break;
+ }
+
+ if (!timestamp || seen.has(uniqueKey)) {
+ return acc;
+ }
+
+ const dayKey = getDayKey(timestamp);
+
+ if (!acc[dayKey]) {
+ acc[dayKey] = [];
+ }
+
+ acc[dayKey].push(item);
+
+ seen.add(uniqueKey);
+
+ return acc;
+ }, {});
+
+ // Sort items within each day by timestamp (newest first)
+ for (const dayKey in grouped) {
+ grouped[dayKey].sort((a, b) => {
+ const getTimestamp = (item: NotificationMessageType): number => {
+ switch (item.type) {
+ case 'stakingReward':
+ return item.payout?.timestamp ?? 0;
+ case 'receivedFund':
+ return item.receivedFund?.timestamp ?? 0;
+ case 'referenda':
+ return item.referenda?.latestTimestamp ?? 0;
+ default:
+ return 0;
+ }
+ };
+
+ return getTimestamp(b) - getTimestamp(a);
+ });
+ }
+
+ // Sort the day groups themselves (newest first)
+ const sortedEntries = Object.entries(grouped).sort(([a], [b]) => {
+ // Parse your "15 Dec 2025" strings back into Date objects for sorting
+ const dateA = new Date(a);
+ const dateB = new Date(b);
+
+ return dateB.getTime() - dateA.getTime(); // newest first
+ });
+
+ // Convert sorted entries back into a Record
+ const sortedGrouped: Record = Object.fromEntries(sortedEntries);
+
+ return sortedGrouped;
+}
+
+/**
+ * Check if a given date string (formatted as "17 Dec 2024")
+ * represents today's date.
+ *
+ * @param dateString - The date string in format "DD Mon YYYY"
+ * @returns true if the date is today's date, otherwise false
+ */
+export function isToday (dateString: string): boolean {
+ const today = new Date();
+ const todayString = today.toLocaleDateString('en-GB', {
+ day: '2-digit',
+ month: 'short',
+ year: 'numeric'
+ });
+
+ return dateString === todayString;
+}
+
+/**
+ * Converts a timestamp (in seconds) to a formatted local time string like "10:01 am".
+ *
+ * @param timestamp - The UNIX timestamp in seconds
+ * @returns A formatted time string (e.g., "10:01 am")
+ */
+export function getTimeOfDay (timestamp: number): string {
+ // Convert timestamp from seconds → milliseconds
+ const date = new Date(timestamp * 1000);
+
+ // Format time as "10:01 am" or "9:45 pm"
+ return date.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ hour12: true, // Use 12-hour format with am/pm
+ minute: '2-digit'
+ }).toLowerCase(); // optional: make "AM"/"PM" lowercase
+}
+
+// /**
+// * Converts a numeric string with blockchain-style decimals (e.g. "92861564408", 10)
+// * into a human-readable decimal string (e.g. "9.2861564408").
+// */
+// function divideAmountByDecimals (amountStr: string, decimals: number): string {
+// if (typeof amountStr !== 'string' || isNaN(Number(decimals)) || decimals < 0) {
+// return amountStr;
+// }
+// amountStr = amountStr.replace(/^0+/, '').padStart(decimals + 1, '0'); // remove leading zeros safely
+// const intPart = amountStr.slice(0, -decimals) || '0';
+// const fracPart = amountStr.slice(-decimals).replace(/0+$/, ''); // remove trailing zeros
+// return fracPart ? `${intPart}.${fracPart}` : intPart;
+// }
+
+/**
+ * Converts a blockchain-style integer (string or number) into a scaled number.
+ * Examples:
+ * - "92861564408", decimal=10 → 9.2861564408
+ * - 1234567 → 1234567
+ * - "123450000000000000000", decimal=18 → 123.45
+ *
+ * If the value is extremely large, it safely clamps at Number.MAX_SAFE_INTEGER.
+ *
+ * @param value - numeric string or number
+ * @param decimalPoint - number of decimal digits to keep
+ * @param decimal - blockchain-style decimals (divide by 10^decimal)
+ * @returns numeric result (rounded)
+ */
+export function formatNumber (
+ value: number | string | undefined,
+ decimalPoint = 2,
+ decimal = 0
+): number {
+ if (value === undefined || value === null) {
+ return 0;
+ }
+
+ const strValue = value.toString().replace(/,/g, '').trim();
+
+ if (!/^\d+(\.\d+)?$/.test(strValue)) {
+ return 0;
+ }
+
+ let result: number;
+
+ // If blockchain-style decimal division is required
+ if (decimal > 0 && /^[0-9]+$/.test(strValue)) {
+ // Use BigInt division for safety
+ const bigintVal = BigInt(strValue);
+ const divisor = 10n ** BigInt(decimal);
+
+ // Get decimal as string manually
+ const intPart = bigintVal / divisor;
+ const fracPart = bigintVal % divisor;
+ const fracStr = fracPart.toString().padStart(decimal, '0').slice(0, decimalPoint);
+
+ result = parseFloat(`${intPart}.${fracStr}`);
+ } else {
+ result = parseFloat(strValue);
+ }
+
+ if (!isFinite(result)) {
+ return Number.MAX_SAFE_INTEGER;
+ }
+
+ // Round to requested decimal points
+ const rounded = Number(result.toFixed(decimalPoint));
+
+ return Math.min(rounded, Number.MAX_SAFE_INTEGER);
+}
+
+export function getNotificationItemTitle (t: TFunction, type: NotificationType, referenda?: ReferendaProp) {
+ switch (type) {
+ case 'receivedFund':
+ return t('Fund Received');
+
+ case 'referenda': {
+ const status = referenda?.status ?? '';
+
+ if (['approved', 'executed'].includes(status)) {
+ return t('Referendum approved');
+ } else if (['ongoing', 'decision', 'submitted'].includes(referenda?.status ?? '')) {
+ return t('New Referendum');
+ } else if (referenda?.status === 'cancelled') {
+ return t('Referendum Cancelled');
+ } else if (referenda?.status === 'timeout') {
+ return t('Referendum time outed');
+ } else {
+ return t('Referendum Rejected');
+ }
+ }
+
+ case 'stakingReward':
+ return t('Reward');
+
+ default:
+ return t('Update');
+ }
+}
+
+export function getNotificationDescription (item: NotificationMessageType, t: TFunction, chainInfo: ChainInfo, price: Price, currency: CurrencyItemType | undefined) {
+ const { chainName, decimal, token } = chainInfo;
+ const currencySign = currency?.sign;
+
+ switch (item.type) {
+ case 'receivedFund': {
+ const assetSymbol = item.receivedFund?.assetSymbol;
+ const assetAmount = formatNumber(item.receivedFund?.amount);
+ const currencyAmount = formatNumber(item.receivedFund?.currencyAmount);
+
+ const amountSection = `${assetAmount} ${assetSymbol} (${currencySign}${currencyAmount})`;
+
+ return {
+ text: t('Received {{amountSection}} on {{chainName}}', { replace: { amountSection, chainName } }),
+ textInColor: amountSection
+ };
+ }
+
+ case 'referenda': {
+ const statusMap: Record = {
+ approved: t('{{chainName}} referendum #{{referendumIndex}} has been approved'),
+ cancelled: t('{{chainName}} referendum #{{referendumIndex}} has been cancelled'),
+ decision: t('{{chainName}} referendum #{{referendumIndex}} has been created'),
+ executed: t('{{chainName}} referendum #{{referendumIndex}} has been executed'),
+ ongoing: t('{{chainName}} referendum #{{referendumIndex}} has been created'),
+ rejected: t('{{chainName}} referendum #{{referendumIndex}} has been rejected'),
+ submitted: t('{{chainName}} referendum #{{referendumIndex}} has been submitted'),
+ timeout: t('{{chainName}} referendum #{{referendumIndex}} has timed out')
+ };
+
+ const status = item.referenda?.status;
+ const referendumIndex = item.referenda?.referendumIndex;
+ // Default to "rejected" text if status is missing
+ const textTemplate = statusMap[status ?? 'rejected'];
+
+ return {
+ text: t(textTemplate, {
+ replace: { chainName, referendumIndex }
+ }),
+ textInColor: `#${referendumIndex}`
+ };
+ }
+
+ case 'stakingReward': {
+ const assetAmount = formatNumber(item.payout?.amount, 2, decimal);
+ const currencyAmount = formatNumber(assetAmount * (price.price ?? 0));
+
+ const amountSection = `${assetAmount} ${token} (${currencySign}${currencyAmount})`;
+
+ return {
+ text: t('Received {{amountSection}} from {{chainName}} staking', { replace: { amountSection, chainName } }),
+ textInColor: amountSection
+ };
+ }
+ }
+}
+
+export function getNotificationIcon (item: NotificationMessageType) {
+ switch (item.type) {
+ case 'receivedFund':
+ return { ItemIcon: ArrowDown3, bgcolor: '#06D7F64D', borderColor: '#06D7F680', color: '#06D7F6' };
+
+ case 'referenda': {
+ const neutralStyle = { ItemIcon: Receipt2, bgcolor: '#303045', borderColor: '#222236', color: '#696D7E' };
+
+ const statusMap = {
+ approved: { ItemIcon: Receipt2, bgcolor: '#FF4FB91A', borderColor: '#FF4FB940', color: '#FF4FB9' },
+ cancelled: neutralStyle,
+ ongoing: { ItemIcon: Receipt2, bgcolor: '#82FFA540', borderColor: '#82FFA51A', color: '#82FFA5' },
+ rejected: neutralStyle,
+ timeout: neutralStyle
+ };
+
+ const status = item.referenda?.status;
+
+ return statusMap[status ?? 'rejected'] ?? neutralStyle;
+ }
+
+ case 'stakingReward':
+ return { ItemIcon: Award, bgcolor: '#277DFF4D', borderColor: '#2A4FA680', color: '#74A4FF' };
+ }
+}
diff --git a/packages/extension-polkagate/src/popup/settings/accountSettings/index.tsx b/packages/extension-polkagate/src/popup/settings/accountSettings/index.tsx
index b48773850..2ec706967 100644
--- a/packages/extension-polkagate/src/popup/settings/accountSettings/index.tsx
+++ b/packages/extension-polkagate/src/popup/settings/accountSettings/index.tsx
@@ -9,7 +9,6 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { windowOpen } from '@polkadot/extension-polkagate/src/messaging';
import { setStorage } from '@polkadot/extension-polkagate/src/util';
import { useExtensionPopups } from '@polkadot/extension-polkagate/src/util/handleExtensionPopup';
-import { noop } from '@polkadot/util';
import { ActionCard, BackWithLabel, Motion } from '../../../components';
import { useAccountSelectedChain, useTranslation } from '../../../hooks';
@@ -40,12 +39,11 @@ function AccountSettings (): React.ReactElement {
const isComingFromAccountsList = (location.state as State)?.pathname === '/accounts';
const onBack = useCallback(() => navigate(isComingFromAccountsList ? (location.state as State)?.pathname ?? '' : '/settings') as void, [isComingFromAccountsList, location, navigate]);
+ const onNotificationSettings = useCallback(() => navigate('/notification/settings') as void, [navigate]);
const onExport = useCallback(() => navigate('/settings-account-export') as void, [navigate]);
const onImport = useCallback(() => windowOpen('/account/have-wallet') as unknown as void, []);
const onManageProxy = useCallback(() => windowOpen(`/proxyManagement/${address}/${selectedChain}`) as unknown as void, [address, selectedChain]);
- const onCloseRemove = useCallback(() => {
- navigate('/') as void;
- }, [navigate]);
+ const onCloseRemove = useCallback(() => navigate('/') as void, [navigate]);
const CARD_STYLE = { alignItems: 'center', height: '58px', mt: '5px' };
@@ -62,7 +60,7 @@ function AccountSettings (): React.ReactElement {
iconColor='#FF4FB9'
iconSize={24}
iconWithoutTransform
- onClick={noop}
+ onClick={onNotificationSettings}
style={{ ...CARD_STYLE }}
title={t('Notifications')}
/>
diff --git a/packages/extension-polkagate/src/popup/settings/index.tsx b/packages/extension-polkagate/src/popup/settings/index.tsx
index 94b3bb014..59b484a74 100644
--- a/packages/extension-polkagate/src/popup/settings/index.tsx
+++ b/packages/extension-polkagate/src/popup/settings/index.tsx
@@ -14,7 +14,7 @@ import ActionRow from './partials/ActionRow';
import Introduction from './partials/Introduction';
import Socials from './partials/Socials';
-enum SETTING_PAGES {
+export enum SETTING_PAGES {
ABOUT = 'about',
ACCOUNT = 'account',
EXTENSION = 'extension'
diff --git a/packages/extension-polkagate/src/util/constants.ts b/packages/extension-polkagate/src/util/constants.ts
index ebed84585..294c53177 100644
--- a/packages/extension-polkagate/src/util/constants.ts
+++ b/packages/extension-polkagate/src/util/constants.ts
@@ -282,21 +282,22 @@ export const KODADOT_URL = 'https://kodadot.xyz';
export const DEMO_ACCOUNT = '1ChFWeNRLarAPRCTM3bfJmncJbSAbSS9yqjueWz7jX7iTVZ';
export enum ExtensionPopups {
- LANGUAGE,
+ DAPPS,
+ DERIVE,
+ EXPORT,
GOVERNANCE,
+ LANGUAGE,
NEW_NETWORK,
NONE,
+ NOTIFICATION,
PASSWORD,
PRIVACY,
- WARNING,
// Account Popups
- DAPPS,
- DERIVE,
- EXPORT,
IMPORT,
RECEIVE,
RENAME,
REMOVE,
+ WARNING
}
export const TRANSACTION_FLOW_STEPS = {
@@ -327,13 +328,15 @@ export const STORAGE_KEY = {
LAST_PASS_CHANGE: 'lastPasswordChange',
LOGIN_INFO: 'loginInfo',
MY_POOL: 'MyPool',
+ NOTIFICATIONS: 'notifications',
+ NOTIFICATION_SETTINGS: 'notificationSetting',
PRICE_IN_CURRENCIES: 'pricesInCurrencies',
SELECTED_ACCOUNT: 'selectedAccount',
SELECTED_CHAINS: 'selectedChains',
SELECTED_PROFILE: 'profile',
TEST_NET_ENABLED: 'testnet_enabled',
USER_ADDED_ENDPOINT: 'userAddedEndpoint',
- VALIDATORS_INFO: 'validatorsInfo',
+ VALIDATORS_INFO: 'validatorsInfo'
};
// Function names for asset fetching worker calls
diff --git a/packages/extension-ui/src/Popup/contexts/AccountAssetProvider.tsx b/packages/extension-ui/src/Popup/contexts/AccountAssetProvider.tsx
index 082862852..446d0c406 100644
--- a/packages/extension-ui/src/Popup/contexts/AccountAssetProvider.tsx
+++ b/packages/extension-ui/src/Popup/contexts/AccountAssetProvider.tsx
@@ -8,6 +8,7 @@ import React, { useContext, useEffect, useState } from 'react';
import { AccountContext, AccountsAssetsContext, GenesisHashOptionsContext, UserAddedChainContext, WorkerContext } from '@polkadot/extension-polkagate/src/components/contexts';
import { setStorage } from '@polkadot/extension-polkagate/src/components/Loading';
import { useExtensionLockContext } from '@polkadot/extension-polkagate/src/context/ExtensionLockContext';
+import { useNotifications } from '@polkadot/extension-polkagate/src/hooks';
import useAssetsBalances from '@polkadot/extension-polkagate/src/hooks/useAssetsBalances';
import useNFT from '@polkadot/extension-polkagate/src/hooks/useNFT';
import { STORAGE_KEY } from '@polkadot/extension-polkagate/src/util/constants';
@@ -18,6 +19,8 @@ export default function AccountAssetProvider ({ children }: { children: React.Re
const userAddedChainCtx = useContext(UserAddedChainContext);
const worker = useContext(WorkerContext);
+ useNotifications(false); // fetches and saves notification in the local storage
+
const [accountsAssets, setAccountsAssets] = useState();
const { isExtensionLocked } = useExtensionLockContext();
const assetsOnChains = useAssetsBalances(accounts, genesisHashOptions, userAddedChainCtx, worker, isExtensionLocked);
diff --git a/packages/extension-ui/src/Popup/routes/featuresRoutes.ts b/packages/extension-ui/src/Popup/routes/featuresRoutes.ts
index 4bd353bad..92c54a0ef 100644
--- a/packages/extension-ui/src/Popup/routes/featuresRoutes.ts
+++ b/packages/extension-ui/src/Popup/routes/featuresRoutes.ts
@@ -9,6 +9,8 @@ import NFTAlbum from '@polkadot/extension-polkagate/src/fullscreen/nft';
import Send from '@polkadot/extension-polkagate/src/fullscreen/sendFund';
import Settings from '@polkadot/extension-polkagate/src/fullscreen/settings';
import History from '@polkadot/extension-polkagate/src/popup/history/newDesign';
+import Notification from '@polkadot/extension-polkagate/src/popup/notification';
+import NotificationSettings from '@polkadot/extension-polkagate/src/popup/notification/NotificationSettings';
import MigratePasswords from '@polkadot/extension-polkagate/src/popup/passwordManagement/MigratePasswords';
// NOTE: the rule for paths is /urlName/:address/:genesisHash/blah blah
@@ -42,6 +44,16 @@ export const FEATURE_ROUTES: RouteConfig[] = [
Component: Settings,
path: '/settingsfs/*'
},
+ {
+ Component: Notification,
+ path: '/notification/',
+ trigger: 'notification'
+ },
+ {
+ Component: NotificationSettings,
+ path: '/notification/settings',
+ trigger: 'notification-settings'
+ },
{
Component: MigratePasswords,
path: '/migratePasswords'