feat(notifications): implement a react/redux notification system (#1786)

* feat(notifications): implement a react/redux notification system

* squash into impl explicit timeout, style

* ref(notifications): convert toastr notifications to use react

* ref(toastr): remove library

* squash into conversion: pass timeout

* squash into clean remove from debian patch
This commit is contained in:
virtuacoplenny 2017-07-28 10:56:49 -07:00 committed by yanas
parent e818fa1e9e
commit da1c760abf
19 changed files with 409 additions and 203 deletions

2
app.js
View File

@ -13,8 +13,6 @@ import 'aui-experimental';
import 'aui-css';
import 'aui-experimental-css';
window.toastr = require('toastr');
import conference from './conference';
import API from './modules/API';
import keyboardshortcut from './modules/keyboardshortcut/keyboardshortcut';

View File

@ -1,107 +0,0 @@
/*
* Toastr
* Copyright 2012-2014 John Papa and Hans Fjällemark.
* All Rights Reserved.
* Use, reproduction, distribution, and modification of this code is subject to the terms and
* conditions of the MIT license, available at http://www.opensource.org/licenses/mit-license.php
*
* Author: John Papa and Hans Fjällemark
* Project: https://github.com/CodeSeven/toastr
*
* Last updated: October 13, 2016
*/
.toast-title,
.toast-message .title {
font-weight: bold;
margin: 0 0 3px;
@include text-truncate;
}
.toast-message {
-ms-word-wrap: break-word;
word-wrap: break-word;
}
.toast-message a,
.toast-message label,
.toast-message .connected,
.toast-message .disconnected {
color: $notificationLinkColor;
text-decoration: none;
}
.toast-message a:hover {
text-decoration: underline;
}
.toast-message br {
display: none;
}
// close button
.toast-close-button {
color: $notificationColor;
background: transparent;
font-size: 15px;
line-height: 1.2;
height: 20px;
width: 20px;
padding: 0;
border: 0;
margin: -6px -10px 0 0;
float: right;
cursor: pointer;
@include opacity(0.4);
/* Additional properties for button version
iOS requires the button element instead of an anchor tag.
If you want the anchor version, it requires `href="#"`. */
-webkit-appearance: none;
}
.toast-close-button:hover,
.toast-close-button:focus {
@include opacity(1);
}
.toast {
color: $notificationColor;
background-color: $notificationBackground;
font-size: $notificationFontSize;
padding: $notificationPadding;
border: 1px solid lighten($notificationBackground, 10%);
@include border-radius($notificationBorderRadius);
@include box-shadow(1px, 1px, 2px, rgba(0,0,0,0.3));
@include opacity($notificationOpacity);
}
.toast:hover {
@include opacity(1);
}
#toast-container {
position: fixed;
z-index: $notificationZ;
}
#toast-container.notification-bottom-right {
$videoOffset: 2 * ($thumbnailVideoMargin + $thumbnailsBorder) + $thumbnailVideoBorder;
bottom: 135px;
right: $filmstripToggleButtonWidth + $videoOffset;
}
#toast-container * {
@include box-sizing(border-box);
}
#toast-container .toast {
width: $notificationWidth;
margin: 0 0 8px;
}

View File

@ -85,20 +85,6 @@ $modalMockAKInputBackground: #fafbfc;
$modalMockAKInputBorder: 1px solid #f4f5f7;
$modalTextColor: #333;
/**
* Notifications
*/
$notificationFontSize: 13px;
$notificationColor: #FFFFFF;
$notificationBackground: $tooltipBg;
$notificationTitleColor: $notificationColor;
$notificationMessageColor: $notificationColor;
$notificationLinkColor: $notificationColor;
$notificationOpacity: 0.9;
$notificationPadding: 15px 20px;
$notificationBorderRadius: 4px;
$notificationWidth: 215px;
/**
* Misc.
*/
@ -126,7 +112,6 @@ $tooltipsZ: 401;
$dropdownMaskZ: 900;
$dropdownZ: 901;
$centeredVideoLabelZ: 1010;
$notificationZ: 1011;
$jitsipopoverZ: 1012;
$popoverZ: 1015;
$overlayZ: 1016;

View File

@ -159,15 +159,4 @@
transition-delay: 0.1s;
}
}
/**
* Move toastr closer to the bottom of the screen and move left to avoid
* overlapping of videos when they are configured at default height.
*/
#toast-container {
&.notification-bottom-right {
bottom: 25px;
right: 130 + 2 * ($thumbnailVideoMargin + $thumbnailsBorder) + $thumbnailVideoBorder;
}
}
}

