feat(dropbox): For mobile.

This involves redesign of the web recording dialog in order to look the
same as the mobile one.
This commit is contained in:
hristoterezov 2018-09-24 19:08:55 -05:00 committed by Любомир Маринов
parent ae7a882188
commit af37141e3d
16 changed files with 518 additions and 321 deletions

View File

@ -3,8 +3,23 @@
}
.recording-dialog {
flex: 0;
flex-direction: column;
.recording-header {
display: flex;
flex: 0;
flex-direction: row;
justify-content: space-between;
align-items: center;
.recording-title {
font-size: 16px;
font-weight: bold;
}
}
.authorization-panel {
border-bottom: 2px solid rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
margin-bottom: 10px;
@ -32,7 +47,7 @@
}
}
.logged-in-pannel {
.logged-in-panel {
padding: 10px;
}
}

View File

@ -468,8 +468,7 @@
"on": "Recording",
"pending": "Preparing to record the meeting...",
"rec": "REC",
"authDropboxText": "Upload your recording to Dropbox.",
"authDropboxCompletedText": "Your recording file will appear in your Dropbox shortly after the recording has finished.",
"authDropboxText": "Upload to Dropbox",
"serviceName": "Recording service",
"signOut": "Sign Out",
"signIn": "sign in",

View File

@ -1,40 +1,9 @@
// @flow
import { Dropbox } from 'dropbox';
import {
getJitsiMeetGlobalNS,
getLocationContextRoot,
parseStandardURIString
} from '../base/util';
import { parseURLParams } from '../base/config';
import { getLocationContextRoot } from '../base/util';
import { UPDATE_DROPBOX_TOKEN } from './actionTypes';
/**
* Executes the oauth flow.
*
* @param {string} authUrl - The URL to oauth service.
* @returns {Promise<string>} - The URL with the authorization details.
*/
function authorize(authUrl: string): Promise<string> {
const windowName = `oauth${Date.now()}`;
const gloabalNS = getJitsiMeetGlobalNS();
gloabalNS.oauthCallbacks = gloabalNS.oauthCallbacks || {};
return new Promise(resolve => {
const popup = window.open(authUrl, windowName);
gloabalNS.oauthCallbacks[windowName] = () => {
const returnURL = popup.location.href;
popup.close();
delete gloabalNS.oauthCallbacks.windowName;
resolve(returnURL);
};
});
}
import { _authorizeDropbox } from './functions';
/**
* Action to authorize the Jitsi Recording app in dropbox.
@ -45,18 +14,13 @@ export function authorizeDropbox() {
return (dispatch: Function, getState: Function) => {
const state = getState();
const { locationURL } = state['features/base/connection'];
const { dropbox } = state['features/base/config'];
const { dropbox = {} } = state['features/base/config'];
const redirectURI = `${locationURL.origin
+ getLocationContextRoot(locationURL)}static/oauth.html`;
const dropboxAPI = new Dropbox({ clientId: dropbox.clientId });
const url = dropboxAPI.getAuthenticationUrl(redirectURI);
authorize(url).then(returnUrl => {
const params
= parseURLParams(parseStandardURIString(returnUrl), true) || {};
dispatch(updateDropboxToken(params.access_token));
});
_authorizeDropbox(dropbox.clientId, redirectURI)
.then(
token => dispatch(updateDropboxToken(token)));
};
}

View File

@ -0,0 +1,50 @@
// @flow
export * from './functions';
import { getDisplayName, getSpaceUsage } from './functions';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Information related to the user's dropbox account.
*/
type DropboxUserData = {
/**
* The available space left in MB into the user's Dropbox account.
*/
spaceLeft: number,
/**
* The display name of the user in Dropbox.
*/
userName: string
};
/**
* Fetches information about the user's dropbox account.
*
* @param {string} token - The dropbox access token.
* @param {string} clientId - The Jitsi Recorder dropbox app ID.
* @returns {Promise<DropboxUserData|undefined>}
*/
export function getDropboxData(
token: string,
clientId: string
): Promise<?DropboxUserData> {
return Promise.all(
[ getDisplayName(token, clientId), getSpaceUsage(token, clientId) ]
).then(([ userName, space ]) => {
const { allocated, used } = space;
return {
userName,
spaceLeft: Math.floor((allocated - used) / 1048576)// 1MiB=1048576B
};
}, error => {
logger.error(error);
return undefined;
});
}

