init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
import React, { ReactElement, useEffect, useState } from 'react';
export const NotificationsTransitionContext = React.createContext({
unmounting: new Map<string, TimeoutType | null>()
});
type TimeoutType = ReturnType<typeof setTimeout>;
const NotificationsTransition = ({ children }: { children: ReactElement[]; }) => {
const [ childrenToRender, setChildrenToRender ] = useState(children);
const [ timeoutIds, setTimeoutIds ] = useState(new Map<string, TimeoutType | null>());
useEffect(() => {
const toUnmount = childrenToRender.filter(child =>
children.findIndex(c => c.props.uid === child.props.uid) === -1) ?? [];
const toMount = children?.filter(child =>
childrenToRender.findIndex(c => c.props.uid === child.props.uid) === -1) ?? [];
/**
* Update current notifications.
* In some cases the UID is the same but the other props change.
* This way we make sure the notification displays the latest info.
*/
children.forEach(child => {
const index = childrenToRender.findIndex(c => c.props.uid === child.props.uid);
if (index !== -1) {
childrenToRender[index] = child;
}
});
if (toUnmount.length > 0) {
const ids = new Map(timeoutIds);
toUnmount.forEach(child => {
const timeoutId = setTimeout(() => {
timeoutIds.set(child.props.uid, null);
setTimeoutIds(timeoutIds);
}, 250);
ids.set(child.props.uid, timeoutId);
});
setTimeoutIds(ids);
}
setChildrenToRender(toMount.concat(childrenToRender));
}, [ children ]);
useEffect(() => {
const toRemove: string[] = [];
timeoutIds.forEach((value, key) => {
if (value === null) {
toRemove.push(key);
timeoutIds.delete(key);
}
});
toRemove.length > 0 && setChildrenToRender(childrenToRender.filter(child =>
toRemove.findIndex(id => child.props.uid === id) === -1));
}, [ timeoutIds ]);
return (
<NotificationsTransitionContext.Provider value = {{ unmounting: timeoutIds }}>
{childrenToRender}
</NotificationsTransitionContext.Provider>
);
};
export default NotificationsTransition;

View File

@@ -0,0 +1 @@
export { default as NotificationsContainer } from './native/NotificationsContainer';

View File

@@ -0,0 +1 @@
export { default as NotificationsContainer } from './web/NotificationsContainer';

View File

