Moves google-api in its own feature. (#3339)

* Moves google-api in its own feature.

* Stores the profile email in redux.
This commit is contained in:
Дамян Минков 2018-08-02 16:56:36 -05:00 committed by virtuacoplenny
parent 7ad0639f7a
commit af7c69a1aa
9 changed files with 339 additions and 160 deletions

View File

@ -0,0 +1,19 @@
/**
* The type of Redux action which changes Google API state.
*
* {
* type: SET_GOOGLE_API_STATE
* }
* @public
*/
export const SET_GOOGLE_API_STATE = Symbol('SET_GOOGLE_API_STATE');
/**
* The type of Redux action which changes Google API profile state.
*
* {
* type: SET_GOOGLE_API_PROFILE
* }
* @public
*/
export const SET_GOOGLE_API_PROFILE = Symbol('SET_GOOGLE_API_PROFILE');

View File

@ -0,0 +1,139 @@
/* @flow */
import {
SET_GOOGLE_API_PROFILE,
SET_GOOGLE_API_STATE
} from './actionTypes';
import { GOOGLE_API_STATES } from './constants';
import googleApi from './googleApi';
/**
* Loads Google API.
*
* @param {string} clientId - The client ID to be used with the API library.
* @returns {Function}
*/
export function loadGoogleAPI(clientId: string) {
return (dispatch: Dispatch<*>) =>
googleApi.get()
.then(() => googleApi.initializeClient(clientId))
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.LOADED }))
.then(() => googleApi.isSignedIn())
.then(isSignedIn => {
if (isSignedIn) {
dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN });
}
});
}
/**
* Prompts the participant to sign in to the Google API Client Library.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function signIn() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}));
}
/**
* Updates the profile data that is currently used.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function updateProfile() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}))
.then(() => googleApi.getCurrentUserProfile())
.then(profile => dispatch({
type: SET_GOOGLE_API_PROFILE,
profileEmail: profile.getEmail()
}));
}
/**
* Executes a request for a list of all YouTube broadcasts associated with
* user currently signed in to the Google API Client Library.
*
* @returns {function(): (Promise<*>|Promise<any[] | never>)}
*/
export function requestAvailableYouTubeBroadcasts() {
return () =>
googleApi.requestAvailableYouTubeBroadcasts()
.then(response => {
// Takes in a list of broadcasts from the YouTube API,
// removes dupes, removes broadcasts that cannot get a stream key,
// and parses the broadcasts into flat objects.
const broadcasts = response.result.items;
const parsedBroadcasts = {};
for (let i = 0; i < broadcasts.length; i++) {
const broadcast = broadcasts[i];
const boundStreamID = broadcast.contentDetails.boundStreamId;
if (boundStreamID && !parsedBroadcasts[boundStreamID]) {
parsedBroadcasts[boundStreamID] = {
boundStreamID,
id: broadcast.id,
status: broadcast.status.lifeCycleStatus,
title: broadcast.snippet.title
};
}
}
return Object.values(parsedBroadcasts);
});
}
/**
* Fetches the stream key for a YouTube broadcast and updates the internal
* state to display the associated stream key as being entered.
*
* @param {string} boundStreamID - The bound stream ID associated with the
* broadcast from which to get the stream key.
* @returns {function(): (Promise<*>|Promise<{
* streamKey: (*|string),
* selectedBoundStreamID: *} | never>)}
*/
export function requestLiveStreamsForYouTubeBroadcast(boundStreamID: string) {
return () =>
googleApi.requestLiveStreamsForYouTubeBroadcast(boundStreamID)
.then(response => {
const broadcasts = response.result.items;
const streamName = broadcasts
&& broadcasts[0]
&& broadcasts[0].cdn.ingestionInfo.streamName;
const streamKey = streamName || '';
return {
streamKey,
selectedBoundStreamID: boundStreamID
};
});
}
/**
* Forces the Google web client application to prompt for a sign in, such as
* when changing account, and will then fetch available YouTube broadcasts.
*
* @returns {function(): (Promise<*>|Promise<{
* streamKey: (*|string),
* selectedBoundStreamID: *} | never>)}
*/
export function showAccountSelection() {
return () =>
googleApi.showAccountSelection();
}

View File