View File

@ -1,39 +0,0 @@
// @flow
import { Dropbox } from 'dropbox';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Fetches information about the user's dropbox account.
*
* @param {string} token - The dropbox access token.
* @param {string} clientId - The Jitsi Recorder dropbox app ID.
* @returns {Promise<Object|undefined>}
*/
export function getDropboxData(
token: string,
clientId: string
): Promise<?Object> {
const dropboxAPI = new Dropbox({
accessToken: token,
clientId
});
return Promise.all(
[ dropboxAPI.usersGetCurrentAccount(), dropboxAPI.usersGetSpaceUsage() ]
).then(([ account, space ]) => {
const { allocation, used } = space;
const { allocated } = allocation;
return {
userName: account.name.display_name,
spaceLeft: Math.floor((allocated - used) / 1048576)// 1MiB=1048576B
};
}, error => {
logger.error(error);
return undefined;
});
}

View File

@ -0,0 +1,52 @@
// @flow
import { NativeModules } from 'react-native';
const { Dropbox } = NativeModules;
/**
* Returns the display name for the current dropbox account.
*
* @param {string} token - The dropbox access token.
* @returns {Promise<string>} - The promise will be resolved with the display
* name or rejected with an error.
*/
export function getDisplayName(token: string) {
return Dropbox.getDisplayName(token);
}
/**
* Returns information about the space usage for the current dropbox account.
*
* @param {string} token - The dropbox access token.
* @returns {Promise<{ used: number, allocated: number}>} - The promise will be
* resolved with the object with information about the space usage (the used
* space and the allocated space) for the current dropbox account or rejected
* with an error.
*/
export function getSpaceUsage(token: string) {
return Dropbox.getSpaceUsage(token);
}
/**
* Action to authorize the Jitsi Recording app in dropbox.
*
* @param {string} clientId - The Jitsi Recorder dropbox app ID.
* @param {string} redirectURI - The return URL.
* @returns {Promise<string>} - The promise will be resolved with the dropbox
* access token or rejected with an error.
*/
export function _authorizeDropbox(): Promise<string> {
return Dropbox.authorize();
}
/**
* Returns <tt>true</tt> if the dropbox features is enabled and <tt>false</tt>
* otherwise.
*
* @returns {boolean}
*/
export function isEnabled() {
return Dropbox.ENABLED;
}

View File

@ -0,0 +1,112 @@
// @flow
import { Dropbox } from 'dropbox';
import {
getJitsiMeetGlobalNS,
parseStandardURIString
} from '../base/util';
import { parseURLParams } from '../base/config';
/**
* Returns the display name for the current dropbox account.
*
* @param {string} token - The dropbox access token.
* @param {string} clientId - The Jitsi Recorder dropbox app ID.
* @returns {Promise<string>}
*/
export function getDisplayName(token: string, clientId: string) {
const dropboxAPI = new Dropbox({
accessToken: token,
clientId
});
return (
dropboxAPI.usersGetCurrentAccount()
.then(account => account.name.display_name));
}
/**
* Returns information about the space usage for the current dropbox account.
*
* @param {string} token - The dropbox access token.
* @param {string} clientId - The Jitsi Recorder dropbox app ID.
* @returns {Promise<Object>}
*/
export function getSpaceUsage(token: string, clientId: string) {
const dropboxAPI = new Dropbox({
accessToken: token,
clientId
});
return dropboxAPI.usersGetSpaceUsage().then(space => {
const { allocation, used } = space;
const { allocated } = allocation;
return {
used,
allocated
};
});
}
/**
* Executes the oauth flow.
*
* @param {string} authUrl - The URL to oauth service.
* @returns {Promise<string>} - The URL with the authorization details.
*/
function authorize(authUrl: string): Promise<string> {
const windowName = `oauth${Date.now()}`;
const gloabalNS = getJitsiMeetGlobalNS();
gloabalNS.oauthCallbacks = gloabalNS.oauthCallbacks || {};
return new Promise(resolve => {
const popup = window.open(authUrl, windowName);
gloabalNS.oauthCallbacks[windowName] = () => {
const returnURL = popup.location.href;
popup.close();
delete gloabalNS.oauthCallbacks.windowName;
resolve(returnURL);
};
});
}
/**
* Action to authorize the Jitsi Recording app in dropbox.
*
* @param {string} clientId - The Jitsi Recorder dropbox app ID.
* @param {string} redirectURI - The return URL.
* @returns {Promise<string>}
*/
export function _authorizeDropbox(
clientId: string,
redirectURI: string
): Promise<string> {
const dropboxAPI = new Dropbox({ clientId });
const url = dropboxAPI.getAuthenticationUrl(redirectURI);
return authorize(url).then(returnUrl => {
const params
= parseURLParams(parseStandardURIString(returnUrl), true) || {};
return params.access_token;
});
}
/**
* Returns <tt>true</tt> if the dropbox features is enabled and <tt>false</tt>
* otherwise.
*
* @param {Object} state - The redux state.
* @returns {boolean}
*/
export function isEnabled(state: Object) {
const { dropbox = {} } = state['features/base/config'];
return typeof dropbox.clientId === 'string';
}