@@ -0,0 +1,222 @@
import React, { useCallback, useContext, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Animated, Text, TextStyle, View, ViewStyle } from 'react-native';
import Icon from '../../../base/icons/components/Icon';
import {
IconCloseLarge,
IconInfoCircle,
IconUsers,
IconWarning
} from '../../../base/icons/svg';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import Button from '../../../base/ui/components/native/Button';
import IconButton from '../../../base/ui/components/native/IconButton';
import { BUTTON_MODES, BUTTON_TYPES } from '../../../base/ui/constants.native';
import { CHAR_LIMIT } from '../../../chat/constants';
import { replaceNonUnicodeEmojis } from '../../../chat/functions';
import { NOTIFICATION_ICON, NOTIFICATION_TYPE } from '../../constants';
import { INotificationProps } from '../../types';
import { NotificationsTransitionContext } from '../NotificationsTransition';
import styles from './styles';
/**
* Secondary colors for notification icons.
*
* @type {{error, info, normal, success, warning}}
*/
const ICON_COLOR = {
error: BaseTheme.palette.iconError,
normal: BaseTheme.palette.iconNormal,
success: BaseTheme.palette.iconSuccess,
warning: BaseTheme.palette.iconWarning
};
export interface IProps extends INotificationProps {
_participants: ArrayLike<any>;
onDismissed: Function;
}
const Notification = ({
appearance = NOTIFICATION_TYPE.NORMAL,
customActionHandler,
customActionNameKey,
customActionType,
description,
descriptionArguments,
descriptionKey,
icon,
onDismissed,
title,
titleArguments,
titleKey,
uid
}: IProps) => {
const { t } = useTranslation();
const notificationOpacityAnimation = useRef(new Animated.Value(0)).current;
const { unmounting } = useContext(NotificationsTransitionContext);
useEffect(() => {
Animated.timing(
notificationOpacityAnimation,
{
toValue: 1,
duration: 200,
useNativeDriver: true
})
.start();
}, []);
useEffect(() => {
if (unmounting.get(uid ?? '')) {
Animated.timing(
notificationOpacityAnimation,
{
toValue: 0,
duration: 200,
useNativeDriver: true
})
.start();
}
}, [ unmounting ]);
const onDismiss = useCallback(() => {
onDismissed(uid);
}, [ onDismissed, uid ]);
const mapAppearanceToButtons = () => {
if (customActionNameKey?.length && customActionHandler?.length && customActionType?.length) {
return customActionNameKey?.map((customAction: string, index: number) => (
<Button
accessibilityLabel = { customAction }
key = { index }
labelKey = { customAction }
mode = { BUTTON_MODES.TEXT }
// eslint-disable-next-line react/jsx-no-bind
onClick = { () => {
if (customActionHandler[index]()) {
onDismiss();
}
} }
style = { styles.btn }
// @ts-ignore
type = { customActionType[index] } />
));
}
return [];
};
const getIcon = () => {
let src;
switch (icon || appearance) {
case NOTIFICATION_ICON.PARTICIPANT:
src = IconInfoCircle;
break;
case NOTIFICATION_ICON.PARTICIPANTS:
src = IconUsers;
break;
case NOTIFICATION_ICON.WARNING:
src = IconWarning;
break;
default:
src = IconInfoCircle;
break;
}
return src;
};
const _getDescription = () => {
const descriptionArray = [];
descriptionKey
&& descriptionArray.push(t(descriptionKey, descriptionArguments));
description && descriptionArray.push(description);
return descriptionArray;
};
// eslint-disable-next-line react/no-multi-comp
const _renderContent = () => {
const titleText = title || (titleKey && t(titleKey, titleArguments));
const descriptionArray = _getDescription();
if (descriptionArray?.length) {
return (
<>
<Text
numberOfLines = { 1 }
style = { styles.contentTextTitleDescription as TextStyle }>
{ titleText }
</Text>
{
descriptionArray.map((line, index) => (
<Text
key = { index }
style = { styles.contentText }>
{ line.length >= CHAR_LIMIT ? line : replaceNonUnicodeEmojis(line) }
</Text>
))
}
</>
);
}
return (
<Text
numberOfLines = { 1 }
style = { styles.contentTextTitle as TextStyle }>
{ titleText }
</Text>
);
};
return (
<Animated.View
pointerEvents = 'box-none'
style = { [
_getDescription()?.length
? styles.notificationWithDescription
: styles.notification,
{
opacity: notificationOpacityAnimation
}
] as ViewStyle[] }>
<View
style = { (icon === NOTIFICATION_ICON.PARTICIPANTS
? styles.contentColumn
: styles.interactiveContentColumn) as ViewStyle }>
<View style = { styles.iconContainer as ViewStyle }>
<Icon
color = { ICON_COLOR[appearance as keyof typeof ICON_COLOR] }
size = { 24 }
src = { getIcon() } />
</View>
<View
pointerEvents = 'box-none'
style = { styles.contentContainer }>
{ _renderContent() }
</View>
<View style = { styles.btnContainer as ViewStyle }>
{ mapAppearanceToButtons() }
</View>
</View>
<IconButton
color = { BaseTheme.palette.icon04 }
onPress = { onDismiss }
src = { IconCloseLarge }
type = { BUTTON_TYPES.TERTIARY } />
</Animated.View>
);
};
export default Notification;

View File

