feat(device-errors): move device error dialogs to notifications

- Create a notification component for displaying a toggle.
- Create an action for showing the component if allowed by
  the local storage setting and for saving the setting to
  local storage.
- Remove all notifications having a timeout by default so the
  device error notification must be dismissed manually.
- Split the camera and mic error dialog into two separate
  notifications.
This commit is contained in:
Leonard Kim 2017-07-31 11:36:41 -07:00
parent 1ad8436cb5
commit 74ddae4a6a
12 changed files with 335 additions and 128 deletions

View File

@ -625,13 +625,13 @@ export default {
// If both requests for 'audio' + 'video' and 'audio' // If both requests for 'audio' + 'video' and 'audio'
// only failed, we assume that there are some problems // only failed, we assume that there are some problems
// with user's microphone and show corresponding dialog. // with user's microphone and show corresponding dialog.
APP.UI.showDeviceErrorDialog( APP.UI.showMicErrorNotification(audioOnlyError);
audioOnlyError, videoOnlyError); APP.UI.showCameraErrorNotification(videoOnlyError);
} else { } else {
// If request for 'audio' + 'video' failed, but request // If request for 'audio' + 'video' failed, but request
// for 'audio' only was OK, we assume that we had // for 'audio' only was OK, we assume that we had
// problems with camera and show corresponding dialog. // problems with camera and show corresponding dialog.
APP.UI.showDeviceErrorDialog(null, audioAndVideoError); APP.UI.showCameraErrorNotification(audioAndVideoError);
} }
} }
@ -770,7 +770,7 @@ export default {
const maybeShowErrorDialog = (error) => { const maybeShowErrorDialog = (error) => {
if (showUI) { if (showUI) {
APP.UI.showDeviceErrorDialog(error, null); APP.UI.showMicErrorNotification(error);
} }
}; };
@ -830,7 +830,7 @@ export default {
const maybeShowErrorDialog = (error) => { const maybeShowErrorDialog = (error) => {
if (showUI) { if (showUI) {
APP.UI.showDeviceErrorDialog(null, error); APP.UI.showCameraErrorNotification(error);
} }
}; };
@ -2028,7 +2028,7 @@ export default {
APP.settings.setCameraDeviceId(cameraDeviceId, true); APP.settings.setCameraDeviceId(cameraDeviceId, true);
}) })
.catch((err) => { .catch((err) => {
APP.UI.showDeviceErrorDialog(null, err); APP.UI.showCameraErrorNotification(err);
}); });
} }
); );
@ -2049,7 +2049,7 @@ export default {
APP.settings.setMicDeviceId(micDeviceId, true); APP.settings.setMicDeviceId(micDeviceId, true);
}) })
.catch((err) => { .catch((err) => {
APP.UI.showDeviceErrorDialog(err, null); APP.UI.showMicErrorNotification(err);
}); });
} }
); );

View File

@ -474,5 +474,9 @@
"retry": "Try again", "retry": "Try again",
"support": "Support", "support": "Support",
"supportMsg": "If this keeps happening, reach out to" "supportMsg": "If this keeps happening, reach out to"
},
"deviceError": {
"cameraPermission": "Error obtaining camera permission",
"microphonePermission": "Error obtaining microphone permission"
} }
} }

View File