View File

@ -1,4 +1,4 @@
export * from './actions';
export * from './functions';
export * from './functions.any';
import './reducer';

View File

@ -11,6 +11,7 @@ import {
getLocalParticipant,
isLocalParticipantModerator
} from '../../../base/participants';
import { isEnabled as isDropboxEnabled } from '../../../dropbox';
import {
AbstractButton,
type AbstractButtonProps
@ -123,7 +124,6 @@ export function _mapStateToProps(state: Object, ownProps: Props): Object {
// its own to be visible or not.
const isModerator = isLocalParticipantModerator(state);
const {
dropbox = {},
enableFeaturesBasedOnToken,
fileRecordingsEnabled
} = state['features/base/config'];
@ -131,7 +131,7 @@ export function _mapStateToProps(state: Object, ownProps: Props): Object {
visible = isModerator
&& fileRecordingsEnabled
&& typeof dropbox.clientId === 'string';
&& isDropboxEnabled(state);
if (enableFeaturesBasedOnToken) {
visible = visible && String(features.recording) === 'true';

View File

@ -222,8 +222,10 @@ class StartRecordingDialog extends Component<Props, State> {
* }}
*/
function mapStateToProps(state: Object) {
const { dropbox = {} } = state['features/base/config'];
return {
_clientId: state['features/base/config'].dropbox.clientId,
_clientId: dropbox.clientId,
_conference: state['features/base/conference'].conference,
_token: state['features/dropbox'].token
};

View File

@ -0,0 +1,216 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { translate } from '../../../base/i18n';
import {
Container,
LoadingIndicator,
Switch,
Text
} from '../../../base/react';
import { authorizeDropbox, updateDropboxToken } from '../../../dropbox';
import styles from './styles';
import { getRecordingDurationEstimation } from '../../functions';
type Props = {
/**
* The redux dispatch function.
*/
dispatch: Function,
/**
* <tt>true</tt> if we have valid oauth token.
*/
isTokenValid: boolean,
/**
* <tt>true</tt> if we are in process of validating the oauth token.
*/
isValidating: boolean,
/**
* Number of MiB of available space in user's Dropbox account.
*/
spaceLeft: ?number,
/**
* The translate function.
*/
t: Function,
/**
* The display name of the user's Dropbox account.
*/
userName: ?string,
};
/**
* React Component for getting confirmation to start a file recording session.
*
* @extends Component
*/
class StartRecordingDialogContent extends Component<Props> {
/**
* Initializes a new {@code StartRecordingDialogContent} instance.
*
* @inheritdoc
*/
constructor(props) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._signIn = this._signIn.bind(this);
this._signOut = this._signOut.bind(this);
this._onSwitchChange = this._onSwitchChange.bind(this);
}
/**
* Renders the component.
*
* @protected
* @returns {React$Component}
*/
render() {
const { isTokenValid, isValidating, t } = this.props;
let content = null;
if (isValidating) {
content = this._renderSpinner();
} else if (isTokenValid) {
content = this._renderSignOut();
}
// else { // Sign in screen:
// We don't need to render any additional information.
// }
return (
<Container
className = 'recording-dialog'
style = { styles.container }>
<Container
className = 'recording-header'
style = { styles.header }>
<Text
className = 'recording-title'
style = { styles.title }>
{ t('recording.authDropboxText') }
</Text>
<Switch
disabled = { isValidating }
onValueChange = { this._onSwitchChange }
style = { styles.switch }
value = { isTokenValid } />
</Container>
<Container
className = 'authorization-panel'>
{ content }
</Container>
</Container>
);
}
_onSwitchChange: boolean => void;
/**
* Handler for onValueChange events from the Switch component.
*
* @returns {void}
*/
_onSwitchChange() {
if (this.props.isTokenValid) {
this._signOut();
} else {
this._signIn();
}
}
/**
* Renders a spinner component.
*
* @returns {React$Component}
*/
_renderSpinner() {
return (
<LoadingIndicator
isCompleting = { false }
size = 'medium' />
);
}
/**
* Renders the screen with the account information of a logged in user.
*
* @returns {React$Component}
*/
_renderSignOut() {
const { spaceLeft, t, userName } = this.props;
const duration = getRecordingDurationEstimation(spaceLeft);
return (
<Container>
<Container
className = 'logged-in-panel'
style = { styles.loggedIn }>
<Container>
<Text>
{ t('recording.loggedIn', { userName }) }
</Text>
</Container>
<Container>
<Text>
{
t('recording.availableSpace', {
spaceLeft,
duration
})
}
</Text>
</Container>
</Container>
<Container style = { styles.startRecordingText }>
<Text>{ t('recording.startRecordingBody') }</Text>
</Container>
</Container>
);
}
_signIn: () => {};
/**
* Sings in a user.
*
* @returns {void}
*/
_signIn() {
sendAnalytics(
createRecordingDialogEvent('start', 'signIn.button')
);
this.props.dispatch(authorizeDropbox());
}
_signOut: () => {};
/**
* Sings out an user from dropbox.
*
* @returns {void}
*/
_signOut() {
sendAnalytics(
createRecordingDialogEvent('start', 'signOut.button')
);
this.props.dispatch(updateDropboxToken());
}
}
export default translate(connect()(StartRecordingDialogContent));

View File

@ -1,38 +0,0 @@
// @flow
import React, { Component } from 'react';
import { DialogContent } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
type Props = {
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* React Component for getting confirmation to start a file recording session.
*
* @extends Component
*/
class StartRecordingDialogContent extends Component<Props> {
/**
* Renders the platform specific dialog content.
*
* @returns {void}
*/
render() {
const { t } = this.props;
return (
<DialogContent>
{ t('recording.startRecordingBody') }
</DialogContent>
);
}
}
export default translate(StartRecordingDialogContent);

View File

@ -1,194 +0,0 @@
// @flow
import Spinner from '@atlaskit/spinner';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
createRecordingDialogEvent,
sendAnalytics
} from '../../../analytics';
import { translate } from '../../../base/i18n';
import { authorizeDropbox, updateDropboxToken } from '../../../dropbox';
type Props = {
/**
* The redux dispatch function.
*/
dispatch: Function,
/**
* <tt>true</tt> if we have valid oauth token.
*/
isTokenValid: boolean,
/**
* <tt>true</tt> if we are in process of validating the oauth token.
*/
isValidating: boolean,
/**
* Number of MiB of available space in user's Dropbox account.
*/
spaceLeft: ?number,
/**
* The translate function.
*/
t: Function,
/**
* The display name of the user's Dropbox account.
*/
userName: ?string,
};
/**
* React Component for getting confirmation to start a file recording session.
*
* @extends Component
*/
class StartRecordingDialogContent extends Component<Props> {
/**
* Initializes a new {@code StartRecordingDialogContent} instance.
*
* @inheritdoc
*/
constructor(props) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onSignInClick = this._onSignInClick.bind(this);
this._onSignOutClick = this._onSignOutClick.bind(this);
}
/**
* Renders the platform specific dialog content.
*
* @protected
* @returns {React$Component}
*/
render() {
const { isTokenValid, isValidating, t } = this.props;
let content = null;
if (isValidating) {
content = this._renderSpinner();
} else if (isTokenValid) {
content = this._renderSignOut();
} else {
content = this._renderSignIn();
}
return (
<div className = 'recording-dialog'>
<div className = 'authorization-panel'>
{ content }
</div>
<div>{ t('recording.startRecordingBody') }</div>
</div>
);
}
/**
* Renders a spinner component.
*
* @returns {React$Component}
*/
_renderSpinner() {
return (
<Spinner
isCompleting = { false }
size = 'medium' />
);
}
/**
* Renders the sign in screen.
*
* @returns {React$Component}
*/
_renderSignIn() {
const { t } = this.props;
return (
<div>
<div>{ t('recording.authDropboxText') }</div>
<div
className = 'dropbox-sign-in'
onClick = { this._onSignInClick }>
<img
className = 'dropbox-logo'
src = 'images/dropboxLogo.svg' />
<span>{ t('recording.signIn') }</span>
</div>
</div>
);
}
/**
* Renders the screen with the account information of a logged in user.
*
* @returns {React$Component}
*/
_renderSignOut() {
const { spaceLeft, t, userName } = this.props;
return (
<div>
<div>{ t('recording.authDropboxCompletedText') }</div>
<div className = 'logged-in-panel'>
<div>
{ t('recording.loggedIn', { userName }) }&nbsp;(&nbsp;
<a onClick = { this._onSignOutClick }>
{ t('recording.signOut') }
</a>
&nbsp;)
</div>
<div>
{
t('recording.availableSpace', {
spaceLeft,
// assuming 1min -> 10MB recording:
duration: Math.floor((spaceLeft || 0) / 10)
})
}
</div>
</div>
</div>
);
}
_onSignInClick: () => {};
/**
* Handles click events for the dropbox sign in button.
*
* @returns {void}
*/
_onSignInClick() {
sendAnalytics(
createRecordingDialogEvent('start', 'signIn.button')
);
this.props.dispatch(authorizeDropbox());
}
_onSignOutClick: () => {};
/**
* Sings out an user from dropbox.
*
* @returns {void}
*/
_onSignOutClick() {
sendAnalytics(
createRecordingDialogEvent('start', 'signOut.button')
);
this.props.dispatch(updateDropboxToken());
}
}
export default translate(connect()(StartRecordingDialogContent));

View File

@ -0,0 +1,43 @@
// @flow
import { BoxModel, createStyleSheet } from '../../../base/styles';
// XXX The "standard" {@code BoxModel.padding} has been deemed insufficient in
// the special case(s) of the recording feature bellow.
const _PADDING = BoxModel.padding * 1.5;
/**
* The styles of the React {@code Components} of the feature recording.
*/
export default createStyleSheet({
container: {
flex: 0,
flexDirection: 'column'
},
header: {
alignItems: 'center',
flex: 0,
flexDirection: 'row',
justifyContent: 'space-between',
paddingBottom: _PADDING,
paddingTop: _PADDING
},
loggedIn: {
paddingBottom: _PADDING
},
startRecordingText: {
paddingBottom: _PADDING
},
switch: {
paddingRight: BoxModel.padding
},
title: {
fontSize: 16,
fontWeight: 'bold'
}
});

View File

@ -0,0 +1,3 @@
// XXX CSS is used on Web, JavaScript styles are use only for mobile. Export an
// (empty) object so that styles[*] statements on Web don't trigger errors.
export default {};

View File

@ -19,6 +19,18 @@ export function getActiveSession(state: Object, mode: string) {
|| sessionData.status === statusConstants.PENDING));
}
/**
* Returns an estimated recording duration based on the size of the video file
* in MB. The estimate is calculated under the assumption that 1 min of recorded
* video needs 10MB of storage on avarage.
*
* @param {number} size - The size in MB of the recorded video.
* @returns {number} - The estimated duration in minutes.
*/
export function getRecordingDurationEstimation(size: ?number) {
return Math.floor((size || 0) / 10);
}
/**
* Searches in the passed in redux state for a recording session that matches
* the passed in recording session ID.