@@ -0,0 +1,239 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { Platform } from 'react-native';
import { Edge, SafeAreaView } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { hideNotification } from '../../actions';
import { areThereNotifications } from '../../functions';
import NotificationsTransition from '../NotificationsTransition';
import Notification from './Notification';
import styles from './styles';
interface IProps extends WithTranslation {
/**
* The notifications to be displayed, with the first index being the
* notification at the top and the rest shown below it in order.
*/
_notifications: Array<Object>;
/**
* Invoked to update the redux store in order to remove notifications.
*/
dispatch: IStore['dispatch'];
/**
* Whether or not the layout should change to support tile view mode.
*/
shouldDisplayTileView: boolean;
/**
* Checks toolbox visibility.
*/
toolboxVisible: boolean;
}
/**
* Implements a React {@link Component} which displays notifications and handles
* automatic dismissal after a notification is shown for a defined timeout
* period.
*
* @augments {Component}
*/
class NotificationsContainer extends Component<IProps> {
/**
* A timeout id returned by setTimeout.
*/
_notificationDismissTimeout: any;
/**
* Initializes a new {@code NotificationsContainer} instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
/**
* The timeout set for automatically dismissing a displayed
* notification. This value is set on the instance and not state to
* avoid additional re-renders.
*
* @type {number|null}
*/
this._notificationDismissTimeout = null;
// Bind event handlers so they are only bound once for every instance.
this._onDismissed = this._onDismissed.bind(this);
}
/**
* Sets a timeout (if applicable).
*
* @inheritdoc
*/
override componentDidMount() {
// Set the initial dismiss timeout (if any)
// @ts-ignore
this._manageDismissTimeout();
}
/**
* Sets a timeout if the currently displayed notification has changed.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: IProps) {
this._manageDismissTimeout(prevProps);
}
/**
* Sets/clears the dismiss timeout for the top notification.
*
* @param {IProps} [prevProps] - The previous properties (if called from
* {@code componentDidUpdate}).
* @returns {void}
* @private
*/
_manageDismissTimeout(prevProps: IProps) {
const { _notifications } = this.props;
if (_notifications.length) {
const notification = _notifications[0];
const previousNotification = prevProps?._notifications.length
? prevProps._notifications[0] : undefined;
if (notification !== previousNotification) {
this._clearNotificationDismissTimeout();
// @ts-ignore
if (notification?.timeout) {
// @ts-ignore
const { timeout, uid } = notification;
// @ts-ignore
this._notificationDismissTimeout = setTimeout(() => {
// Perform a no-op if a timeout is not specified.
this._onDismissed(uid);
}, timeout);
}
}
} else if (this._notificationDismissTimeout) {
// Clear timeout when all notifications are cleared (e.g external
// call to clear them)
this._clearNotificationDismissTimeout();
}
}
/**
* Clear any dismissal timeout that is still active.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._clearNotificationDismissTimeout();
}
/**
* Clears the running notification dismiss timeout, if any.
*
* @returns {void}
*/
_clearNotificationDismissTimeout() {
this._notificationDismissTimeout && clearTimeout(this._notificationDismissTimeout);
this._notificationDismissTimeout = null;
}
/**
* Emits an action to remove the notification from the redux store so it
* stops displaying.
*
* @param {Object} uid - The id of the notification to be removed.
* @private
* @returns {void}
*/
_onDismissed(uid: any) {
const { _notifications } = this.props;
// Clear the timeout only if it's the top notification that's being
// dismissed (the timeout is set only for the top one).
// @ts-ignore
if (!_notifications.length || _notifications[0].uid === uid) {
this._clearNotificationDismissTimeout();
}
this.props.dispatch(hideNotification(uid));
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const { _notifications, shouldDisplayTileView, toolboxVisible } = this.props;
let notificationsContainerStyle;
if (shouldDisplayTileView) {
if (toolboxVisible) {
notificationsContainerStyle = styles.withToolboxTileView;
} else {
notificationsContainerStyle = styles.withoutToolboxTileView;
}
} else {
notificationsContainerStyle
= toolboxVisible ? styles.withToolbox : styles.withoutToolbox;
}
return (
<SafeAreaView
edges = { [ Platform.OS === 'ios' && 'bottom', 'left', 'right' ].filter(Boolean) as Edge[] }
style = { notificationsContainerStyle as any }>
<NotificationsTransition>
{
_notifications.map(notification => {
// @ts-ignore
const { props, uid } = notification;
return (
<Notification
{ ...props }
key = { uid }
onDismissed = { this._onDismissed }
uid = { uid } />
);
})
}
</NotificationsTransition>
</SafeAreaView>
);
}
}
/**
* Maps (parts of) the Redux state to the associated NotificationsContainer's
* props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {Props}
*/
export function mapStateToProps(state: IReduxState) {
const { notifications } = state['features/notifications'];
const _visible = areThereNotifications(state);
return {
_notifications: _visible ? notifications : []
};
}
export default connect(mapStateToProps)(NotificationsContainer);

View File