View File

@ -33,7 +33,6 @@
/* Modules BEGIN */
@import 'dial-out';
@import 'toastr';
@import 'base';
@import 'utils';
@import 'overlay/overlay';

View File

@ -3,7 +3,7 @@ Index: jitsi-meet/index.html
===================================================================
--- jitsi-meet.orig/index.html
+++ jitsi-meet/index.html
@@ -10,14 +10,14 @@
@@ -10,13 +10,13 @@
<meta itemprop="description" content="Join a WebRTC video conference powered by the Jitsi Videobridge"/>
<meta itemprop="image" content="/images/jitsilogo.png"/>
<script src="https://api.callstats.io/static/callstats.min.js"></script>
@ -19,4 +19,4 @@ Index: jitsi-meet/index.html
+ <script src="libs/jquery-ui.min.js"></script>
<script src="libs/tooltip.js?v=1"></script><!-- bootstrap tooltip lib -->
<script src="libs/popover.js?v=1"></script><!-- bootstrap tooltip lib -->
<script src="libs/toastr.js?v=1"></script><!-- notifications lib -->
- <script src="libs/toastr.js?v=1"></script><!-- notifications lib -->

View File

@ -1,4 +1,4 @@
/* global APP, JitsiMeetJS, $, config, interfaceConfig, toastr */
/* global APP, JitsiMeetJS, $, config, interfaceConfig */
const logger = require("jitsi-meet-logger").getLogger(__filename);
@ -343,26 +343,6 @@ UI.start = function () {
}
document.title = interfaceConfig.APP_NAME;
if (!interfaceConfig.filmStripOnly) {
toastr.options = {
"closeButton": true,
"debug": false,
"positionClass": "notification-bottom-right",
"onclick": null,
"showDuration": "300",
"hideDuration": "1000",
"timeOut": "2000",
"extendedTimeOut": "1000",
"showEasing": "swing",
"hideEasing": "linear",
"showMethod": "fadeIn",
"hideMethod": "fadeOut",
"newestOnTop": false,
// this is the default toastr close button html, just adds tabIndex
"closeHtml": '<button type="button" tabIndex="-1">&times;</button>'
};
}
};
/**
@ -868,7 +848,7 @@ UI.notifyInitiallyMuted = function () {
"connected",
"notify.muted",
null,
{ timeOut: 120000 });
120000);
};
/**

View File

@ -1,9 +1,13 @@
/* global $, APP, toastr */
/* global $, APP */
const logger = require("jitsi-meet-logger").getLogger(__filename);
import UIUtil from './UIUtil';
import jitsiLocalStorage from '../../util/JitsiLocalStorage';
import {
Notification,
showNotification
} from '../../../react/features/notifications';
/**
* Flag for enable/disable of the notifications.
* @type {boolean}
@ -448,31 +452,25 @@ var messageHandler = {
* @param messageKey the key from the language file for the text of the
* message.
* @param messageArguments object with the arguments for the message.
* @param options passed to toastr (e.g. timeOut)
* @param optional configurations for the notification (e.g. timeout)
*/
participantNotification: function(displayName, displayNameKey, cls,
messageKey, messageArguments, options) {
// If we're in ringing state we skip all toaster notifications.
if(!notificationsEnabled || APP.UI.isOverlayVisible())
messageKey, messageArguments, timeout) {
// If we're in ringing state we skip all notifications.
if (!notificationsEnabled || APP.UI.isOverlayVisible()) {
return;
var displayNameSpan = '<span class="title" ';
if (displayName) {
displayNameSpan += ">" + UIUtil.escapeHtml(displayName);
} else {
displayNameSpan += "data-i18n='" + displayNameKey + "'>";
}
displayNameSpan += "</span>";
let element = toastr.info(
displayNameSpan + '<br>' +
'<span class=' + cls + ' data-i18n="' + messageKey + '"' +
(messageArguments?
" data-i18n-options='"
+ JSON.stringify(messageArguments) + "'"
: "") + "></span>", null, options);
APP.translation.translateElement(element);
return element;
APP.store.dispatch(
showNotification(
Notification,
{
defaultTitleKey: displayNameKey,
descriptionArguments: messageArguments,
descriptionKey: messageKey,
title: displayName
},
timeout));
},
/**
@ -488,28 +486,12 @@ var messageHandler = {
*/
notify: function(titleKey, messageKey, messageArguments) {
// If we're in ringing state we skip all toaster notifications.
// If we're in ringing state we skip all notifications.
if(!notificationsEnabled || APP.UI.isOverlayVisible())
return;
const options = messageArguments
? `data-i18n-options='${JSON.stringify(messageArguments)}'` : "";
let element = toastr.info(`
<span class="title" data-i18n="${titleKey}"></span>
<br>
<span data-i18n="${messageKey}" ${options}></span>
`
);
APP.translation.translateElement(element);
return element;
},
/**
* Removes the toaster.
* @param toasterElement
*/
remove: function(toasterElement) {
toasterElement.remove();
this.participantNotification(
null, titleKey, null, messageKey, messageArguments);
},
/**

View File

@ -21,6 +21,7 @@
"@atlaskit/button-group": "1.1.3",
"@atlaskit/dropdown-menu": "1.4.0",
"@atlaskit/field-text": "2.7.0",
"@atlaskit/flag": "3.0.0",
"@atlaskit/icon": "7.0.0",
"@atlaskit/inline-dialog": "3.2.0",
"@atlaskit/inline-message": "2.1.1",
@ -67,7 +68,6 @@
"strophe": "1.2.4",
"strophejs-plugins": "0.0.7",
"styled-components": "1.3.0",
"toastr": "2.1.2",
"url-polyfill": "github/url-polyfill",
"xmldom": "0.1.27"
},

View File

@ -7,6 +7,7 @@ import { connect, disconnect } from '../../base/connection';
import { DialogContainer } from '../../base/dialog';
import { Filmstrip } from '../../filmstrip';
import { LargeVideo } from '../../large-video';
import { NotificationsContainer } from '../../notifications';
import { OverlayContainer } from '../../overlay';
import { Toolbox } from '../../toolbox';
import { HideNotificationBarStyle } from '../../unsupported-browser';
@ -78,6 +79,7 @@ class Conference extends Component {
{ filmStripOnly ? null : <Toolbox /> }
<DialogContainer />
{ filmStripOnly ? null : <NotificationsContainer /> }
<OverlayContainer />
{/*

View File

@ -0,0 +1,24 @@
/*
* 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 = Symbol('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: number
* }
*/
export const SHOW_NOTIFICATION = Symbol('SHOW_NOTIFICATION');

View File

@ -0,0 +1,47 @@
import {
HIDE_NOTIFICATION,
SHOW_NOTIFICATION
} from './actionTypes';
/**
* 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) {
return {
type: HIDE_NOTIFICATION,
uid
};
}
/**
* Queues a notification for display.
*
* @param {ReactComponent} component - The notification component to be
* displayed.
* @param {Object} props - The props needed to show the notification component.
* @param {number} timeout - How long the notification should display before
* automatically being hidden.
* @returns {{
* type: SHOW_NOTIFICATION,
* component: ReactComponent,
* props: Object,
* timeout: number,
* uid: number
* }}
*/
export function showNotification(component, props = {}, timeout) {
return {
type: SHOW_NOTIFICATION,
component,
props,
timeout,
uid: window.Date.now()
};
}

View File

@ -0,0 +1,100 @@
import Flag from '@atlaskit/flag';
import EditorInfoIcon from '@atlaskit/icon/glyph/editor/info';
import React, { Component } from 'react';
import { translate } from '../../base/i18n';
/**
* Implements a React {@link Component} to display a notification.
*
* @extends Component
*/
class Notification extends Component {
/**
* {@code Notification} component's property types.
*
* @static
*/
static propTypes = {
/**
* The translation key to display as the title of the notification if
* no title is provided.
*/
defaultTitleKey: React.PropTypes.string,
/**
* The translation arguments that may be necessary for the description.
*/
descriptionArguments: React.PropTypes.object,
/**
* The translation key to use as the body of the notification.
*/
descriptionKey: React.PropTypes.string,
/**
* Whether or not the dismiss button should be displayed. This is passed
* in by {@code FlagGroup}.
*/
isDismissAllowed: React.PropTypes.bool,
/**
* Callback invoked when the user clicks to dismiss the notification.
* this is passed in by {@code FlagGroup}.
*/
onDismissed: React.PropTypes.func,
/**
* Invoked to obtain translated strings.
*/
t: React.PropTypes.func,
/**
* The text to display at the top of the notification. If not passed in,
* the passed in defaultTitleKey will be used.
*/
title: React.PropTypes.string,
/**
* The unique identifier for the notification. Passed back by the
* {@code Flag} component in the onDismissed callback.
*/
uid: React.PropTypes.number
};
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
defaultTitleKey,
descriptionArguments,
descriptionKey,
isDismissAllowed,
onDismissed,
t,
title,
uid
} = this.props;
return (
<Flag
appearance = 'normal'
description = { t(descriptionKey, descriptionArguments) }
icon = { (
<EditorInfoIcon
label = 'info'
size = 'medium' />
) }
id = { uid }
isDismissAllowed = { isDismissAllowed }
onDismissed = { onDismissed }
title = { title || t(defaultTitleKey) } />
);
}
}
export default translate(Notification);