@ -39,6 +39,9 @@ import {
showDialOutButton, showDialOutButton,
showToolbox showToolbox
} from '../../react/features/toolbox'; } from '../../react/features/toolbox';
import {
maybeShowNotificationWithDoNotDisplay
} from '../../react/features/notifications';
var EventEmitter = require("events"); var EventEmitter = require("events");
UI.messageHandler = messageHandler; UI.messageHandler = messageHandler;
@ -64,8 +67,6 @@ let sharedVideoManager;
let followMeHandler; let followMeHandler;
let deviceErrorDialog;
const TrackErrors = JitsiMeetJS.errors.track; const TrackErrors = JitsiMeetJS.errors.track;
const JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP = { const JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP = {
@ -1189,115 +1190,74 @@ UI.showExtensionInlineInstallationDialog = function (callback) {
}); });
}; };
/**
* Shows a notifications about the passed in microphone error.
*
* @param {JitsiTrackError} micError - An error object related to using or
* acquiring an audio stream.
* @returns {void}
*/
UI.showMicErrorNotification = function (micError) {
if (!micError) {
return;
}
const { message, name } = micError;
const persistenceKey = `doNotShowErrorAgain-mic-${name}`;
const micJitsiTrackErrorMsg
= JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.microphone[name];
const micErrorMsg = micJitsiTrackErrorMsg
|| JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.microphone[TrackErrors.GENERAL];
const additionalMicErrorMsg = micJitsiTrackErrorMsg ? null : message;
APP.store.dispatch(maybeShowNotificationWithDoNotDisplay(
persistenceKey,
{
additionalMessage: additionalMicErrorMsg,
messageKey: micErrorMsg,
showToggle: Boolean(micJitsiTrackErrorMsg),
subtitleKey: 'dialog.micErrorPresent',
titleKey: name === TrackErrors.PERMISSION_DENIED
? 'deviceError.microphonePermission' : 'dialog.error',
toggleLabelKey: 'dialog.doNotShowWarningAgain'
}));
};
/** /**
* Shows dialog with combined information about camera and microphone errors. * Shows a notifications about the passed in camera error.
* @param {JitsiTrackError} micError *
* @param {JitsiTrackError} cameraError * @param {JitsiTrackError} cameraError - An error object related to using or
* acquiring a video stream.
* @returns {void}
*/ */
UI.showDeviceErrorDialog = function (micError, cameraError) { UI.showCameraErrorNotification = function (cameraError) {
let dontShowAgain = { if (!cameraError) {
id: "doNotShowWarningAgain", return;
localStorageKey: "doNotShowErrorAgain",
textKey: "dialog.doNotShowWarningAgain"
};
let isMicJitsiTrackErrorAndHasName = micError && micError.name &&
micError instanceof JitsiMeetJS.errorTypes.JitsiTrackError;
let isCameraJitsiTrackErrorAndHasName = cameraError && cameraError.name &&
cameraError instanceof JitsiMeetJS.errorTypes.JitsiTrackError;
let showDoNotShowWarning = false;
if (micError && cameraError && isMicJitsiTrackErrorAndHasName &&
isCameraJitsiTrackErrorAndHasName) {
showDoNotShowWarning = true;
} else if (micError && isMicJitsiTrackErrorAndHasName && !cameraError) {
showDoNotShowWarning = true;
} else if (cameraError && isCameraJitsiTrackErrorAndHasName && !micError) {
showDoNotShowWarning = true;
} }
if (micError) { const { message, name } = cameraError;
dontShowAgain.localStorageKey += "-mic-" + micError.name;
}
if (cameraError) { const persistenceKey = `doNotShowErrorAgain-camera-${name}`;
dontShowAgain.localStorageKey += "-camera-" + cameraError.name;
}
let cameraJitsiTrackErrorMsg = cameraError const cameraJitsiTrackErrorMsg =
? JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.camera[cameraError.name] JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.camera[name];
: undefined; const cameraErrorMsg = cameraJitsiTrackErrorMsg
let micJitsiTrackErrorMsg = micError || JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.camera[TrackErrors.GENERAL];
? JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.microphone[micError.name] const additionalCameraErrorMsg = cameraJitsiTrackErrorMsg ? null : message;
: undefined;
let cameraErrorMsg = cameraError
? cameraJitsiTrackErrorMsg ||
JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.camera[TrackErrors.GENERAL]
: "";
let micErrorMsg = micError
? micJitsiTrackErrorMsg ||
JITSI_TRACK_ERROR_TO_MESSAGE_KEY_MAP.microphone[TrackErrors.GENERAL]
: "";
let additionalCameraErrorMsg = !cameraJitsiTrackErrorMsg && cameraError &&
cameraError.message
? `<div>${cameraError.message}</div>`
: ``;
let additionalMicErrorMsg = !micJitsiTrackErrorMsg && micError &&
micError.message
? `<div>${micError.message}</div>`
: ``;
let message = '';
if (micError) { APP.store.dispatch(maybeShowNotificationWithDoNotDisplay(
message = ` persistenceKey,
${message} {
<h3 data-i18n='dialog.micErrorPresent'></h3> additionalMessage: additionalCameraErrorMsg,
<h4 data-i18n='${micErrorMsg}'></h4> messageKey: cameraErrorMsg,
${additionalMicErrorMsg}`; showToggle: Boolean(cameraJitsiTrackErrorMsg),
} subtitleKey: 'dialog.cameraErrorPresent',
titleKey: name === TrackErrors.PERMISSION_DENIED
if (cameraError) { ? 'deviceError.cameraPermission' : 'dialog.error',
message = ` toggleLabelKey: 'dialog.doNotShowWarningAgain'
${message} }));
<h3 data-i18n='dialog.cameraErrorPresent'></h3>
<h4 data-i18n='${cameraErrorMsg}'></h4>
${additionalCameraErrorMsg}`;
}
// To make sure we don't have multiple error dialogs open at the same time,
// we will just close the previous one if we are going to show a new one.
deviceErrorDialog && deviceErrorDialog.close();
deviceErrorDialog = messageHandler.openDialog(
getTitleKey(),
message,
false,
{Ok: true},
function () {},
null,
function () {
// Reset dialog reference to null to avoid memory leaks when
// user closed the dialog manually.
deviceErrorDialog = null;
},
showDoNotShowWarning ? dontShowAgain : undefined
);
function getTitleKey() {
let title = "dialog.error";
if (micError && micError.name === TrackErrors.PERMISSION_DENIED) {
if (!cameraError
|| cameraError.name === TrackErrors.PERMISSION_DENIED) {
title = "dialog.permissionDenied";
}
} else if (cameraError
&& cameraError.name === TrackErrors.PERMISSION_DENIED) {
title = "dialog.permissionDenied";
}
return title;
}
}; };
/** /**

View File

@ -455,7 +455,7 @@ var messageHandler = {
* @param optional configurations for the notification (e.g. timeout) * @param optional configurations for the notification (e.g. timeout)
*/ */
participantNotification: function(displayName, displayNameKey, cls, participantNotification: function(displayName, displayNameKey, cls,
messageKey, messageArguments, timeout) { messageKey, messageArguments, timeout = 2500) {
// If we're in ringing state we skip all notifications. // If we're in ringing state we skip all notifications.
if (!notificationsEnabled || APP.UI.isOverlayVisible()) { if (!notificationsEnabled || APP.UI.isOverlayVisible()) {
return; return;

View File

@ -192,9 +192,12 @@ export default {
createVideoTrack(false).then(([stream]) => stream) createVideoTrack(false).then(([stream]) => stream)
])) ]))
.then(tracks => { .then(tracks => {
if (audioTrackError || videoTrackError) { if (audioTrackError) {
APP.UI.showDeviceErrorDialog( APP.UI.showMicErrorNotification(audioTrackError);
audioTrackError, videoTrackError); }
if (videoTrackError) {
APP.UI.showCameraErrorNotification(videoTrackError);
} }
return tracks.filter(t => typeof t !== 'undefined'); return tracks.filter(t => typeof t !== 'undefined');
@ -215,7 +218,7 @@ export default {
}) })
.catch(err => { .catch(err => {
audioTrackError = err; audioTrackError = err;
showError && APP.UI.showDeviceErrorDialog(err, null); showError && APP.UI.showMicErrorNotification(err);
return []; return [];
}); });
} }
@ -228,7 +231,7 @@ export default {
}) })
.catch(err => { .catch(err => {
videoTrackError = err; videoTrackError = err;
showError && APP.UI.showDeviceErrorDialog(null, err); showError && APP.UI.showCameraErrorNotification(err);
return []; return [];
}); });
} }