@@ -0,0 +1,135 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
const contentColumn = {
flex: 1,
flexDirection: 'column',
paddingLeft: BaseTheme.spacing[2]
};
const notification = {
backgroundColor: BaseTheme.palette.ui10,
borderRadius: BaseTheme.shape.borderRadius,
borderLeftColor: BaseTheme.palette.link01Active,
borderLeftWidth: BaseTheme.spacing[1],
display: 'flex',
flexDirection: 'row',
marginVertical: BaseTheme.spacing[1],
maxWidth: 416,
width: '100%'
};
/**
* The styles of the React {@code Components} of the feature notifications.
*/
export default {
/**
* The content (left) column of the notification.
*/
interactiveContentColumn: {
...contentColumn
},
contentColumn: {
...contentColumn,
justifyContent: 'center'
},
/**
* Test style of the notification.
*/
contentContainer: {
paddingHorizontal: BaseTheme.spacing[2]
},
contentText: {
color: BaseTheme.palette.text04,
paddingLeft: BaseTheme.spacing[4],
paddingTop: BaseTheme.spacing[1]
},
contentTextTitleDescription: {
color: BaseTheme.palette.text04,
fontWeight: 'bold',
paddingLeft: BaseTheme.spacing[4],
paddingTop: BaseTheme.spacing[2]
},
contentTextTitle: {
color: BaseTheme.palette.text04,
fontWeight: 'bold',
paddingLeft: BaseTheme.spacing[4],
paddingTop: BaseTheme.spacing[3]
},
/**
* Dismiss icon style.
*/
dismissIcon: {
color: BaseTheme.palette.icon04,
fontSize: 20
},
notification: {
...notification
},
notificationWithDescription: {
...notification,
paddingBottom: BaseTheme.spacing[2]
},
/**
* Wrapper for the message.
*/
notificationContent: {
alignItems: 'center',
flexDirection: 'row'
},
participantName: {
color: BaseTheme.palette.text04,
overflow: 'hidden'
},
iconContainer: {
position: 'absolute',
left: BaseTheme.spacing[2],
top: 12
},
btn: {
paddingLeft: BaseTheme.spacing[3]
},
btnContainer: {
display: 'flex',
flexDirection: 'row',
paddingLeft: BaseTheme.spacing[3],
paddingTop: BaseTheme.spacing[1]
},
withToolbox: {
bottom: 56,
position: 'absolute',
width: '100%'
},
withToolboxTileView: {
bottom: 56,
position: 'absolute',
width: '100%'
},
withoutToolbox: {
position: 'absolute',
width: '100%'
},
withoutToolboxTileView: {
bottom: 0,
position: 'absolute',
width: '100%'
}
};

View File