View File

@ -0,0 +1,157 @@
import { FlagGroup } from '@atlaskit/flag';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { hideNotification } from '../actions';
/**
* The duration for which a notification should be displayed before being
* dismissed automatically.
*
* @type {number}
*/
const DEFAULT_NOTIFICATION_TIMEOUT = 2500;
/**
* Implements a React {@link Component} which displays notifications and handles
* automatic dismissmal after a notification is shown for a defined timeout
* period.
*
* @extends {Component}
*/
class NotificationsContainer extends Component {
/**
* {@code NotificationsContainer} component's property types.
*
* @static
*/
static propTypes = {
/**
* The notifications to be displayed, with the first index being the
* notification at the top and the rest shown below it in order.
*/
_notifications: React.PropTypes.array,
/**
* Invoked to update the redux store in order to remove notifications.
*/
dispatch: React.PropTypes.func
};
/**
* Initializes a new {@code NotificationsContainer} instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props) {
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 the currently displayed notification has changed.
*
* @inheritdoc
* returns {void}
*/
componentDidUpdate() {
const { _notifications } = this.props;
if (_notifications.length && !this._notificationDismissTimeout) {
const notification = _notifications[0];
const { timeout, uid } = notification;
this._notificationDismissTimeout = setTimeout(() => {
this._onDismissed(uid);
}, timeout || DEFAULT_NOTIFICATION_TIMEOUT);
}
}
/**
* Clear any dismissal timeout that is still active.
*
* @inheritdoc
* returns {void}
*/
componentWillUnmount() {
clearTimeout(this._notificationDismissTimeout);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { _notifications } = this.props;
const flags = _notifications.map(notification => {
const Notification = notification.component;
const { props, uid } = notification;
// The id attribute is necessary as {@code FlagGroup} looks for
// either id or key to set a key on notifications, but accessing
// props.key will cause React to print an error.
return (
<Notification
{ ...props }
id = { uid }
key = { uid }
uid = { uid } />
);
});
return (
<FlagGroup onDismissed = { this._onDismissed }>
{ flags }
</FlagGroup>
);
}
/**
* Emits an action to remove the notification from the redux store so it
* stops displaying.
*
* @param {number} flagUid - The id of the notification to be removed.
* @private
* @returns {void}
*/
_onDismissed(flagUid) {
clearTimeout(this._notificationDismissTimeout);
this._notificationDismissTimeout = null;
this.props.dispatch(hideNotification(flagUid));
}
}
/**
* Maps (parts of) the Redux state to the associated NotificationsContainer's
* props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _notifications: React.PropTypes.array
* }}
*/
function _mapStateToProps(state) {
return {
_notifications: state['features/notifications']
};
}
export default connect(_mapStateToProps)(NotificationsContainer);

View File

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

View File

@ -0,0 +1,5 @@
export * from './actions';
export * from './actionTypes';
export * from './components';
import './reducer';

View File

@ -0,0 +1,43 @@
import { ReducerRegistry } from '../base/redux';
import {
HIDE_NOTIFICATION,
SHOW_NOTIFICATION
} from './actionTypes';
/**
* The initial state of the feature notifications.
*
* @type {array}
*/
const DEFAULT_STATE = [];
/**
* 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('features/notifications',
(state = DEFAULT_STATE, action) => {
switch (action.type) {
case HIDE_NOTIFICATION:
return state.filter(
notification => notification.uid !== action.uid);
case SHOW_NOTIFICATION:
return [
...state,
{
component: action.component,
props: action.props,
timeout: action.timeout,
uid: action.uid
}
];
}
return state;
});