snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
type TimeoutType = ReturnType<typeof setTimeout>;
export const NotificationsTransitionContext = React.createContext({
unmounting: new Map<string, TimeoutType>()
});
/**
* How long a removed notification is kept mounted so that its exit animation can play.
*/
const UNMOUNT_ANIMATION_TIMEOUT = 250;
const NotificationsTransition = ({ children }: { children: ReactElement[]; }) => {
const [ childrenToRender, setChildrenToRender ] = useState(children);
/**
* The pending removals, by uid. Held in a ref so that the timeout callbacks always act on the current
* timers instead of the ones captured when they were scheduled, and mirrored into state so that a render
* is triggered and the notifications which are animating out can be marked as such.
*/
const timers = useRef(new Map<string, TimeoutType>());
const [ unmounting, setUnmounting ] = useState(timers.current);
const removeChild = useCallback((uid: string) => {
timers.current.delete(uid);
setUnmounting(new Map(timers.current));
setChildrenToRender(current => current.filter(child => child.props.uid !== uid));
}, []);
useEffect(() => {
const uids = new Set(children.map(child => child.props.uid));
let timersChanged = false;
// A notification that was re-added while it was animating out is displayed again, so its pending
// removal has to be cancelled. Notifications are keyed by uid and are reused (the lobby one, for
// example), so without this the removal would fire and drop a notification that is still current.
timers.current.forEach((timeoutId, uid) => {
if (uids.has(uid)) {
clearTimeout(timeoutId);
timers.current.delete(uid);
timersChanged = true;
}
});
childrenToRender.forEach(child => {
const { uid } = child.props;
if (!uids.has(uid) && !timers.current.has(uid)) {
timers.current.set(uid, setTimeout(() => removeChild(uid), UNMOUNT_ANIMATION_TIMEOUT));
timersChanged = true;
}
});
if (timersChanged) {
setUnmounting(new Map(timers.current));
}
setChildrenToRender(current => {
const toMount = children.filter(child =>
current.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.
*/
const updated = current.map(child =>
children.find(c => c.props.uid === child.props.uid) ?? child);
return toMount.concat(updated);
});
}, [ children ]);
useEffect(() => {
const pending = timers.current;
return () => {
pending.forEach(timeoutId => clearTimeout(timeoutId));
pending.clear();
};
}, []);
const context = useMemo(() => {
return { unmounting };
}, [ unmounting ]);
return (
<NotificationsTransitionContext.Provider value = { context }>
{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,236 @@
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 && titleText) {
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>
))
}
</>
);
} else if (descriptionArray?.length && !titleText) {
return (
<>
{
descriptionArray.map((line, index) => (
<Text
key = { index }
style = { styles.contentTextDescription }>
{ line.length >= CHAR_LIMIT ? line : replaceNonUnicodeEmojis(line) }
</Text>
))
}
</>
);
} else {
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,141 @@
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]
},
contentTextDescription: {
color: BaseTheme.palette.text04,
paddingLeft: BaseTheme.spacing[4],
paddingTop: BaseTheme.spacing[2]
},
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,378 @@
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.notificationBackground,
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.notificationNormalIcon
},
'&.error': {
backgroundColor: theme.palette.notificationError
},
'&.success': {
backgroundColor: theme.palette.notificationSuccess
},
'&.warning': {
backgroundColor: theme.palette.notificationWarning
}
},
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.notificationText,
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.notificationActionText,
...theme.typography.bodyShortBold,
marginRight: theme.spacing(3),
padding: 0,
cursor: 'pointer',
'&:last-of-type': {
marginRight: 0
},
'&.destructive': {
color: theme.palette.notificationErrorText
},
'&:focus-visible': {
outline: `2px solid ${theme.palette.notificationActionFocus}`,
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.notificationError,
normal: theme.palette.notificationNormalIcon,
success: theme.palette.notificationSuccess,
warning: theme.palette.notificationWarning
};
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 processCustomActions
= (key?: string[], handler?: Function[], type?: string[]): {
content: string; onClick: () => void; testId?: string; type?: string; }[] => {
if (key?.length && handler?.length) {
return key.map((customAction: string, customActionIndex: number) => {
return {
content: t(customAction),
onClick: () => {
if (handler?.[customActionIndex]()) {
onDismiss();
}
},
type: type?.[customActionIndex],
testId: customAction
};
});
}
return [];
};
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 processCustomActions(customActionNameKey, customActionHandler, customActionType).concat(buttons);
}
case NOTIFICATION_TYPE.WARNING:
return [
{
content: t('dialog.Ok'),
onClick: onDismiss
}
];
default:
return processCustomActions(customActionNameKey, customActionHandler, customActionType);
}
}, [ 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.notificationCloseIcon }
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);