@ -0,0 +1,33 @@
// @flow
/**
* The Google API scopes to request access to for streaming.
*
* @type {Array<string>}
*/
export const GOOGLE_API_SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly'
];
/**
* An enumeration of the different states the Google API can be in.
*
* @private
* @type {Object}
*/
export const GOOGLE_API_STATES = {
/**
* The state in which the Google API still needs to be loaded.
*/
NEEDS_LOADING: 0,
/**
* The state in which the Google API is loaded and ready for use.
*/
LOADED: 1,
/**
* The state in which a user has been logged in through the Google API.
*/
SIGNED_IN: 2
};

View File

@ -0,0 +1,5 @@
export { GOOGLE_API_STATES } from './constants';
export * from './googleApi';
export * from './actions';
import './reducer';

View File

@ -0,0 +1,40 @@
// @flow
import { ReducerRegistry } from '../base/redux';
import {
SET_GOOGLE_API_PROFILE,
SET_GOOGLE_API_STATE
} from './actionTypes';
import { GOOGLE_API_STATES } from './constants';
/**
* The default state is the Google API needs loading.
*
* @type {{googleAPIState: number}}
*/
const DEFAULT_STATE = {
googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING,
profileEmail: ''
};
/**
* Reduces the Redux actions of the feature features/google-api.
*/
ReducerRegistry.register('features/google-api',
(state = DEFAULT_STATE, action) => {
switch (action.type) {
case SET_GOOGLE_API_STATE:
return {
...state,
googleAPIState: action.googleAPIState
};
case SET_GOOGLE_API_PROFILE:
return {
...state,
profileEmail: action.profileEmail
};
}
return state;
});

View File

