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,45 @@
/**
* The type of (redux) action which signals that all the stored notifications
* need to be cleared.
*
* {
* type: CLEAR_NOTIFICATIONS
* }
*/
export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS';
/**
* The type of (redux) action which signals that a specific notification should
* not be displayed anymore.
*
* {
* type: HIDE_NOTIFICATION,
* uid: string
* }
*/
export const HIDE_NOTIFICATION = 'HIDE_NOTIFICATION';
/**
* The type of (redux) action which signals that a notification component should
* be displayed.
*
* {
* type: SHOW_NOTIFICATION,
* component: ReactComponent,
* props: Object,
* timeout: number,
* uid: string
* }
*/
export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION';
/**
* The type of (redux) action which signals that notifications should not
* display.
*
* {
* type: SET_NOTIFICATIONS_ENABLED,
* enabled: Boolean
* }
*/
export const SET_NOTIFICATIONS_ENABLED = 'SET_NOTIFICATIONS_ENABLED';

View File

@@ -0,0 +1,346 @@
import { throttle } from 'lodash-es';
import { IStore } from '../app/types';
import { IConfig } from '../base/config/configType';
import { NOTIFICATIONS_ENABLED } from '../base/flags/constants';
import { getFeatureFlag } from '../base/flags/functions';
import { getParticipantCount } from '../base/participants/functions';
import {
CLEAR_NOTIFICATIONS,
HIDE_NOTIFICATION,
SET_NOTIFICATIONS_ENABLED,
SHOW_NOTIFICATION
} from './actionTypes';
import {
NOTIFICATION_ICON,
NOTIFICATION_TIMEOUT,
NOTIFICATION_TIMEOUT_TYPE,
NOTIFICATION_TYPE,
SILENT_JOIN_THRESHOLD,
SILENT_LEFT_THRESHOLD
} from './constants';
import { INotificationProps } from './types';
/**
* Function that returns notification timeout value based on notification timeout type.
*
* @param {string} type - Notification type.
* @param {Object} notificationTimeouts - Config notification timeouts.
* @returns {number}
*/
function getNotificationTimeout(type?: string, notificationTimeouts?: IConfig['notificationTimeouts']) {
if (type === NOTIFICATION_TIMEOUT_TYPE.SHORT) {
return notificationTimeouts?.short ?? NOTIFICATION_TIMEOUT.SHORT;
} else if (type === NOTIFICATION_TIMEOUT_TYPE.MEDIUM) {
return notificationTimeouts?.medium ?? NOTIFICATION_TIMEOUT.MEDIUM;
} else if (type === NOTIFICATION_TIMEOUT_TYPE.LONG) {
return notificationTimeouts?.long ?? NOTIFICATION_TIMEOUT.LONG;
} else if (type === NOTIFICATION_TIMEOUT_TYPE.EXTRA_LONG) {
return notificationTimeouts?.extraLong ?? NOTIFICATION_TIMEOUT.EXTRA_LONG;
} else if (type === NOTIFICATION_TIMEOUT_TYPE.STICKY) {
return notificationTimeouts?.sticky ?? NOTIFICATION_TIMEOUT.STICKY;
}
return 0;
}
/**
* Clears (removes) all the notifications.
*
* @returns {{
* type: CLEAR_NOTIFICATIONS
* }}
*/
export function clearNotifications() {
return {
type: CLEAR_NOTIFICATIONS
};
}
/**
* Removes the notification with the passed in id.
*
* @param {string} uid - The unique identifier for the notification to be
* removed.
* @returns {{
* type: HIDE_NOTIFICATION,
* uid: string
* }}
*/
export function hideNotification(uid: string) {
return {
type: HIDE_NOTIFICATION,
uid
};
}
/**
* Stops notifications from being displayed.
*
* @param {boolean} enabled - Whether or not notifications should display.
* @returns {{
* type: SET_NOTIFICATIONS_ENABLED,
* enabled: boolean
* }}
*/
export function setNotificationsEnabled(enabled: boolean) {
return {
type: SET_NOTIFICATIONS_ENABLED,
enabled
};
}
/**
* Queues an error notification for display.
*
* @param {Object} props - The props needed to show the notification component.
* @param {string} type - Notification type.
* @returns {Object}
*/
export function showErrorNotification(props: INotificationProps, type = NOTIFICATION_TIMEOUT_TYPE.STICKY) {
return showNotification({
...props,
appearance: NOTIFICATION_TYPE.ERROR
}, type);
}
/**
* Queues a success notification for display.
*
* @param {Object} props - The props needed to show the notification component.
* @param {string} type - Notification type.
* @returns {Object}
*/
export function showSuccessNotification(props: INotificationProps, type?: string) {
return showNotification({
...props,
appearance: NOTIFICATION_TYPE.SUCCESS
}, type);
}
/**
* Queues a notification for display.
*
* @param {Object} props - The props needed to show the notification component.
* @param {string} type - Timeout type.
* @returns {Function}
*/
export function showNotification(props: INotificationProps = {}, type?: string) {
return function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
const { disabledNotifications = [], notifications, notificationTimeouts } = getState()['features/base/config'];
const enabledFlag = getFeatureFlag(getState(), NOTIFICATIONS_ENABLED, true);
const { descriptionKey, titleKey } = props;
const shouldDisplay = enabledFlag
&& !(disabledNotifications.includes(descriptionKey ?? '')
|| disabledNotifications.includes(titleKey ?? ''))
&& (!notifications
|| notifications.includes(descriptionKey ?? '')
|| notifications.includes(titleKey ?? ''));
if (typeof APP !== 'undefined') {
APP.API.notifyNotificationTriggered(titleKey, descriptionKey);
}
if (shouldDisplay) {
return dispatch({
type: SHOW_NOTIFICATION,
props,
timeout: getNotificationTimeout(type, notificationTimeouts),
uid: props.uid || Date.now().toString()
});
}
};
}
/**
* Queues a warning notification for display.
*
* @param {Object} props - The props needed to show the notification component.
* @param {string} type - Notification type.
* @returns {Object}
*/
export function showWarningNotification(props: INotificationProps, type?: string) {
return showNotification({
...props,
appearance: NOTIFICATION_TYPE.WARNING
}, type);
}
/**
* Queues a message notification for display.
*
* @param {Object} props - The props needed to show the notification component.
* @param {string} type - Notification type.
* @returns {Object}
*/
export function showMessageNotification(props: INotificationProps, type?: string) {
return showNotification({
...props,
concatText: true,
titleKey: 'notify.chatMessages',
appearance: NOTIFICATION_TYPE.NORMAL,
icon: NOTIFICATION_ICON.MESSAGE
}, type);
}
/**
* An array of names of participants that have joined the conference. The array
* is replaced with an empty array as notifications are displayed.
*
* @private
* @type {string[]}
*/
let joinedParticipantsNames: string[] = [];
/**
* A throttled internal function that takes the internal list of participant
* names, {@code joinedParticipantsNames}, and triggers the display of a
* notification informing of their joining.
*
* @private
* @type {Function}
*/
const _throttledNotifyParticipantConnected = throttle((dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const participantCount = getParticipantCount(getState());
// Skip join notifications altogether for large meetings.
if (participantCount > SILENT_JOIN_THRESHOLD) {
joinedParticipantsNames = [];
return;
}
const joinedParticipantsCount = joinedParticipantsNames.length;
let notificationProps;
if (joinedParticipantsCount >= 3) {
notificationProps = {
titleArguments: {
name: joinedParticipantsNames[0]
},
titleKey: 'notify.connectedThreePlusMembers'
};
} else if (joinedParticipantsCount === 2) {
notificationProps = {
titleArguments: {
first: joinedParticipantsNames[0],
second: joinedParticipantsNames[1]
},
titleKey: 'notify.connectedTwoMembers'
};
} else if (joinedParticipantsCount) {
notificationProps = {
titleArguments: {
name: joinedParticipantsNames[0]
},
titleKey: 'notify.connectedOneMember'
};
}
if (notificationProps) {
dispatch(
showNotification(notificationProps, NOTIFICATION_TIMEOUT_TYPE.SHORT));
}
joinedParticipantsNames = [];
}, 2000, { leading: false });
/**
* An array of names of participants that have left the conference. The array
* is replaced with an empty array as notifications are displayed.
*
* @private
* @type {string[]}
*/
let leftParticipantsNames: string[] = [];
/**
* A throttled internal function that takes the internal list of participant
* names, {@code leftParticipantsNames}, and triggers the display of a
* notification informing of their leaving.
*
* @private
* @type {Function}
*/
const _throttledNotifyParticipantLeft = throttle((dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const participantCount = getParticipantCount(getState());
// Skip left notifications altogether for large meetings.
if (participantCount > SILENT_LEFT_THRESHOLD) {
leftParticipantsNames = [];
return;
}
const leftParticipantsCount = leftParticipantsNames.length;
let notificationProps;
if (leftParticipantsCount >= 3) {
notificationProps = {
titleArguments: {
name: leftParticipantsNames[0]
},
titleKey: 'notify.leftThreePlusMembers'
};
} else if (leftParticipantsCount === 2) {
notificationProps = {
titleArguments: {
first: leftParticipantsNames[0],
second: leftParticipantsNames[1]
},
titleKey: 'notify.leftTwoMembers'
};
} else if (leftParticipantsCount) {
notificationProps = {
titleArguments: {
name: leftParticipantsNames[0]
},
titleKey: 'notify.leftOneMember'
};
}
if (notificationProps) {
dispatch(
showNotification(notificationProps, NOTIFICATION_TIMEOUT_TYPE.SHORT));
}
leftParticipantsNames = [];
}, 2000, { leading: false });
/**
* Queues the display of a notification of a participant having connected to
* the meeting. The notifications are batched so that quick consecutive
* connection events are shown in one notification.
*
* @param {string} displayName - The name of the participant that connected.
* @returns {Function}
*/
export function showParticipantJoinedNotification(displayName: string) {
joinedParticipantsNames.push(displayName);
return (dispatch: IStore['dispatch'], getState: IStore['getState']) =>
_throttledNotifyParticipantConnected(dispatch, getState);
}
/**
* Queues the display of a notification of a participant having left to
* the meeting. The notifications are batched so that quick consecutive
* connection events are shown in one notification.
*
* @param {string} displayName - The name of the participant that left.
* @returns {Function}
*/
export function showParticipantLeftNotification(displayName: string) {
leftParticipantsNames.push(displayName);
return (dispatch: IStore['dispatch'], getState: IStore['getState']) =>
_throttledNotifyParticipantLeft(dispatch, getState);
}

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);