View File

@ -29,6 +29,7 @@
"@atlaskit/multi-select": "6.2.0", "@atlaskit/multi-select": "6.2.0",
"@atlaskit/spinner": "2.2.3", "@atlaskit/spinner": "2.2.3",
"@atlaskit/tabs": "2.0.0", "@atlaskit/tabs": "2.0.0",
"@atlaskit/toggle": "2.0.4",
"@atlassian/aui": "6.0.6", "@atlassian/aui": "6.0.6",
"async": "0.9.0", "async": "0.9.0",
"autosize": "1.18.13", "autosize": "1.18.13",

View File

@ -4,7 +4,7 @@
* *
* { * {
* type: HIDE_NOTIFICATION, * type: HIDE_NOTIFICATION,
* uid: string * uid: number
* } * }
*/ */
export const HIDE_NOTIFICATION = Symbol('HIDE_NOTIFICATION'); export const HIDE_NOTIFICATION = Symbol('HIDE_NOTIFICATION');

View File

@ -1,7 +1,10 @@
import jitsiLocalStorage from '../../../modules/util/JitsiLocalStorage';
import { import {
HIDE_NOTIFICATION, HIDE_NOTIFICATION,
SHOW_NOTIFICATION SHOW_NOTIFICATION
} from './actionTypes'; } from './actionTypes';
import { NotificationWithToggle } from './components';
/** /**
* Removes the notification with the passed in id. * Removes the notification with the passed in id.
@ -10,7 +13,7 @@ import {
* removed. * removed.
* @returns {{ * @returns {{
* type: HIDE_NOTIFICATION, * type: HIDE_NOTIFICATION,
* uid: string * uid: number
* }} * }}
*/ */
export function hideNotification(uid) { export function hideNotification(uid) {
@ -45,3 +48,33 @@ export function showNotification(component, props = {}, timeout) {
uid: window.Date.now() uid: window.Date.now()
}; };
} }
/**
* Displays a notification unless the passed in persistenceKey value exists in
* local storage and has been set to "true".
*
* @param {string} persistenceKey - The local storage key to look up for whether
* or not the notification should display.
* @param {Object} props - The props needed to show the notification component.
* @returns {Function}
*/
export function maybeShowNotificationWithDoNotDisplay(persistenceKey, props) {
return dispatch => {
if (jitsiLocalStorage.getItem(persistenceKey) === 'true') {
return;
}
const newProps = Object.assign({}, props, {
onToggleSubmit: isToggled => {
jitsiLocalStorage.setItem(persistenceKey, isToggled);
}
});
dispatch({
type: SHOW_NOTIFICATION,
component: NotificationWithToggle,
props: newProps,
uid: window.Date.now()
});
};
}

