This commit is contained in:
45
react/features/notifications/actionTypes.ts
Normal file
45
react/features/notifications/actionTypes.ts
Normal 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';
|
||||
346
react/features/notifications/actions.ts
Normal file
346
react/features/notifications/actions.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
1
react/features/notifications/components/index.native.ts
Normal file
1
react/features/notifications/components/index.native.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as NotificationsContainer } from './native/NotificationsContainer';
|
||||
1
react/features/notifications/components/index.web.ts
Normal file
1
react/features/notifications/components/index.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as NotificationsContainer } from './web/NotificationsContainer';
|
||||
222
react/features/notifications/components/native/Notification.tsx
Normal file
222
react/features/notifications/components/native/Notification.tsx
Normal 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;
|
||||
@@ -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);
|
||||
135
react/features/notifications/components/native/styles.ts
Normal file
135
react/features/notifications/components/native/styles.ts
Normal 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%'
|
||||
}
|
||||
};
|
||||
372
react/features/notifications/components/web/Notification.tsx
Normal file
372
react/features/notifications/components/web/Notification.tsx
Normal 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;
|
||||
@@ -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);
|
||||
130
react/features/notifications/constants.ts
Normal file
130
react/features/notifications/constants.ts
Normal 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;
|
||||
41
react/features/notifications/functions.ts
Normal file
41
react/features/notifications/functions.ts
Normal 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]));
|
||||
}
|
||||
210
react/features/notifications/middleware.ts
Normal file
210
react/features/notifications/middleware.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
);
|
||||
131
react/features/notifications/reducer.ts
Normal file
131
react/features/notifications/reducer.ts
Normal 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;
|
||||
}
|
||||
23
react/features/notifications/types.ts
Normal file
23
react/features/notifications/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user