View File

@@ -0,0 +1,130 @@
/**
* The standard time when auto-disappearing notifications should disappear.
*/
export const NOTIFICATION_TIMEOUT = {
SHORT: 2500,
MEDIUM: 5000,
LONG: 10000,
EXTRA_LONG: 60000,
STICKY: false
};
/**
* Notification timeout type.
*/
export enum NOTIFICATION_TIMEOUT_TYPE {
EXTRA_LONG = 'extra_long',
LONG = 'long',
MEDIUM = 'medium',
SHORT = 'short',
STICKY = 'sticky'
}
/**
* The set of possible notification types.
*
* @enum {string}
*/
export const NOTIFICATION_TYPE = {
ERROR: 'error',
NORMAL: 'normal',
SUCCESS: 'success',
WARNING: 'warning'
};
/**
* A mapping of notification type to priority of display.
*
* @enum {number}
*/
export const NOTIFICATION_TYPE_PRIORITIES = {
[NOTIFICATION_TYPE.ERROR]: 5,
[NOTIFICATION_TYPE.NORMAL]: 3,
[NOTIFICATION_TYPE.SUCCESS]: 3,
[NOTIFICATION_TYPE.WARNING]: 4
};
/**
* The set of possible notification icons.
*
* @enum {string}
*/
export const NOTIFICATION_ICON = {
...NOTIFICATION_TYPE,
MESSAGE: 'message',
PARTICIPANT: 'participant',
PARTICIPANTS: 'participants'
};
/**
* The identifier of the calendar notification.
*
* @type {string}
*/
export const CALENDAR_NOTIFICATION_ID = 'CALENDAR_NOTIFICATION_ID';
/**
* The identifier of the disable self view notification.
*
* @type {string}
*/
export const DATA_CHANNEL_CLOSED_NOTIFICATION_ID = 'DATA_CHANNEL_CLOSED_NOTIFICATION_ID';
/**
* The identifier of the disable self view notification.
*
* @type {string}
*/
export const DISABLE_SELF_VIEW_NOTIFICATION_ID = 'DISABLE_SELF_VIEW_NOTIFICATION_ID';
/**
* The identifier of the lobby notification.
*
* @type {string}
*/
export const LOBBY_NOTIFICATION_ID = 'LOBBY_NOTIFICATION';
/**
* The identifier of the local recording notification.
*
* @type {string}
*/
export const LOCAL_RECORDING_NOTIFICATION_ID = 'LOCAL_RECORDING_NOTIFICATION_ID';
/**
* The identifier of the raise hand notification.
*
* @type {string}
*/
export const RAISE_HAND_NOTIFICATION_ID = 'RAISE_HAND_NOTIFICATION';
/**
* The identifier of the salesforce link notification.
*
* @type {string}
*/
export const SALESFORCE_LINK_NOTIFICATION_ID = 'SALESFORCE_LINK_NOTIFICATION';
/**
* The identifier of the visitors promotion notification.
*
* @type {string}
*/
export const VISITORS_PROMOTION_NOTIFICATION_ID = 'VISITORS_PROMOTION_NOTIFICATION';
/**
* The identifier of the visitors notification indicating the meeting is not live.
*
* @type {string}
*/
export const VISITORS_NOT_LIVE_NOTIFICATION_ID = 'VISITORS_NOT_LIVE_NOTIFICATION_ID';
/**
* Amount of participants beyond which no join notification will be emitted.
*/
export const SILENT_JOIN_THRESHOLD = 30;
/**
* Amount of participants beyond which no left notification will be emitted.
*/
export const SILENT_LEFT_THRESHOLD = 30;