@@ -0,0 +1,372 @@
import { Theme } from '@mui/material';
import React, { isValidElement, useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { keyframes } from 'tss-react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import {
IconCheck,
IconCloseLarge,
IconInfo,
IconMessage,
IconUser,
IconUsers,
IconWarningCircle
} from '../../../base/icons/svg';
import Message from '../../../base/react/components/web/Message';
import { getSupportUrl } from '../../../base/react/functions';
import { NOTIFICATION_ICON, NOTIFICATION_TYPE } from '../../constants';
import { INotificationProps } from '../../types';
import { NotificationsTransitionContext } from '../NotificationsTransition';
interface IProps extends INotificationProps {
/**
* Callback invoked when the user clicks to dismiss the notification.
*/
onDismissed: Function;
}
/**
* Secondary colors for notification icons.
*
* @type {{error, info, normal, success, warning}}
*/
const useStyles = makeStyles()((theme: Theme) => {
return {
container: {
backgroundColor: theme.palette.ui10,
padding: '8px 16px 8px 20px',
display: 'flex',
position: 'relative' as const,
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: '0px 6px 20px rgba(0, 0, 0, 0.25)',
marginBottom: theme.spacing(2),
'&:last-of-type': {
marginBottom: 0
},
animation: `${keyframes`
0% {
opacity: 0;
transform: translateX(-80%);
}
100% {
opacity: 1;
transform: translateX(0);
}
`} 0.2s forwards ease`,
'&.unmount': {
animation: `${keyframes`
0% {
opacity: 1;
transform: translateX(0);
}
100% {
opacity: 0;
transform: translateX(-80%);
}
`} 0.2s forwards ease`
}
},
ribbon: {
width: '4px',
height: 'calc(100% - 16px)',
position: 'absolute' as const,
left: 0,
top: '8px',
borderRadius: '4px',
'&.normal': {
backgroundColor: theme.palette.action01
},
'&.error': {
backgroundColor: theme.palette.iconError
},
'&.success': {
backgroundColor: theme.palette.success01
},
'&.warning': {
backgroundColor: theme.palette.warning01
}
},
content: {
display: 'flex',
alignItems: 'flex-start',
padding: '8px 0',
flex: 1,
maxWidth: '100%'
},
textContainer: {
display: 'flex',
flexDirection: 'column' as const,
justifyContent: 'space-between',
color: theme.palette.text04,
flex: 1,
margin: '0 8px',
// maxWidth: 100% minus the icon on left (20px) minus the close icon on the right (20px) minus the margins
maxWidth: 'calc(100% - 40px - 16px)',
maxHeight: '150px'
},
title: {
...theme.typography.bodyShortBold
},
description: {
...theme.typography.bodyShortRegular,
overflow: 'auto',
overflowWrap: 'break-word',
userSelect: 'all',
'&:not(:empty)': {
marginTop: theme.spacing(1)
}
},
actionsContainer: {
display: 'flex',
width: '100%',
'&:not(:empty)': {
marginTop: theme.spacing(2)
}
},
action: {
border: 0,
outline: 0,
backgroundColor: 'transparent',
color: theme.palette.action01,
...theme.typography.bodyShortBold,
marginRight: theme.spacing(3),
padding: 0,
cursor: 'pointer',
'&:last-of-type': {
marginRight: 0
},
'&.destructive': {
color: theme.palette.textError
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.action01}`,
outlineOffset: 2
}
},
closeIcon: {
cursor: 'pointer'
}
};
});
const Notification = ({
appearance = NOTIFICATION_TYPE.NORMAL,
customActionHandler,
customActionNameKey,
customActionType,
description,
descriptionArguments,
descriptionKey,
disableClosing,
hideErrorSupportLink,
icon,
onDismissed,
title,
titleArguments,
titleKey,
uid
}: IProps) => {
const { classes, cx, theme } = useStyles();
const { t } = useTranslation();
const { unmounting } = useContext(NotificationsTransitionContext);
const supportUrl = useSelector(getSupportUrl);
const isErrorOrWarning = useMemo(
() => appearance === NOTIFICATION_TYPE.ERROR || appearance === NOTIFICATION_TYPE.WARNING,
[ appearance ]
);
const ICON_COLOR = {
error: theme.palette.iconError,
normal: theme.palette.action01,
success: theme.palette.success01,
warning: theme.palette.warning01
};
const onDismiss = useCallback(() => {
onDismissed(uid);
}, [ uid ]);
// eslint-disable-next-line react/no-multi-comp
const renderDescription = useCallback(() => {
const descriptionArray = [];
descriptionKey
&& descriptionArray.push(t(descriptionKey, descriptionArguments));
description && typeof description === 'string' && descriptionArray.push(description);
// Keeping in mind that:
// - Notifications that use the `translateToHtml` function get an element-based description array with one entry
// - Message notifications receive string-based description arrays that might need additional parsing
// We look for ready-to-render elements, and if present, we roll with them
// Otherwise, we use the Message component that accepts a string `text` prop
const shouldRenderHtml = descriptionArray.length === 1 && isValidElement(descriptionArray[0]);
// the id is used for testing the UI
return (
<div
className = { classes.description }
data-testid = { descriptionKey } >
{shouldRenderHtml ? descriptionArray : <Message text = { descriptionArray.join(' ') } />}
{typeof description === 'object' && description}
</div>
);
}, [ description, descriptionArguments, descriptionKey, classes ]);
const _onOpenSupportLink = useCallback(() => {
window.open(supportUrl, '_blank', 'noopener');
}, [ supportUrl ]);
const mapAppearanceToButtons = useCallback((): {
content: string; onClick: () => void; testId?: string; type?: string; }[] => {
switch (appearance) {
case NOTIFICATION_TYPE.ERROR: {
const buttons = [
{
content: t('dialog.dismiss'),
onClick: onDismiss
}
];
if (!hideErrorSupportLink && supportUrl) {
buttons.push({
content: t('dialog.contactSupport'),
onClick: _onOpenSupportLink
});
}
return buttons;
}
case NOTIFICATION_TYPE.WARNING:
return [
{
content: t('dialog.Ok'),
onClick: onDismiss
}
];
default:
if (customActionNameKey?.length && customActionHandler?.length) {
return customActionNameKey.map((customAction: string, customActionIndex: number) => {
return {
content: t(customAction),
onClick: () => {
if (customActionHandler?.[customActionIndex]()) {
onDismiss();
}
},
type: customActionType?.[customActionIndex],
testId: customAction
};
});
}
return [];
}
}, [ appearance, onDismiss, customActionHandler, customActionNameKey, hideErrorSupportLink, supportUrl ]);
const getIcon = useCallback(() => {
let iconToDisplay;
switch (icon || appearance) {
case NOTIFICATION_ICON.ERROR:
case NOTIFICATION_ICON.WARNING:
iconToDisplay = IconWarningCircle;
break;
case NOTIFICATION_ICON.SUCCESS:
iconToDisplay = IconCheck;
break;
case NOTIFICATION_ICON.MESSAGE:
iconToDisplay = IconMessage;
break;
case NOTIFICATION_ICON.PARTICIPANT:
iconToDisplay = IconUser;
break;
case NOTIFICATION_ICON.PARTICIPANTS:
iconToDisplay = IconUsers;
break;
default:
iconToDisplay = IconInfo;
break;
}
return iconToDisplay;
}, [ icon, appearance ]);
return (
<div
aria-atomic = { true }
aria-live = { isErrorOrWarning ? 'assertive' : 'polite' }
className = { cx(classes.container, (unmounting.get(uid ?? '') && 'unmount') as string | undefined) }
data-testid = { titleKey || descriptionKey }
id = { uid }
role = { isErrorOrWarning ? 'alert' : 'status' }>
<div className = { cx(classes.ribbon, appearance) } />
<div className = { classes.content }>
<div className = { icon }>
<Icon
color = { ICON_COLOR[appearance as keyof typeof ICON_COLOR] }
size = { 20 }
src = { getIcon() } />
</div>
<div className = { classes.textContainer }>
<span className = { classes.title }>{title || t(titleKey ?? '', titleArguments)}</span>
{renderDescription()}
<div className = { classes.actionsContainer }>
{mapAppearanceToButtons().map(({ content, onClick, type, testId }) => (
<button
aria-label = { content }
className = { cx(classes.action, type) }
data-testid = { testId }
key = { content }
onClick = { onClick }
type = 'button'>
{content}
</button>
))}
</div>
</div>
{ !disableClosing && (
<Icon
className = { classes.closeIcon }
color = { theme.palette.icon04 }
id = 'close-notification'
onClick = { onDismiss }
size = { 20 }
src = { IconCloseLarge }
tabIndex = { 0 }
testId = { `${titleKey || descriptionKey}-dismiss` } />
)}
</div>
</div>
);
};
export default Notification;

View File

@@ -0,0 +1,117 @@
import React, { useCallback } from 'react';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { hideNotification } from '../../actions';
import { areThereNotifications } from '../../functions';
import { INotificationProps } from '../../types';
import NotificationsTransition from '../NotificationsTransition';
import Notification from './Notification';
interface IProps {
/**
* Whether we are a SIP gateway or not.
*/
_iAmSipGateway: boolean;
/**
* Whether or not the chat is open.
*/
_isChatOpen: boolean;
/**
* The notifications to be displayed, with the first index being the
* notification at the top and the rest shown below it in order.
*/
_notifications: Array<{
props: INotificationProps;
uid: string;
}>;
/**
* Invoked to update the redux store in order to remove notifications.
*/
dispatch: IStore['dispatch'];
/**
* Whether or not the notifications are displayed in a portal.
*/
portal?: boolean;
}
const useStyles = makeStyles()(() => {
return {
container: {
position: 'absolute',
left: '16px',
bottom: '84px',
width: '320px',
maxWidth: '100%',
zIndex: 600
},
containerPortal: {
width: '100%',
maxWidth: 'calc(100% - 32px)'
}
};
});
const NotificationsContainer = ({
_iAmSipGateway,
_notifications,
dispatch,
portal
}: IProps) => {
const { classes, cx } = useStyles();
const _onDismissed = useCallback((uid: string) => {
dispatch(hideNotification(uid));
}, []);
if (_iAmSipGateway) {
return null;
}
return (
<div
className = { cx(classes.container, {
[classes.containerPortal]: portal
}) }
id = 'notifications-container'>
<NotificationsTransition>
{_notifications.map(({ props, uid }) => (
<Notification
{ ...props }
key = { uid }
onDismissed = { _onDismissed }
uid = { uid } />
)) || null}
</NotificationsTransition>
</div>
);
};
/**
* Maps (parts of) the Redux state to the associated props for this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { notifications } = state['features/notifications'];
const { iAmSipGateway } = state['features/base/config'];
const { isOpen: isChatOpen } = state['features/chat'];
const _visible = areThereNotifications(state);
return {
_iAmSipGateway: Boolean(iAmSipGateway),
_isChatOpen: isChatOpen,
_notifications: _visible ? notifications : []
};
}
export default connect(_mapStateToProps)(NotificationsContainer);