Skip to content
110 changes: 110 additions & 0 deletions packages/apps/src/Menu/Notifications/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright 2017-2025 @polkadot/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0

import React from 'react';

import { Badge, Icon, styled } from '@polkadot/react-components';
import { useNotifications, useToggle } from '@polkadot/react-hooks';

import { useTranslation } from '../../translate.js';
import NotificationsModal from './modal.js';

interface Props {
className?: string;
isToplevel?: boolean;
classNameText?: string;
}

const Notifications = ({ className, isToplevel = true }: Props) => {
const { t } = useTranslation();
const [isModalVisible, toggleModal] = useToggle();
const { notifications } = useNotifications();

return (
<StyledLi className={`${className} ui--MenuItem ${notifications.length ? 'withCounter' : ''} isLink ${isToplevel ? 'topLevel highlight--color-contrast' : ''}`}>
<section onClick={toggleModal}>
<Icon icon={'bell'} />
<span className='media--800'>{t('Notifications')}</span>
{!!notifications.length && (
<Badge
color='white'
info={notifications.length}
/>
)}
</section>
{isModalVisible && <NotificationsModal toggleModal={toggleModal} />}
</StyledLi>
);
};

const StyledLi = styled.li`
cursor: pointer;
position: relative;
white-space: nowrap;

&.topLevel {
font-weight: var(--font-weight-normal);
line-height: 1.214rem;
border-radius: 0.15rem;

section {
padding: 0.857rem 0.857em 0.857rem 1rem;
line-height: 1.214rem;
border-radius: 0.25rem;
transition: background-color 0.2s ease;
}

&:hover section {
background-color: rgba(0, 0, 0, 0.2);
}

&.isActive.highlight--color-contrast {
font-weight: var(--font-weight-normal);
color: var(--color-text);

section {
background-color: var(--bg-tabs);
}
}

&.isActive {
border-radius: 0.15rem 0.15rem 0 0;

section {
padding: 0.857rem 1.429rem 0.857rem;
cursor: default;
}

&&.withCounter section {
padding-right: 3.2rem;
}
}

.ui--Badge {
top: 0.7rem;
}
}

&&.withCounter section {
padding-right: 3.2rem;
}

section {
color: inherit !important;
display: block;
padding: 0.5rem 1.15rem 0.57rem;
text-decoration: none;
line-height: 1.5rem;
}

.ui--Badge {
position: absolute;
right: 0.5rem;
}

.ui--Icon {
margin-right: 0.5rem;
}
`;

export default React.memo(Notifications);
186 changes: 186 additions & 0 deletions packages/apps/src/Menu/Notifications/modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright 2017-2025 @polkadot/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0

import React from 'react';
import { Link } from 'react-router-dom';

import { Button, Icon, Modal, styled } from '@polkadot/react-components';
import { useNotifications } from '@polkadot/react-hooks';
import { formatNumber } from '@polkadot/util';

import { useTranslation } from '../../translate.js';
import { reviveElement } from './utils.js';

interface Props {
className?: string;
toggleModal: () => void;
}

const NotificationsModal = ({ className, toggleModal }: Props) => {
const { t } = useTranslation();
const { notifications, removeNotification } = useNotifications();

const handleRemove = (id: string) => {
removeNotification(id);
};

return (
<Modal
className={`${className} notifications-Modal`}
header={t('Notifications')}
onClose={toggleModal}
size='large'
>
<div className='notificationsList'>
{notifications.length === 0
? (
<div className='emptyState'>
<Icon icon='bell' />
<p>{t('No notifications')}</p>
</div>
)
: (
notifications.map((notif) => (
<div
className='notificationItem'
key={notif.key}
>
<div className='notificationContent'>
<p className='notificationDescription'>
{reviveElement(notif.message)}{' '}
<span className='status'>({notif.status})</span>
</p>
<div className='meta'>
<p>
{notif.accountId && <span className='accountId'>{(notif.accountId)}</span>}
<span className='timestamp'>{new Date(notif.timestamp).toLocaleString()}</span>
</p>
{notif.blockNumber && (
<p className='--digits'>
<Link to={`/explorer/query/${notif.blockNumber}`}>{formatNumber(notif.blockNumber)}</Link>
</p>
)}
</div>
</div>
<div className='notificationActions'>
<Button
icon='trash'
isCircular
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleRemove(notif.key)}
tooltip={t('Remove')}
/>
</div>
</div>
))
)}
</div>
</Modal>
);
};