View File

@@ -0,0 +1,41 @@
import { MODERATION_NOTIFICATIONS, MediaType } from '../av-moderation/constants';
import { IStateful } from '../base/app/types';
import { toState } from '../base/redux/functions';
/**
* Tells whether or not the notifications are enabled and if there are any
* notifications to be displayed based on the current Redux state.
*
* @param {IStateful} stateful - The redux store state.
* @returns {boolean}
*/
export function areThereNotifications(stateful: IStateful) {
const state = toState(stateful);
const { enabled, notifications } = state['features/notifications'];
return enabled && notifications.length > 0;
}
/**
* Tells whether join/leave notifications are enabled in interface_config.
*
* @returns {boolean}
*/
export function joinLeaveNotificationsDisabled() {
return Boolean(typeof interfaceConfig !== 'undefined' && interfaceConfig?.DISABLE_JOIN_LEAVE_NOTIFICATIONS);
}
/**
* Returns whether or not the moderation notification for the given type is displayed.
*
* @param {MEDIA_TYPE} mediaType - The media type to check.
* @param {IStateful} stateful - The redux store state.
* @returns {boolean}
*/
export function isModerationNotificationDisplayed(mediaType: MediaType, stateful: IStateful) {
const state = toState(stateful);
const { notifications } = state['features/notifications'];
return Boolean(notifications.find(n => n.uid === MODERATION_NOTIFICATIONS[mediaType]));
}