@ -26,6 +26,18 @@ export type Props = {
*/ */
_googleApiApplicationClientID: string, _googleApiApplicationClientID: string,
/**
* The current state of interactions with the Google API. Determines what
* Google related UI should display.
*/
_googleAPIState: number,
/**
* The email of the user currently logged in to the Google web client
* application.
*/
_googleProfileEmail: string,
/** /**
* The live stream key that was used before. * The live stream key that was used before.
*/ */
@ -60,18 +72,6 @@ export type State = {
*/ */
errorType: ?string, errorType: ?string,
/**
* The current state of interactions with the Google API. Determines what
* Google related UI should display.
*/
googleAPIState: number,
/**
* The email of the user currently logged in to the Google web client
* application.
*/
googleProfileEmail: string,
/** /**
* The boundStreamID of the broadcast currently selected in the broadcast * The boundStreamID of the broadcast currently selected in the broadcast
* dropdown. * dropdown.
@ -84,36 +84,6 @@ export type State = {
streamKey: string streamKey: string
}; };
/**
* An enumeration of the different states the Google API can be in while
* interacting with {@code StartLiveStreamDialog}.
*
* @private
* @type {Object}
*/
export const GOOGLE_API_STATES = {
/**
* The state in which the Google API still needs to be loaded.
*/
NEEDS_LOADING: 0,
/**
* The state in which the Google API is loaded and ready for use.
*/
LOADED: 1,
/**
* The state in which a user has been logged in through the Google API.
*/
SIGNED_IN: 2,
/**
* The state in which the Google API encountered an error either loading
* or with an API request.
*/
ERROR: 3
};
/** /**
* Implements an abstract class for the StartLiveStreamDialog on both platforms. * Implements an abstract class for the StartLiveStreamDialog on both platforms.
* *
@ -136,8 +106,6 @@ export default class AbstractStartLiveStreamDialog
this.state = { this.state = {
broadcasts: undefined, broadcasts: undefined,
errorType: undefined, errorType: undefined,
googleAPIState: GOOGLE_API_STATES.NEEDS_LOADING,
googleProfileEmail: '',
selectedBoundStreamID: undefined, selectedBoundStreamID: undefined,
streamKey: '' streamKey: ''
}; };
@ -331,6 +299,8 @@ export function _mapStateToProps(state: Object) {
_conference: state['features/base/conference'].conference, _conference: state['features/base/conference'].conference,
_googleApiApplicationClientID: _googleApiApplicationClientID:
state['features/base/config'].googleApiApplicationClientID, state['features/base/config'].googleApiApplicationClientID,
_googleAPIState: state['features/google-api'].googleAPIState,
_googleProfileEmail: state['features/google-api'].profileEmail,
_streamKey: state['features/recording'].streamKey _streamKey: state['features/recording'].streamKey
}; };
} }

View File

@ -6,19 +6,24 @@ import { connect } from 'react-redux';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import googleApi from '../../googleApi'; import {
updateProfile,
GOOGLE_API_STATES,
loadGoogleAPI,
requestAvailableYouTubeBroadcasts,
requestLiveStreamsForYouTubeBroadcast,
showAccountSelection,
signIn
} from '../../../google-api';
import AbstractStartLiveStreamDialog, { import AbstractStartLiveStreamDialog, {
_mapStateToProps, _mapStateToProps,
GOOGLE_API_STATES,
type Props type Props
} from './AbstractStartLiveStreamDialog'; } from './AbstractStartLiveStreamDialog';
import BroadcastsDropdown from './BroadcastsDropdown'; import BroadcastsDropdown from './BroadcastsDropdown';
import GoogleSignInButton from './GoogleSignInButton'; import GoogleSignInButton from './GoogleSignInButton';
import StreamKeyForm from './StreamKeyForm'; import StreamKeyForm from './StreamKeyForm';
declare var interfaceConfig: Object;
/** /**
* A React Component for requesting a YouTube stream key to use for live * A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference. * streaming of the current conference.
@ -40,6 +45,7 @@ class StartLiveStreamDialog
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._onGetYouTubeBroadcasts = this._onGetYouTubeBroadcasts.bind(this); this._onGetYouTubeBroadcasts = this._onGetYouTubeBroadcasts.bind(this);
this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this); this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this);
this._onGoogleSignIn = this._onGoogleSignIn.bind(this);
this._onRequestGoogleSignIn = this._onRequestGoogleSignIn.bind(this); this._onRequestGoogleSignIn = this._onRequestGoogleSignIn.bind(this);
this._onYouTubeBroadcastIDSelected this._onYouTubeBroadcastIDSelected
= this._onYouTubeBroadcastIDSelected.bind(this); = this._onYouTubeBroadcastIDSelected.bind(this);
@ -58,23 +64,23 @@ class StartLiveStreamDialog
* @returns {Promise} * @returns {Promise}
*/ */
_onInitializeGoogleApi() { _onInitializeGoogleApi() {
return googleApi.get() this.props.dispatch(
.then(() => googleApi.initializeClient( loadGoogleAPI(this.props._googleApiApplicationClientID))
this.props._googleApiApplicationClientID)) .catch(response => this._parseErrorFromResponse(response));
.then(() => this._setStateIfMounted({ }
googleAPIState: GOOGLE_API_STATES.LOADED
})) /**
.then(() => googleApi.isSignedIn()) * Automatically selects the input field's value after starting to edit the
.then(isSignedIn => { * display name.
if (isSignedIn) { *
return this._onGetYouTubeBroadcasts(); * @inheritdoc
} * @returns {void}
}) */
.catch(() => { componentDidUpdate(previousProps) {
this._setStateIfMounted({ if (previousProps._googleAPIState === GOOGLE_API_STATES.LOADED
googleAPIState: GOOGLE_API_STATES.ERROR && this.props._googleAPIState === GOOGLE_API_STATES.SIGNED_IN) {
}); this._onGetYouTubeBroadcasts();
}); }
} }
_onGetYouTubeBroadcasts: () => Promise<*>; _onGetYouTubeBroadcasts: () => Promise<*>;
@ -84,42 +90,39 @@ class StartLiveStreamDialog
* list of the user's YouTube broadcasts. * list of the user's YouTube broadcasts.
* *
* @private * @private
* @returns {Promise} * @returns {void}
*/ */
_onGetYouTubeBroadcasts() { _onGetYouTubeBroadcasts() {
return googleApi.get() this.props.dispatch(updateProfile())
.then(() => googleApi.signInIfNotSignedIn()) .catch(response => this._parseErrorFromResponse(response));
.then(() => googleApi.getCurrentUserProfile())
.then(profile => {
this._setStateIfMounted({
googleProfileEmail: profile.getEmail(),
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
});
})
.then(() => googleApi.requestAvailableYouTubeBroadcasts())
.then(response => {
const broadcasts = this._parseBroadcasts(response.result.items);
this.props.dispatch(requestAvailableYouTubeBroadcasts())
.then(broadcasts => {
this._setStateIfMounted({ this._setStateIfMounted({
broadcasts broadcasts
}); });
if (broadcasts.length === 1 && !this.state.streamKey) { if (broadcasts.length === 1) {
const broadcast = broadcasts[0]; const broadcast = broadcasts[0];
this._onYouTubeBroadcastIDSelected(broadcast.boundStreamID); this._onYouTubeBroadcastIDSelected(broadcast.boundStreamID);
} }
}) })
.catch(response => { .catch(response => this._parseErrorFromResponse(response));
// Only show an error if an external request was made with the }
// Google api. Do not error if the login in canceled.
if (response && response.result) { _onGoogleSignIn: () => Object;
this._setStateIfMounted({
errorType: this._parseErrorFromResponse(response), /**
googleAPIState: GOOGLE_API_STATES.ERROR * Forces the Google web client application to prompt for a sign in, such as
}); * when changing account, and will then fetch available YouTube broadcasts.
} *
}); * @private
* @returns {Promise}
*/
_onGoogleSignIn() {
this.props.dispatch(signIn())
.catch(response => this._parseErrorFromResponse(response));
} }
_onRequestGoogleSignIn: () => Object; _onRequestGoogleSignIn: () => Object;
@ -132,8 +135,14 @@ class StartLiveStreamDialog
* @returns {Promise} * @returns {Promise}
*/ */
_onRequestGoogleSignIn() { _onRequestGoogleSignIn() {
return googleApi.showAccountSelection() // when there is an error we show the google sign-in button.
.then(() => this._setStateIfMounted({ broadcasts: undefined })) // once we click it we want to clear the error from the state
this.props.dispatch(showAccountSelection())
.then(() =>
this._setStateIfMounted({
broadcasts: undefined,
errorType: undefined
}))
.then(() => this._onGetYouTubeBroadcasts()); .then(() => this._onGetYouTubeBroadcasts());
} }
@ -151,55 +160,20 @@ class StartLiveStreamDialog
* @returns {Promise} * @returns {Promise}
*/ */
_onYouTubeBroadcastIDSelected(boundStreamID) { _onYouTubeBroadcastIDSelected(boundStreamID) {
return googleApi.requestLiveStreamsForYouTubeBroadcast(boundStreamID) this.props.dispatch(
.then(response => { requestLiveStreamsForYouTubeBroadcast(boundStreamID))
const broadcasts = response.result.items; .then(({ streamKey, selectedBoundStreamID }) =>
const streamName = broadcasts
&& broadcasts[0]
&& broadcasts[0].cdn.ingestionInfo.streamName;
const streamKey = streamName || '';
this._setStateIfMounted({ this._setStateIfMounted({
streamKey, streamKey,
selectedBoundStreamID: boundStreamID selectedBoundStreamID
}); }));
});
}
_parseBroadcasts: (Array<Object>) => Array<Object>;
/**
* Takes in a list of broadcasts from the YouTube API, removes dupes,
* removes broadcasts that cannot get a stream key, and parses the
* broadcasts into flat objects.
*
* @param {Array} broadcasts - Broadcast descriptions as obtained from
* calling the YouTube API.
* @private
* @returns {Array} An array of objects describing each unique broadcast.
*/
_parseBroadcasts(broadcasts) {
const parsedBroadcasts = {};
for (let i = 0; i < broadcasts.length; i++) {
const broadcast = broadcasts[i];
const boundStreamID = broadcast.contentDetails.boundStreamId;
if (boundStreamID && !parsedBroadcasts[boundStreamID]) {
parsedBroadcasts[boundStreamID] = {
boundStreamID,
id: broadcast.id,
status: broadcast.status.lifeCycleStatus,
title: broadcast.snippet.title
};
}
}
return Object.values(parsedBroadcasts);
} }
/** /**
* Searches in a Google API error response for the error type. * Only show an error if an external request was made with the Google api.
* Do not error if the login in canceled.
* And searches in a Google API error response for the error type.
* *
* @param {Object} response - The Google API response that may contain an * @param {Object} response - The Google API response that may contain an
* error. * error.
@ -207,12 +181,19 @@ class StartLiveStreamDialog
* @returns {string|null} * @returns {string|null}
*/ */
_parseErrorFromResponse(response) { _parseErrorFromResponse(response) {
if (!response || !response.result) {
return;
}
const result = response.result; const result = response.result;
const error = result.error; const error = result.error;
const errors = error && error.errors; const errors = error && error.errors;
const firstError = errors && errors[0]; const firstError = errors && errors[0];
return (firstError && firstError.reason) || null; this._setStateIfMounted({
errorType: (firstError && firstError.reason) || null
});
} }
_renderDialogContent: () => React$Component<*> _renderDialogContent: () => React$Component<*>
@ -243,20 +224,22 @@ class StartLiveStreamDialog
* @returns {ReactElement} * @returns {ReactElement}
*/ */
_renderYouTubePanel() { _renderYouTubePanel() {
const { t } = this.props; const {
t,
_googleProfileEmail
} = this.props;
const { const {
broadcasts, broadcasts,
googleProfileEmail,
selectedBoundStreamID selectedBoundStreamID
} = this.state; } = this.state;
let googleContent, helpText; let googleContent, helpText;
switch (this.state.googleAPIState) { switch (this.props._googleAPIState) {
case GOOGLE_API_STATES.LOADED: case GOOGLE_API_STATES.LOADED:
googleContent = ( // eslint-disable-line no-extra-parens googleContent = ( // eslint-disable-line no-extra-parens
<GoogleSignInButton <GoogleSignInButton
onClick = { this._onGetYouTubeBroadcasts } onClick = { this._onGoogleSignIn }
text = { t('liveStreaming.signIn') } /> text = { t('liveStreaming.signIn') } />
); );
helpText = t('liveStreaming.signInCTA'); helpText = t('liveStreaming.signInCTA');
@ -279,7 +262,7 @@ class StartLiveStreamDialog
helpText = ( // eslint-disable-line no-extra-parens helpText = ( // eslint-disable-line no-extra-parens
<div> <div>
{ `${t('liveStreaming.chooseCTA', { `${t('liveStreaming.chooseCTA',
{ email: googleProfileEmail })} ` } { email: _googleProfileEmail })} ` }
<a onClick = { this._onRequestGoogleSignIn }> <a onClick = { this._onRequestGoogleSignIn }>
{ t('liveStreaming.changeSignIn') } { t('liveStreaming.changeSignIn') }
</a> </a>
@ -288,16 +271,6 @@ class StartLiveStreamDialog
break; break;
case GOOGLE_API_STATES.ERROR:
googleContent = ( // eslint-disable-line no-extra-parens
<GoogleSignInButton
onClick = { this._onRequestGoogleSignIn }
text = { t('liveStreaming.signIn') } />
);
helpText = this._getGoogleErrorMessageToDisplay();
break;
case GOOGLE_API_STATES.NEEDS_LOADING: case GOOGLE_API_STATES.NEEDS_LOADING:
default: default:
googleContent = ( // eslint-disable-line no-extra-parens googleContent = ( // eslint-disable-line no-extra-parens
@ -309,6 +282,15 @@ class StartLiveStreamDialog
break; break;
} }
if (this.state.errorType !== undefined) {
googleContent = ( // eslint-disable-line no-extra-parens
<GoogleSignInButton
onClick = { this._onRequestGoogleSignIn }
text = { t('liveStreaming.signIn') } />
);
helpText = this._getGoogleErrorMessageToDisplay();
}
return ( return (
<div className = 'google-panel'> <div className = 'google-panel'>
<div className = 'live-stream-cta'> <div className = 'live-stream-cta'>
@ -336,7 +318,7 @@ class StartLiveStreamDialog
case 'liveStreamingNotEnabled': case 'liveStreamingNotEnabled':
text = this.props.t( text = this.props.t(
'liveStreaming.errorLiveStreamNotEnabled', 'liveStreaming.errorLiveStreamNotEnabled',
{ email: this.state.googleProfileEmail }); { email: this.props._googleProfileEmail });
break; break;
default: default:
text = this.props.t('liveStreaming.errorAPI'); text = this.props.t('liveStreaming.errorAPI');

View File

@ -1,14 +1,5 @@
// @flow // @flow
/**
* The Google API scopes to request access to for streaming.
*
* @type {Array<string>}
*/
export const GOOGLE_API_SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly'
];
/** /**
* The identifier of the sound to be played when a recording or live streaming * The identifier of the sound to be played when a recording or live streaming
* session is stopped. * session is stopped.