export default React.memo(styled(NotificationsModal)`
.ui--Modal-Header {
align-items: center;

h1 {
text-transform: capitalize;
}
}

.notificationsList {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.75rem 0.5rem 0.5rem;
}

.notificationItem {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-table);
border: 1px solid var(--border-table);
border-radius: 0.5rem;
padding: 0.5rem 1rem;
transition: background 0.15s ease-in-out;

&.unread {
border-left: 3px solid var(--accent-color);
}

&:hover {
background: rgba(255, 255, 255, 0.6);

.notificationActions {
opacity: 1;
pointer-events: all;
}
}

.notificationContent {
flex: 1;
margin-right: 0.5rem;

display: flex;
align-items: center;
justify-content: space-between;

.blockNumber {
font-size: 0.85rem;
color: var(--text-secondary);
}

p {
margin: 0;
font-size: 1rem;
color: var(--text-secondary);
}

.status {
text-transform: uppercase;
font-size: 1rem;
text-decoration: italic;
}

.meta {
display: flex;
flex-direction: column;
align-items: end;
font-size: 0.8rem;
color: var(--text-secondary);

.accountId {
font-family: monospace;
opacity: 0.8;
}

.timestamp {
font-size: 0.75rem;
opacity: 0.7;
}
}
}

.notificationActions {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
}
}

.emptyState {
text-align: center;
color: var(--text-secondary);
padding: 2rem 0;

> .ui--Icon {
margin-bottom: 0.5rem;
scale: 1.5;
}
}
`);
61 changes: 61 additions & 0 deletions packages/apps/src/Menu/Notifications/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2017-2025 @polkadot/apps authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { ReactNode } from 'react';

import React from 'react';

interface JsonElement {
type: string;
props?: Record<string, any>;
}

/**
* Revives a JSON React object (from localStorage) into a real ReactNode.
*/
export function reviveElement (node: React.ReactNode): React.ReactNode {
// Already valid React primitives
if (
node === null ||
node === undefined ||
typeof node === 'string' ||
typeof node === 'number' ||
typeof node === 'boolean'
) {
return node;
}

// Arrays -> revive each child
if (Array.isArray(node)) {
return node.map((child: ReactNode) => reviveElement(child));
}

// Objects (JSON React description)
if (typeof node === 'object' && !React.isValidElement(node)) {
const json = node as JsonElement;

// If no `type`, it's not a JSON element
if (typeof json.type !== 'string') {
return node;
}

const { props = {}, type } = json;

// Recursively revive all props (including children)
const revivedProps: Record<string, any> = {};

for (const [key, value] of Object.entries(props)) {
if (key === 'children') {
revivedProps.children = Array.isArray(value)
? value.map((child: ReactNode) => reviveElement(child))
: reviveElement(value as ReactNode);
} else {
revivedProps[key] = value as ReactNode;
}
}

return React.createElement(type, revivedProps);
}

return null;
}
2 changes: 2 additions & 0 deletions packages/apps/src/Menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useAccounts, useApi, useCall, useTeleport } from '@polkadot/react-hooks

import { findMissingApis } from '../endpoint.js';
import { useTranslation } from '../translate.js';
import Notifications from './Notifications/index.js';
import ChainInfo from './ChainInfo.js';
import Grouping from './Grouping.js';
import Item from './Item.js';
Expand Down Expand Up @@ -137,6 +138,7 @@ function Menu ({ className = '' }: Props): React.ReactElement<Props> {
routes={routes}
/>
))}
{!!apiProps.isApiReady && <Notifications />}
</ul>
</div>
<div className='menuSection media--1200'>
Expand Down
Loading