View File

@@ -0,0 +1,210 @@
import { IReduxState, IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import {
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED
} from '../base/participants/actionTypes';
import { PARTICIPANT_ROLE } from '../base/participants/constants';
import {
getLocalParticipant,
getParticipantById,
getParticipantDisplayName,
isScreenShareParticipant,
isWhiteboardParticipant
} from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { PARTICIPANTS_PANE_OPEN } from '../participants-pane/actionTypes';
import {
CLEAR_NOTIFICATIONS,
HIDE_NOTIFICATION,
SHOW_NOTIFICATION
} from './actionTypes';
import {
clearNotifications,
hideNotification,
showNotification,
showParticipantJoinedNotification,
showParticipantLeftNotification
} from './actions';
import {
NOTIFICATION_TIMEOUT_TYPE,
RAISE_HAND_NOTIFICATION_ID
} from './constants';
import { areThereNotifications, joinLeaveNotificationsDisabled } from './functions';
/**
* Map of timers.
*
* @type {Map}
*/
const timers = new Map();
/**
* Function that creates a timeout id for specific notification.
*
* @param {Object} notification - Notification for which we want to create a timeout.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {void}
*/
const createTimeoutId = (notification: { timeout: number; uid: string; }, dispatch: IStore['dispatch']) => {
const {
timeout,
uid
} = notification;
if (timeout) {
const timerID = setTimeout(() => {
dispatch(hideNotification(uid));
}, timeout);
timers.set(uid, timerID);
}
};
/**
* Returns notifications state.
*
* @param {Object} state - Global state.
* @returns {Array<Object>} - Notifications state.
*/
const getNotifications = (state: IReduxState) => {
const _visible = areThereNotifications(state);
const { notifications } = state['features/notifications'];
return _visible ? notifications : [];
};
/**
* Middleware that captures actions to display notifications.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const { dispatch, getState } = store;
const state = getState();
switch (action.type) {
case CLEAR_NOTIFICATIONS: {
const _notifications = getNotifications(state);
for (const notification of _notifications) {
if (timers.has(notification.uid)) {
const timeout = timers.get(notification.uid);
clearTimeout(timeout);
timers.delete(notification.uid);
}
}
timers.clear();
break;
}
case SHOW_NOTIFICATION: {
if (timers.has(action.uid)) {
const timer = timers.get(action.uid);
clearTimeout(timer);
timers.delete(action.uid);
}
createTimeoutId(action, dispatch);
break;
}
case HIDE_NOTIFICATION: {
const timer = timers.get(action.uid);
clearTimeout(timer);
timers.delete(action.uid);
break;
}
case PARTICIPANT_JOINED: {
const result = next(action);
const { participant: p } = action;
const { conference } = state['features/base/conference'];
// Do not display notifications for the virtual screenshare and whiteboard tiles.
if (conference
&& !p.local
&& !isScreenShareParticipant(p)
&& !isWhiteboardParticipant(p)
&& !joinLeaveNotificationsDisabled()
&& !p.isReplacing) {
dispatch(showParticipantJoinedNotification(
getParticipantDisplayName(state, p.id)
));
}
return result;
}
case PARTICIPANT_LEFT: {
if (!joinLeaveNotificationsDisabled()) {
const participant = getParticipantById(
store.getState(),
action.participant.id
);
// Do not display notifications for the virtual screenshare tiles.
if (participant
&& !participant.local
&& !isScreenShareParticipant(participant)
&& !isWhiteboardParticipant(participant)
&& !action.participant.isReplaced) {
dispatch(showParticipantLeftNotification(
getParticipantDisplayName(state, participant.id)
));
}
}
return next(action);
}
case PARTICIPANT_UPDATED: {
const { disableModeratorIndicator } = state['features/base/config'];
if (disableModeratorIndicator) {
return next(action);
}
const { id, role } = action.participant;
const localParticipant = getLocalParticipant(state);
if (localParticipant?.id !== id) {
return next(action);
}
const oldParticipant = getParticipantById(state, id);
const oldRole = oldParticipant?.role;
if (oldRole && oldRole !== role && role === PARTICIPANT_ROLE.MODERATOR) {
store.dispatch(showNotification({
titleKey: 'notify.moderator'
},
NOTIFICATION_TIMEOUT_TYPE.SHORT));
}
return next(action);
}
case PARTICIPANTS_PANE_OPEN: {
store.dispatch(hideNotification(RAISE_HAND_NOTIFICATION_ID));
break;
}
}
return next(action);
});
/**
* StateListenerRegistry provides a reliable way to detect the leaving of a
* conference, where we need to clean up the notifications.
*/
StateListenerRegistry.register(
/* selector */ state => getCurrentConference(state),
/* listener */ (conference, { dispatch }) => {
if (!conference) {
dispatch(clearNotifications());
}
}
);

View File

@@ -0,0 +1,131 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
CLEAR_NOTIFICATIONS,
HIDE_NOTIFICATION,
SET_NOTIFICATIONS_ENABLED,
SHOW_NOTIFICATION
} from './actionTypes';
import { NOTIFICATION_TYPE_PRIORITIES } from './constants';
/**
* The initial state of the feature notifications.
*
* @type {array}
*/
const DEFAULT_STATE = {
enabled: true,
notifications: []
};
interface INotification {
component: Object;
props: {
appearance?: string;
descriptionArguments?: Object;
descriptionKey?: string;
titleKey: string;
};
timeout: number;
uid: string;
}
export interface INotificationsState {
enabled: boolean;
notifications: INotification[];
}
/**
* Reduces redux actions which affect the display of notifications.
*
* @param {Object} state - The current redux state.
* @param {Object} action - The redux action to reduce.
* @returns {Object} The next redux state which is the result of reducing the
* specified {@code action}.
*/
ReducerRegistry.register<INotificationsState>('features/notifications',
(state = DEFAULT_STATE, action): INotificationsState => {
switch (action.type) {
case CLEAR_NOTIFICATIONS:
return {
...state,
notifications: []
};
case HIDE_NOTIFICATION:
return {
...state,
notifications: state.notifications.filter(
notification => notification.uid !== action.uid)
};
case SET_NOTIFICATIONS_ENABLED:
return {
...state,
enabled: action.enabled
};
case SHOW_NOTIFICATION:
return {
...state,
notifications:
_insertNotificationByPriority(state.notifications, {
component: action.component,
props: action.props,
timeout: action.timeout,
uid: action.uid
})
};
}
return state;
});
/**
* Creates a new notification queue with the passed in notification placed at
* the end of other notifications with higher or the same priority.
*
* @param {Object[]} notifications - The queue of notifications to be displayed.
* @param {Object} notification - The new notification to add to the queue.
* @private
* @returns {Object[]} A new array with an updated order of the notification
* queue.
*/
function _insertNotificationByPriority(notifications: INotification[], notification: INotification) {
// Create a copy to avoid mutation.
const copyOfNotifications = notifications.slice();
// Get the index of any queued notification that has the same id as the new notification
let insertAtLocation = copyOfNotifications.findIndex(
(queuedNotification: INotification) =>
queuedNotification?.uid === notification?.uid
);
if (insertAtLocation !== -1) {
copyOfNotifications.splice(insertAtLocation, 1, notification);
return copyOfNotifications;
}
const newNotificationPriority
= NOTIFICATION_TYPE_PRIORITIES[notification.props.appearance ?? ''] || 0;
// Find where to insert the new notification based on priority. Do not
// insert at the front of the queue so that the user can finish acting on
// any notification currently being read.
for (let i = 1; i < notifications.length; i++) {
const queuedNotification = notifications[i];
const queuedNotificationPriority
= NOTIFICATION_TYPE_PRIORITIES[queuedNotification.props.appearance ?? '']
|| 0;
if (queuedNotificationPriority < newNotificationPriority) {
insertAtLocation = i;
break;
}
}
copyOfNotifications.splice(insertAtLocation, 0, notification);
return copyOfNotifications;
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
export interface INotificationProps {
appearance?: string;
concatText?: boolean;
customActionHandler?: Function[];
customActionNameKey?: string[];
customActionType?: string[];
description?: string | React.ReactNode;
descriptionArguments?: Object;
descriptionKey?: string;
disableClosing?: boolean;
hideErrorSupportLink?: boolean;
icon?: string;
maxLines?: number;
sticky?: boolean;
title?: string;
titleArguments?: {
[key: string]: string | number;
};
titleKey?: string;
uid?: string;
}