View File

@ -0,0 +1,210 @@
import Flag from '@atlaskit/flag';
import WarningIcon from '@atlaskit/icon/glyph/warning';
import { ToggleStateless } from '@atlaskit/toggle';
import React, { Component } from 'react';
import { translate } from '../../base/i18n';
/**
* React {@code Component} for displaying a notification with a toggle element.
*
* @extends Component
*/
class NotificationWithToggle extends Component {
/**
* {@code NotificationWithToggle} component's property types.
*
* @static
*/
static propTypes = {
/**
* Any additional text to display at the end of the notification message
* body.
*/
additionalMessage: React.PropTypes.string,
/**
* Whether or not the dismiss button should be displayed. This is passed
* in by {@code FlagGroup}.
*/
isDismissAllowed: React.PropTypes.bool,
/**
* The translation key to be used as the main body of the notification.
*/
messageKey: React.PropTypes.string,
/**
* Callback invoked when the user clicks to dismiss the notification.
* This is passed in by {@code FlagGroup}.
*/
onDismissed: React.PropTypes.func,
/**
* Optional callback to invoke when the notification is dismissed. The
* current value of the toggle element will be passed in.
*/
onToggleSubmit: React.PropTypes.func,
/**
* Whether or not the toggle element should be displayed.
*/
showToggle: React.PropTypes.bool,
/**
* Translation key for a message to display at the top of the
* notification body.
*/
subtitleKey: React.PropTypes.string,
/**
* Invoked to obtain translated strings.
*/
t: React.PropTypes.func,
/**
* The translation key to be used as the title of the notification.
*/
titleKey: React.PropTypes.string,
/*
* The translation key to be used as a label describing what setting the
* toggle will change.
*/
toggleLabelKey: React.PropTypes.string,
/**
* The unique identifier for the notification. Passed back by the
* {@code Flag} component in the onDismissed callback.
*/
uid: React.PropTypes.number
};
/**
* Initializes a new {@code NotificationWithToggle} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props) {
super(props);
this.state = {
/**
* Whether or not the toggle element is active/checked/selected.
*
* @type {boolean}
*/
isToggleChecked: false
};
// Bind event handlers so they are only bound once for every instance.
this._onDismissed = this._onDismissed.bind(this);
this._onToggleChange = this._onToggleChange.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
isDismissAllowed,
t,
titleKey,
uid
} = this.props;
return (
<Flag
actions = { [
{
content: t('dialog.Ok'),
onClick: this._onDismissed
}
] }
appearance = 'warning'
description = { this._renderDescription() }
icon = { (
<WarningIcon
label = 'Warning'
size = 'medium' />
) }
id = { uid }
isDismissAllowed = { isDismissAllowed }
onDismissed = { this._onDismissed }
title = { t(titleKey) } />
);
}
/**
* Calls back into {@code FlagGroup} to dismiss the notification. Optionally
* will execute a passed in onToggleSubmit callback with the current state
* of the toggle element.
*
* @private
* @returns {void}
*/
_onDismissed() {
const { onDismissed, onToggleSubmit, showToggle, uid } = this.props;
if (showToggle && onToggleSubmit) {
onToggleSubmit(this.state.isToggleChecked);
}
onDismissed(uid);
}
/**
* Updates the current known state of the toggle selection.
*
* @param {Object} event - The DOM event from changing the toggle selection.
* @private
* @returns {void}
*/
_onToggleChange(event) {
this.setState({
isToggleChecked: event.target.checked
});
}
/**
* Creates a React Element for displaying the notification message as well
* as a toggle.
*
* @private
* @returns {ReactElement}
*/
_renderDescription() {
const {
additionalMessage,
messageKey,
showToggle,
subtitleKey,
t,
toggleLabelKey
} = this.props;
return (
<div className = 'notification-with-toggle'>
<div>{ t(subtitleKey) }</div>
{ messageKey ? <div>{ t(messageKey) }</div> : null }
{ additionalMessage ? <div>{ additionalMessage }</div>
: null }
{ showToggle
? <div>
{ t(toggleLabelKey) }
<ToggleStateless
isChecked
= { this.state.isToggleChecked }
onChange = { this._onToggleChange } />
</div>
: null }
</div>
);
}
}
export default translate(NotificationWithToggle);

View File

@ -4,14 +4,6 @@ import { connect } from 'react-redux';
import { hideNotification } from '../actions'; 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 * Implements a React {@link Component} which displays notifications and handles
* automatic dismissmal after a notification is shown for a defined timeout * automatic dismissmal after a notification is shown for a defined timeout
@ -74,8 +66,11 @@ class NotificationsContainer extends Component {
const { timeout, uid } = notification; const { timeout, uid } = notification;
this._notificationDismissTimeout = setTimeout(() => { this._notificationDismissTimeout = setTimeout(() => {
this._onDismissed(uid); // Perform a no-op if a timeout is not specified.
}, timeout || DEFAULT_NOTIFICATION_TIMEOUT); if (Number.isInteger(timeout)) {
this._onDismissed(uid);
}
}, timeout);
} }
} }

View File

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