diff --git a/config.js b/config.js index ec907da95..5b60f4856 100644 --- a/config.js +++ b/config.js @@ -256,6 +256,10 @@ var config = { // maintenance at 01:00 AM GMT, // noticeMessage: '', + // Enables calendar integration, depends on googleApiApplicationClientID + // and microsoftApiApplicationClientID + // enableCalendarIntegration: false, + // Stats // @@ -398,6 +402,7 @@ var config = { googleApiApplicationClientID iAmRecorder iAmSipGateway + microsoftApiApplicationClientID peopleSearchQueryTypes peopleSearchUrl requireDisplayName diff --git a/css/_recording.scss b/css/_recording.scss index dfd578299..848f185c8 100644 --- a/css/_recording.scss +++ b/css/_recording.scss @@ -34,39 +34,6 @@ color: $errorColor; } - /** - * The Google sign in button must follow Google's design guidelines. - * See: https://developers.google.com/identity/branding-guidelines - */ - .google-sign-in { - background-color: #4285f4; - border-radius: 2px; - cursor: pointer; - display: inline-flex; - font-family: Roboto, arial, sans-serif; - font-size: 14px; - padding: 1px; - - .google-cta { - color: white; - display: inline-block; - /** - * Hack the line height for vertical centering of text. - */ - line-height: 32px; - margin: 0 15px; - } - - .google-logo { - background-color: white; - border-radius: 2px; - display: inline-block; - padding: 8px; - height: 18px; - width: 18px; - } - } - .google-panel { align-items: center; border-bottom: 2px solid rgba(0, 0, 0, 0.3); diff --git a/css/main.scss b/css/main.scss index bbe497d36..f1911f51a 100644 --- a/css/main.scss +++ b/css/main.scss @@ -82,4 +82,7 @@ @import 'deep-linking/main'; @import 'transcription-subtitles'; @import 'navigate_section_list'; +@import 'third-party-branding/google'; +@import 'third-party-branding/microsoft'; + /* Modules END */ diff --git a/css/modals/settings/_settings.scss b/css/modals/settings/_settings.scss index cd84fb0c7..9a81dffa5 100644 --- a/css/modals/settings/_settings.scss +++ b/css/modals/settings/_settings.scss @@ -10,6 +10,7 @@ margin-bottom: 4px; } + .calendar-tab, .device-selection { margin-top: 20px; } @@ -22,6 +23,7 @@ padding: 20px 0px 4px 0px; } + .calendar-tab, .more-tab, .profile-edit { display: flex; @@ -40,4 +42,20 @@ .language-settings { max-width: 50%; } + + .calendar-tab { + align-items: center; + flex-direction: column; + font-size: 14px; + min-height: 100px; + text-align: center; + } + + .calendar-tab-sign-in { + margin-top: 20px; + } + + .sign-out-cta { + margin-bottom: 20px; + } } diff --git a/css/third-party-branding/google.scss b/css/third-party-branding/google.scss new file mode 100644 index 000000000..10d96d3d0 --- /dev/null +++ b/css/third-party-branding/google.scss @@ -0,0 +1,32 @@ +/** + * The Google sign in button must follow Google's design guidelines. + * See: https://developers.google.com/identity/branding-guidelines + */ +.google-sign-in { + background-color: #4285f4; + border-radius: 2px; + cursor: pointer; + display: inline-flex; + font-family: Roboto, arial, sans-serif; + font-size: 14px; + padding: 1px; + + .google-cta { + color: white; + display: inline-block; + /** + * Hack the line height for vertical centering of text. + */ + line-height: 32px; + margin: 0 15px; + } + + .google-logo { + background-color: white; + border-radius: 2px; + display: inline-block; + padding: 8px; + height: 18px; + width: 18px; + } +} diff --git a/css/third-party-branding/microsoft.scss b/css/third-party-branding/microsoft.scss new file mode 100644 index 000000000..47ea5e8d8 --- /dev/null +++ b/css/third-party-branding/microsoft.scss @@ -0,0 +1,28 @@ +/** + * The Microsoft sign in button must follow Microsoft's brand guidelines. + * See: https://docs.microsoft.com/en-us/azure/active-directory/ + * develop/active-directory-branding-guidelines + */ +.microsoft-sign-in { + align-items: center; + background: #FFFFFF; + border: 1px solid #8C8C8C; + box-sizing: border-box; + cursor: pointer; + display: inline-flex; + font-family: Segoe UI, Roboto, arial, sans-serif; + height: 41px; + padding: 12px; + + .microsoft-cta { + display: inline-block; + color: #5E5E5E; + font-size: 15px; + line-height: 41px; + } + + .microsoft-logo { + display: inline-block; + margin-right: 12px; + } +} diff --git a/images/microsoftLogo.svg b/images/microsoftLogo.svg new file mode 100644 index 000000000..1f7397648 --- /dev/null +++ b/images/microsoftLogo.svg @@ -0,0 +1 @@ +MS-SymbolLockup \ No newline at end of file diff --git a/lang/main.json b/lang/main.json index 0a7fde50c..7e0f16b0e 100644 --- a/lang/main.json +++ b/lang/main.json @@ -157,8 +157,14 @@ }, "messagebox": "Enter text..." }, - "settings": - { + "settings": { + "calendar": { + "about": "The __appName__ calendar integration is used to securely access your calendar so it can read upcoming events.", + "disconnect": "Disconnect", + "microsoftSignIn": "Sign in with Microsoft", + "signedIn": "Currently accessing calendar events for __email__. Click the Disconnect button below to stop accessing calendar events.", + "title": "Calendar" + }, "title": "Settings", "update": "Update", "name": "Name", diff --git a/package-lock.json b/package-lock.json index 94d5d54d7..9b45072fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3003,6 +3003,15 @@ "sdp-transform": "2.3.0" } }, + "@microsoft/microsoft-graph-client": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-1.1.0.tgz", + "integrity": "sha512-sDgchKZz1l3QJVNdkE1P1KpwTjupNt1mS9h1T0CiP+ayMN7IeFKfElB8IYtxFplNalZTmEq+iqoQFqUVpVMLfQ==", + "requires": { + "es6-promise": "^4.1.0", + "isomorphic-fetch": "^2.2.1" + } + }, "@webcomponents/url": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@webcomponents/url/-/url-0.7.1.tgz", @@ -6263,6 +6272,11 @@ "event-emitter": "~0.3.5" } }, + "es6-promise": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.4.tgz", + "integrity": "sha512-/NdNZVJg+uZgtm9eS3O6lrOLYmQag2DjdEXuPaHlZ6RuVqgqaVZfgYCepEIKsLqwdQArOPtC3XzRLqGGfT8KQQ==" + }, "es6-set": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz", @@ -9643,6 +9657,11 @@ "verror": "1.10.0" } }, + "jsrsasign": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-8.0.12.tgz", + "integrity": "sha1-Iqu5ZW00owuVMENnIINeicLlwxY=" + }, "jssha": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/jssha/-/jssha-2.3.1.tgz", diff --git a/package.json b/package.json index 774a7789a..3cd8c643b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@atlaskit/tabs": "4.0.1", "@atlaskit/theme": "2.4.0", "@atlaskit/tooltip": "9.1.1", + "@microsoft/microsoft-graph-client": "1.1.0", "@webcomponents/url": "0.7.1", "autosize": "1.18.13", "i18next": "8.4.3", @@ -46,6 +47,7 @@ "jquery-i18next": "1.2.0", "js-md5": "0.6.1", "jsc-android": "224109.1.0", + "jsrsasign": "8.0.12", "jwt-decode": "2.2.0", "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#4a28a196160411d657518022de8bded7c02ad679", "libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d", diff --git a/react/features/base/dialog/components/DialogWithTabs.web.js b/react/features/base/dialog/components/DialogWithTabs.web.js index 217926ecf..49d010169 100644 --- a/react/features/base/dialog/components/DialogWithTabs.web.js +++ b/react/features/base/dialog/components/DialogWithTabs.web.js @@ -212,7 +212,7 @@ class DialogWithTabs extends Component { const { onSubmit, tabs } = this.props; tabs.forEach(({ submit }, idx) => { - submit(this.state.tabStates[idx]); + submit && submit(this.state.tabStates[idx]); }); onSubmit(); diff --git a/react/features/calendar-sync/actionTypes.js b/react/features/calendar-sync/actionTypes.js index a458513aa..50cb9f8f6 100644 --- a/react/features/calendar-sync/actionTypes.js +++ b/react/features/calendar-sync/actionTypes.js @@ -1,5 +1,15 @@ // @flow +/** + * Resets the state of calendar integration so stored events and selected + * calendar type are cleared. + * + * { + * type: CLEAR_CALENDAR_INTEGRATION + * } + */ +export const CLEAR_CALENDAR_INTEGRATION = Symbol('CLEAR_CALENDAR_INTEGRATION'); + /** * Action to refresh (re-fetch) the entry list. * @@ -32,3 +42,35 @@ export const SET_CALENDAR_AUTHORIZATION = Symbol('SET_CALENDAR_AUTHORIZATION'); * } */ export const SET_CALENDAR_EVENTS = Symbol('SET_CALENDAR_EVENTS'); + +/** + * Action to update calendar type to be used for web. + * + * { + * type: SET_CALENDAR_INTEGRATION, + * integrationReady: boolean, + * integrationType: string + * } + */ +export const SET_CALENDAR_INTEGRATION = Symbol('SET_CALENDAR_INTEGRATION'); + +/** + * The type of Redux action which changes Calendar API auth state. + * + * { + * type: SET_CALENDAR_AUTH_STATE + * } + * @public + */ +export const SET_CALENDAR_AUTH_STATE = Symbol('SET_CALENDAR_AUTH_STATE'); + +/** + * The type of Redux action which changes Calendar Profile email state. + * + * { + * type: SET_CALENDAR_PROFILE_EMAIL, + * email: string + * } + * @public + */ +export const SET_CALENDAR_PROFILE_EMAIL = Symbol('SET_CALENDAR_PROFILE_EMAIL'); diff --git a/react/features/calendar-sync/actions.js b/react/features/calendar-sync/actions.js index ac0276d15..a1d9c79ff 100644 --- a/react/features/calendar-sync/actions.js +++ b/react/features/calendar-sync/actions.js @@ -1,10 +1,87 @@ // @flow +import { loadGoogleAPI } from '../google-api'; + import { + CLEAR_CALENDAR_INTEGRATION, REFRESH_CALENDAR, + SET_CALENDAR_AUTH_STATE, SET_CALENDAR_AUTHORIZATION, - SET_CALENDAR_EVENTS + SET_CALENDAR_EVENTS, + SET_CALENDAR_INTEGRATION, + SET_CALENDAR_PROFILE_EMAIL } from './actionTypes'; +import { _getCalendarIntegration, isCalendarEnabled } from './functions'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * Sets the initial state of calendar integration by loading third party APIs + * and filling out any data that needs to be fetched. + * + * @returns {Function} + */ +export function bootstrapCalendarIntegration(): Function { + return (dispatch, getState) => { + const { + googleApiApplicationClientID + } = getState()['features/base/config']; + const { + integrationReady, + integrationType + } = getState()['features/calendar-sync']; + + if (!isCalendarEnabled()) { + return Promise.reject(); + } + + return Promise.resolve() + .then(() => { + if (googleApiApplicationClientID) { + return dispatch( + loadGoogleAPI(googleApiApplicationClientID)); + } + }) + .then(() => { + if (!integrationType || integrationReady) { + return; + } + + const integrationToLoad + = _getCalendarIntegration(integrationType); + + if (!integrationToLoad) { + dispatch(clearCalendarIntegration()); + + return; + } + + return dispatch(integrationToLoad._isSignedIn()) + .then(signedIn => { + if (signedIn) { + dispatch(setIntegrationReady(integrationType)); + dispatch(updateProfile(integrationType)); + } else { + dispatch(clearCalendarIntegration()); + } + }); + }); + }; +} + +/** + * Resets the state of calendar integration so stored events and selected + * calendar type are cleared. + * + * @returns {{ + * type: CLEAR_CALENDAR_INTEGRATION + * }} + */ +export function clearCalendarIntegration() { + return { + type: CLEAR_CALENDAR_INTEGRATION + }; +} /** * Sends an action to refresh the entry list (fetches new data). @@ -28,6 +105,23 @@ export function refreshCalendar( }; } +/** + * Sends an action to update the current calendar api auth state in redux. + * This is used only for microsoft implementation to store it auth state. + * + * @param {number} newState - The new state. + * @returns {{ + * type: SET_CALENDAR_AUTH_STATE, + * msAuthState: Object + * }} + */ +export function setCalendarAPIAuthState(newState: ?Object) { + return { + type: SET_CALENDAR_AUTH_STATE, + msAuthState: newState + }; +} + /** * Sends an action to signal that a calendar access has been requested. For more * info, see {@link SET_CALENDAR_AUTHORIZATION}. @@ -61,3 +155,90 @@ export function setCalendarEvents(events: Array) { events }; } + +/** + * Sends an action to update the current calendar profile email state in redux. + * + * @param {number} newEmail - The new email. + * @returns {{ + * type: SET_CALENDAR_PROFILE_EMAIL, + * email: string + * }} + */ +export function setCalendarProfileEmail(newEmail: ?string) { + return { + type: SET_CALENDAR_PROFILE_EMAIL, + email: newEmail + }; +} + +/** + * Sets the calendar integration type to be used by web and signals that the + * integration is ready to be used. + * + * @param {string|undefined} integrationType - The calendar type. + * @returns {{ + * type: SET_CALENDAR_INTEGRATION, + * integrationReady: boolean, + * integrationType: string + * }} + */ +export function setIntegrationReady(integrationType: string) { + return { + type: SET_CALENDAR_INTEGRATION, + integrationReady: true, + integrationType + }; +} + +/** + * Signals signing in to the specified calendar integration. + * + * @param {string} calendarType - The calendar integration which should be + * signed into. + * @returns {Function} + */ +export function signIn(calendarType: string): Function { + return (dispatch: Dispatch<*>) => { + const integration = _getCalendarIntegration(calendarType); + + if (!integration) { + return Promise.reject('No supported integration found'); + } + + return dispatch(integration.load()) + .then(() => dispatch(integration.signIn())) + .then(() => dispatch(setIntegrationReady(calendarType))) + .then(() => dispatch(updateProfile(calendarType))) + .catch(error => { + logger.error( + 'Error occurred while signing into calendar integration', + error); + + return Promise.reject(error); + }); + }; +} + +/** + * Signals to get current profile data linked to the current calendar + * integration that is in use. + * + * @param {string} calendarType - The calendar integration to which the profile + * should be updated. + * @returns {Function} + */ +export function updateProfile(calendarType: string): Function { + return (dispatch: Dispatch<*>) => { + const integration = _getCalendarIntegration(calendarType); + + if (!integration) { + return Promise.reject('No integration found'); + } + + return dispatch(integration.getCurrentEmail()) + .then(email => { + dispatch(setCalendarProfileEmail(email)); + }); + }; +} diff --git a/react/features/calendar-sync/components/ConferenceNotification.native.js b/react/features/calendar-sync/components/ConferenceNotification.native.js index ab1023a44..a2ce0f2f6 100644 --- a/react/features/calendar-sync/components/ConferenceNotification.native.js +++ b/react/features/calendar-sync/components/ConferenceNotification.native.js @@ -10,7 +10,7 @@ import { Icon } from '../../base/font-icons'; import { getLocalizedDateFormatter, translate } from '../../base/i18n'; import { ASPECT_RATIO_NARROW } from '../../base/responsive-ui'; -import { CALENDAR_ENABLED } from '../constants'; +import { isCalendarEnabled } from '../functions'; import styles from './styles'; const ALERT_MILLISECONDS = 5 * 60 * 1000; @@ -293,6 +293,6 @@ function _mapStateToProps(state: Object) { }; } -export default CALENDAR_ENABLED +export default isCalendarEnabled() ? translate(connect(_mapStateToProps)(ConferenceNotification)) : undefined; diff --git a/react/features/recording/components/LiveStream/GoogleSignInButton.native.js b/react/features/calendar-sync/components/ConferenceNotification.web.js similarity index 100% rename from react/features/recording/components/LiveStream/GoogleSignInButton.native.js rename to react/features/calendar-sync/components/ConferenceNotification.web.js diff --git a/react/features/calendar-sync/components/MeetingList.native.js b/react/features/calendar-sync/components/MeetingList.native.js index fb5ecb7b5..a8448f7ea 100644 --- a/react/features/calendar-sync/components/MeetingList.native.js +++ b/react/features/calendar-sync/components/MeetingList.native.js @@ -10,7 +10,7 @@ import { NavigateSectionList } from '../../base/react'; import { openSettings } from '../../mobile/permissions'; import { refreshCalendar } from '../actions'; -import { CALENDAR_ENABLED } from '../constants'; +import { isCalendarEnabled } from '../functions'; import styles from './styles'; /** @@ -275,6 +275,6 @@ function _mapStateToProps(state: Object) { }; } -export default CALENDAR_ENABLED +export default isCalendarEnabled() ? translate(connect(_mapStateToProps)(MeetingList)) : undefined; diff --git a/react/features/calendar-sync/components/MeetingList.web.js b/react/features/calendar-sync/components/MeetingList.web.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/calendar-sync/components/MicrosoftSignInButton.native.js b/react/features/calendar-sync/components/MicrosoftSignInButton.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/calendar-sync/components/MicrosoftSignInButton.web.js b/react/features/calendar-sync/components/MicrosoftSignInButton.web.js new file mode 100644 index 000000000..54c1d1f4c --- /dev/null +++ b/react/features/calendar-sync/components/MicrosoftSignInButton.web.js @@ -0,0 +1,44 @@ +// @flow + +import React, { Component } from 'react'; + +/** + * The type of the React {@code Component} props of + * {@link MicrosoftSignInButton}. + */ +type Props = { + + // The callback to invoke when {@code MicrosoftSignInButton} is clicked. + onClick: Function, + + // The text to display within {@code MicrosoftSignInButton}. + text: string +}; + +/** + * A React Component showing a button to sign in with Microsoft. + * + * @extends Component + */ +export default class MicrosoftSignInButton extends Component { + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( +
+ +
+ { this.props.text } +
+
+ ); + } +} diff --git a/react/features/calendar-sync/components/index.js b/react/features/calendar-sync/components/index.js index 0e413c388..af957a46e 100644 --- a/react/features/calendar-sync/components/index.js +++ b/react/features/calendar-sync/components/index.js @@ -1,2 +1,3 @@ export { default as ConferenceNotification } from './ConferenceNotification'; export { default as MeetingList } from './MeetingList'; +export { default as MicrosoftSignInButton } from './MicrosoftSignInButton'; diff --git a/react/features/calendar-sync/constants.js b/react/features/calendar-sync/constants.js index b7743ecd0..966ffb6cb 100644 --- a/react/features/calendar-sync/constants.js +++ b/react/features/calendar-sync/constants.js @@ -1,37 +1,26 @@ // @flow -import { NativeModules } from 'react-native'; - /** - * The indicator which determines whether the calendar feature is enabled by the - * app. + * An enumeration of support calendar integration types. * - * @type {boolean} + * @enum {string} */ -export const CALENDAR_ENABLED = _isCalendarEnabled(); - -/** - * The default state of the calendar. - * - * NOTE: This is defined here, to be reusable by functions.js as well (see file - * for details). - */ -export const DEFAULT_STATE = { - authorization: undefined, - events: [] +export const CALENDAR_TYPE = { + GOOGLE: 'google', + MICROSOFT: 'microsoft' }; /** - * Determines whether the calendar feature is enabled by the app. For - * example, Apple through its App Store requires - * {@code NSCalendarsUsageDescription} in the app's Info.plist or App Store - * rejects the app. - * - * @returns {boolean} If the app has enabled the calendar feature, {@code true}; - * otherwise, {@code false}. + * The number of days to fetch. */ -function _isCalendarEnabled() { - const { calendarEnabled } = NativeModules.AppInfo; +export const FETCH_END_DAYS = 10; - return typeof calendarEnabled === 'undefined' ? true : calendarEnabled; -} +/** + * The number of days to go back when fetching. + */ +export const FETCH_START_DAYS = -1; + +/** + * The max number of events to fetch from the calendar. + */ +export const MAX_LIST_LENGTH = 10; diff --git a/react/features/calendar-sync/functions.any.js b/react/features/calendar-sync/functions.any.js new file mode 100644 index 000000000..5dbdba55b --- /dev/null +++ b/react/features/calendar-sync/functions.any.js @@ -0,0 +1,158 @@ +// @flow + +import md5 from 'js-md5'; + +import { setCalendarEvents } from './actions'; +import { APP_LINK_SCHEME, parseURIString } from '../base/util'; +import { MAX_LIST_LENGTH } from './constants'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * Updates the calendar entries in redux when new list is received. The feature + * calendar-sync doesn't display all calendar events, it displays unique + * title, URL, and start time tuples i.e. it doesn't display subsequent + * occurrences of recurring events, and the repetitions of events coming from + * multiple calendars. + * + * XXX The function's {@code this} is the redux store. + * + * @param {Array} events - The new event list. + * @private + * @returns {void} + */ +export function _updateCalendarEntries(events: Array) { + if (!events || !events.length) { + return; + } + + // eslint-disable-next-line no-invalid-this + const { dispatch, getState } = this; + const knownDomains = getState()['features/base/known-domains']; + const now = Date.now(); + const entryMap = new Map(); + + for (const event of events) { + const entry = _parseCalendarEntry(event, knownDomains); + + if (entry && entry.endDate > now) { + // As was stated above, we don't display subsequent occurrences of + // recurring events, and the repetitions of events coming from + // multiple calendars. + const key = md5.hex(JSON.stringify([ + + // Obviously, we want to display different conference/meetings + // URLs. URLs are the very reason why we implemented the feature + // calendar-sync in the first place. + entry.url, + + // We probably want to display one and the same URL to people if + // they have it under different titles in their Calendar. + // Because maybe they remember the title of the meeting, not the + // URL so they expect to see the title without realizing that + // they have the same URL already under a different title. + entry.title, + + // XXX Eventually, given that the URL and the title are the + // same, what sets one event apart from another is the start + // time of the day (note the use of toTimeString() bellow)! The + // day itself is not important because we don't want multiple + // occurrences of a recurring event or repetitions of an even + // from multiple calendars. + new Date(entry.startDate).toTimeString() + ])); + const existingEntry = entryMap.get(key); + + // We want only the earliest occurrence (which hasn't ended in the + // past, that is) of a recurring event. + if (!existingEntry || existingEntry.startDate > entry.startDate) { + entryMap.set(key, entry); + } + } + } + + dispatch( + setCalendarEvents( + Array.from(entryMap.values()) + .sort((a, b) => a.startDate - b.startDate) + .slice(0, MAX_LIST_LENGTH))); +} + +/** + * Updates the calendar entries in Redux when new list is received. + * + * @param {Object} event - An event returned from the native calendar. + * @param {Array} knownDomains - The known domain list. + * @private + * @returns {CalendarEntry} + */ +function _parseCalendarEntry(event, knownDomains) { + if (event) { + const url = _getURLFromEvent(event, knownDomains); + + if (url) { + const startDate = Date.parse(event.startDate); + const endDate = Date.parse(event.endDate); + + if (isNaN(startDate) || isNaN(endDate)) { + logger.warn( + 'Skipping invalid calendar event', + event.title, + event.startDate, + event.endDate + ); + } else { + return { + endDate, + id: event.id, + startDate, + title: event.title, + url + }; + } + } + } + + return null; +} + +/** + * Retrieves a Jitsi Meet URL from an event if present. + * + * @param {Object} event - The event to parse. + * @param {Array} knownDomains - The known domain names. + * @private + * @returns {string} + */ +function _getURLFromEvent(event, knownDomains) { + const linkTerminatorPattern = '[^\\s<>$]'; + const urlRegExp + = new RegExp( + `http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`, + 'gi'); + const schemeRegExp + = new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi'); + const fieldsToSearch = [ + event.title, + event.url, + event.location, + event.notes, + event.description + ]; + + for (const field of fieldsToSearch) { + if (typeof field === 'string') { + const matches = urlRegExp.exec(field) || schemeRegExp.exec(field); + + if (matches) { + const url = parseURIString(matches[0]); + + if (url) { + return url.toString(); + } + } + } + } + + return null; +} diff --git a/react/features/calendar-sync/functions.js b/react/features/calendar-sync/functions.js deleted file mode 100644 index b55ddfb3e..000000000 --- a/react/features/calendar-sync/functions.js +++ /dev/null @@ -1,18 +0,0 @@ -// @flow -import { toState } from '../base/redux'; - -import { CALENDAR_ENABLED, DEFAULT_STATE } from './constants'; - -/** - * Returns the calendar state, considering the enabled/disabled state of the - * feature. Since that is the normal Redux behaviour, this function will always - * return an object (the default state if the feature is disabled). - * - * @param {Object | Function} stateful - An object or a function that can be - * resolved to a Redux state by {@code toState}. - * @returns {Object} - */ -export function getCalendarState(stateful: Object | Function) { - return CALENDAR_ENABLED - ? toState(stateful)['features/calendar-sync'] : DEFAULT_STATE; -} diff --git a/react/features/calendar-sync/functions.native.js b/react/features/calendar-sync/functions.native.js new file mode 100644 index 000000000..a74e33c4e --- /dev/null +++ b/react/features/calendar-sync/functions.native.js @@ -0,0 +1,99 @@ +import { NativeModules } from 'react-native'; +import RNCalendarEvents from 'react-native-calendar-events'; + +import { setCalendarAuthorization } from './actions'; +import { FETCH_END_DAYS, FETCH_START_DAYS } from './constants'; +import { _updateCalendarEntries } from './functions'; + +export * from './functions.any'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +/** + * Determines whether the calendar feature is enabled by the app. For + * example, Apple through its App Store requires + * {@code NSCalendarsUsageDescription} in the app's Info.plist or App Store + * rejects the app. + * + * @returns {boolean} If the app has enabled the calendar feature, {@code true}; + * otherwise, {@code false}. + */ +export function isCalendarEnabled() { + const { calendarEnabled } = NativeModules.AppInfo; + + return typeof calendarEnabled === 'undefined' ? true : calendarEnabled; +} + +/** + * Reads the user's calendar and updates the stored entries if need be. + * + * @param {Object} store - The redux store. + * @param {boolean} maybePromptForPermission - Flag to tell the app if it should + * prompt for a calendar permission if it wasn't granted yet. + * @param {boolean|undefined} forcePermission - Whether to force to re-ask for + * the permission or not. + * @private + * @returns {void} + */ +export function _fetchCalendarEntries( + store, + maybePromptForPermission, + forcePermission) { + const { dispatch, getState } = store; + const promptForPermission + = (maybePromptForPermission + && !getState()['features/calendar-sync'].authorization) + || forcePermission; + + _ensureCalendarAccess(promptForPermission, dispatch) + .then(accessGranted => { + if (accessGranted) { + const startDate = new Date(); + const endDate = new Date(); + + startDate.setDate(startDate.getDate() + FETCH_START_DAYS); + endDate.setDate(endDate.getDate() + FETCH_END_DAYS); + + RNCalendarEvents.fetchAllEvents( + startDate.getTime(), + endDate.getTime(), + []) + .then(_updateCalendarEntries.bind(store)) + .catch(error => + logger.error('Error fetching calendar.', error)); + } else { + logger.warn('Calendar access not granted.'); + } + }) + .catch(reason => logger.error('Error accessing calendar.', reason)); +} + +/** + * Ensures calendar access if possible and resolves the promise if it's granted. + * + * @param {boolean} promptForPermission - Flag to tell the app if it should + * prompt for a calendar permission if it wasn't granted yet. + * @param {Function} dispatch - The Redux dispatch function. + * @private + * @returns {Promise} + */ +function _ensureCalendarAccess(promptForPermission, dispatch) { + return new Promise((resolve, reject) => { + RNCalendarEvents.authorizationStatus() + .then(status => { + if (status === 'authorized') { + resolve(true); + } else if (promptForPermission) { + RNCalendarEvents.authorizeEventStore() + .then(result => { + dispatch(setCalendarAuthorization(result)); + resolve(result === 'authorized'); + }) + .catch(reject); + } else { + resolve(false); + } + }) + .catch(reject); + }); +} diff --git a/react/features/calendar-sync/functions.web.js b/react/features/calendar-sync/functions.web.js new file mode 100644 index 000000000..0035d6939 --- /dev/null +++ b/react/features/calendar-sync/functions.web.js @@ -0,0 +1,93 @@ +// @flow + +export * from './functions.any'; + +import { + CALENDAR_TYPE, + FETCH_END_DAYS, + FETCH_START_DAYS +} from './constants'; +import { _updateCalendarEntries } from './functions'; +import { googleCalendarApi } from './web/googleCalendar'; +import { microsoftCalendarApi } from './web/microsoftCalendar'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +declare var config: Object; + +/** + * Determines whether the calendar feature is enabled by the web. + * + * @returns {boolean} If the app has enabled the calendar feature, {@code true}; + * otherwise, {@code false}. + */ +export function isCalendarEnabled() { + return Boolean( + config.enableCalendarIntegration + && (config.googleApiApplicationClientID + || config.microsoftApiApplicationClientID)); +} + +/* eslint-disable no-unused-vars */ +/** + * Reads the user's calendar and updates the stored entries if need be. + * + * @param {Object} store - The redux store. + * @param {boolean} maybePromptForPermission - Flag to tell the app if it should + * prompt for a calendar permission if it wasn't granted yet. + * @param {boolean|undefined} forcePermission - Whether to force to re-ask for + * the permission or not. + * @private + * @returns {void} + */ +export function _fetchCalendarEntries( + store, + maybePromptForPermission, + forcePermission) { + /* eslint-enable no-unused-vars */ + const { dispatch, getState } = store; + + const { integrationType } = getState()['features/calendar-sync']; + const integration = _getCalendarIntegration(integrationType); + + if (!integration) { + logger.debug('No calendar type available'); + + return; + } + + dispatch(integration.load()) + .then(() => dispatch(integration._isSignedIn())) + .then(signedIn => { + if (signedIn) { + return Promise.resolve(); + } + + return Promise.reject('Not authorized, please sign in!'); + }) + .then(() => dispatch(integration.getCalendarEntries( + FETCH_START_DAYS, FETCH_END_DAYS))) + .then(events => _updateCalendarEntries.call({ + dispatch, + getState + }, events)) + .catch(error => + logger.error('Error fetching calendar.', error)); +} + +/** + * Returns the calendar API implementation by specified type. + * + * @param {string} calendarType - The calendar type API as defined in + * the constant {@link CALENDAR_TYPE}. + * @private + * @returns {Object|undefined} + */ +export function _getCalendarIntegration(calendarType: string) { + switch (calendarType) { + case CALENDAR_TYPE.GOOGLE: + return googleCalendarApi; + case CALENDAR_TYPE.MICROSOFT: + return microsoftCalendarApi; + } +} diff --git a/react/features/calendar-sync/index.js b/react/features/calendar-sync/index.js index 65787823f..2ab7a37f6 100644 --- a/react/features/calendar-sync/index.js +++ b/react/features/calendar-sync/index.js @@ -1,5 +1,7 @@ +export * from './actions'; export * from './components'; -export * from './functions'; +export * from './constants'; +export { isCalendarEnabled } from './functions'; import './middleware'; import './reducer'; diff --git a/react/features/calendar-sync/middleware.js b/react/features/calendar-sync/middleware.js index 6fed0b8b3..f6e164159 100644 --- a/react/features/calendar-sync/middleware.js +++ b/react/features/calendar-sync/middleware.js @@ -1,36 +1,15 @@ // @flow -import md5 from 'js-md5'; -import RNCalendarEvents from 'react-native-calendar-events'; - -import { APP_WILL_MOUNT } from '../base/app'; +import { SET_CONFIG } from '../base/config'; import { ADD_KNOWN_DOMAINS, addKnownDomains } from '../base/known-domains'; -import { MiddlewareRegistry } from '../base/redux'; -import { APP_LINK_SCHEME, parseURIString } from '../base/util'; -import { APP_STATE_CHANGED } from '../mobile/background'; +import { equals, MiddlewareRegistry } from '../base/redux'; +import { APP_STATE_CHANGED } from '../mobile/background/actionTypes'; -import { setCalendarAuthorization, setCalendarEvents } from './actions'; +import { setCalendarAuthorization } from './actions'; import { REFRESH_CALENDAR } from './actionTypes'; -import { CALENDAR_ENABLED } from './constants'; +import { _fetchCalendarEntries, isCalendarEnabled } from './functions'; -const logger = require('jitsi-meet-logger').getLogger(__filename); - -/** - * The number of days to fetch. - */ -const FETCH_END_DAYS = 10; - -/** - * The number of days to go back when fetching. - */ -const FETCH_START_DAYS = -1; - -/** - * The max number of events to fetch from the calendar. - */ -const MAX_LIST_LENGTH = 10; - -CALENDAR_ENABLED +isCalendarEnabled() && MiddlewareRegistry.register(store => next => action => { switch (action.type) { case ADD_KNOWN_DOMAINS: { @@ -41,7 +20,8 @@ CALENDAR_ENABLED const result = next(action); const newValue = getState()['features/base/known-domains']; - oldValue === newValue || _fetchCalendarEntries(store, false, false); + equals(oldValue, newValue) + || _fetchCalendarEntries(store, false, false); return result; } @@ -54,7 +34,9 @@ CALENDAR_ENABLED return result; } - case APP_WILL_MOUNT: { + case SET_CONFIG: { + const result = next(action); + // For legacy purposes, we've allowed the deserialization of // knownDomains and now we're to translate it to base/known-domains. const state = store.getState()['features/calendar-sync']; @@ -69,7 +51,7 @@ CALENDAR_ENABLED _fetchCalendarEntries(store, false, false); - return next(action); + return result; } case REFRESH_CALENDAR: { @@ -85,121 +67,6 @@ CALENDAR_ENABLED return next(action); }); -/** - * Ensures calendar access if possible and resolves the promise if it's granted. - * - * @param {boolean} promptForPermission - Flag to tell the app if it should - * prompt for a calendar permission if it wasn't granted yet. - * @param {Function} dispatch - The Redux dispatch function. - * @private - * @returns {Promise} - */ -function _ensureCalendarAccess(promptForPermission, dispatch) { - return new Promise((resolve, reject) => { - RNCalendarEvents.authorizationStatus() - .then(status => { - if (status === 'authorized') { - resolve(true); - } else if (promptForPermission) { - RNCalendarEvents.authorizeEventStore() - .then(result => { - dispatch(setCalendarAuthorization(result)); - resolve(result === 'authorized'); - }) - .catch(reject); - } else { - resolve(false); - } - }) - .catch(reject); - }); -} - -/** - * Reads the user's calendar and updates the stored entries if need be. - * - * @param {Object} store - The redux store. - * @param {boolean} maybePromptForPermission - Flag to tell the app if it should - * prompt for a calendar permission if it wasn't granted yet. - * @param {boolean|undefined} forcePermission - Whether to force to re-ask for - * the permission or not. - * @private - * @returns {void} - */ -function _fetchCalendarEntries( - store, - maybePromptForPermission, - forcePermission) { - const { dispatch, getState } = store; - const promptForPermission - = (maybePromptForPermission - && !getState()['features/calendar-sync'].authorization) - || forcePermission; - - _ensureCalendarAccess(promptForPermission, dispatch) - .then(accessGranted => { - if (accessGranted) { - const startDate = new Date(); - const endDate = new Date(); - - startDate.setDate(startDate.getDate() + FETCH_START_DAYS); - endDate.setDate(endDate.getDate() + FETCH_END_DAYS); - - RNCalendarEvents.fetchAllEvents( - startDate.getTime(), - endDate.getTime(), - []) - .then(_updateCalendarEntries.bind(store)) - .catch(error => - logger.error('Error fetching calendar.', error)); - } else { - logger.warn('Calendar access not granted.'); - } - }) - .catch(reason => logger.error('Error accessing calendar.', reason)); -} - -/** - * Retrieves a Jitsi Meet URL from an event if present. - * - * @param {Object} event - The event to parse. - * @param {Array} knownDomains - The known domain names. - * @private - * @returns {string} - */ -function _getURLFromEvent(event, knownDomains) { - const linkTerminatorPattern = '[^\\s<>$]'; - const urlRegExp - = new RegExp( - `http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`, - 'gi'); - const schemeRegExp - = new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi'); - const fieldsToSearch = [ - event.title, - event.url, - event.location, - event.notes, - event.description - ]; - - for (const field of fieldsToSearch) { - if (typeof field === 'string') { - const matches = urlRegExp.exec(field) || schemeRegExp.exec(field); - - if (matches) { - const url = parseURIString(matches[0]); - - if (url) { - return url.toString(); - } - } - } - } - - return null; -} - /** * Clears the calendar access status when the app comes back from the * background. This is needed as some users may never quit the app, but puts it @@ -215,111 +82,3 @@ function _maybeClearAccessStatus(store, { appState }) { appState === 'background' && store.dispatch(setCalendarAuthorization(undefined)); } - -/** - * Updates the calendar entries in Redux when new list is received. - * - * @param {Object} event - An event returned from the native calendar. - * @param {Array} knownDomains - The known domain list. - * @private - * @returns {CalendarEntry} - */ -function _parseCalendarEntry(event, knownDomains) { - if (event) { - const url = _getURLFromEvent(event, knownDomains); - - if (url) { - const startDate = Date.parse(event.startDate); - const endDate = Date.parse(event.endDate); - - if (isNaN(startDate) || isNaN(endDate)) { - logger.warn( - 'Skipping invalid calendar event', - event.title, - event.startDate, - event.endDate - ); - } else { - return { - endDate, - id: event.id, - startDate, - title: event.title, - url - }; - } - } - } - - return null; -} - -/** - * Updates the calendar entries in redux when new list is received. The feature - * calendar-sync doesn't display all calendar events, it displays unique - * title, URL, and start time tuples i.e. it doesn't display subsequent - * occurrences of recurring events, and the repetitions of events coming from - * multiple calendars. - * - * XXX The function's {@code this} is the redux store. - * - * @param {Array} events - The new event list. - * @private - * @returns {void} - */ -function _updateCalendarEntries(events) { - if (!events || !events.length) { - return; - } - - // eslint-disable-next-line no-invalid-this - const { dispatch, getState } = this; - const knownDomains = getState()['features/base/known-domains']; - const now = Date.now(); - const entryMap = new Map(); - - for (const event of events) { - const entry = _parseCalendarEntry(event, knownDomains); - - if (entry && entry.endDate > now) { - // As was stated above, we don't display subsequent occurrences of - // recurring events, and the repetitions of events coming from - // multiple calendars. - const key = md5.hex(JSON.stringify([ - - // Obviously, we want to display different conference/meetings - // URLs. URLs are the very reason why we implemented the feature - // calendar-sync in the first place. - entry.url, - - // We probably want to display one and the same URL to people if - // they have it under different titles in their Calendar. - // Because maybe they remember the title of the meeting, not the - // URL so they expect to see the title without realizing that - // they have the same URL already under a different title. - entry.title, - - // XXX Eventually, given that the URL and the title are the - // same, what sets one event apart from another is the start - // time of the day (note the use of toTimeString() bellow)! The - // day itself is not important because we don't want multiple - // occurrences of a recurring event or repetitions of an even - // from multiple calendars. - new Date(entry.startDate).toTimeString() - ])); - const existingEntry = entryMap.get(key); - - // We want only the earliest occurrence (which hasn't ended in the - // past, that is) of a recurring event. - if (!existingEntry || existingEntry.startDate > entry.startDate) { - entryMap.set(key, entry); - } - } - } - - dispatch( - setCalendarEvents( - Array.from(entryMap.values()) - .sort((a, b) => a.startDate - b.startDate) - .slice(0, MAX_LIST_LENGTH))); -} diff --git a/react/features/calendar-sync/reducer.js b/react/features/calendar-sync/reducer.js index 65f33a632..9b2cb4804 100644 --- a/react/features/calendar-sync/reducer.js +++ b/react/features/calendar-sync/reducer.js @@ -5,21 +5,36 @@ import { ReducerRegistry, set } from '../base/redux'; import { PersistenceRegistry } from '../base/storage'; import { + CLEAR_CALENDAR_INTEGRATION, + SET_CALENDAR_AUTH_STATE, SET_CALENDAR_AUTHORIZATION, - SET_CALENDAR_EVENTS + SET_CALENDAR_EVENTS, + SET_CALENDAR_INTEGRATION, + SET_CALENDAR_PROFILE_EMAIL } from './actionTypes'; -import { CALENDAR_ENABLED, DEFAULT_STATE } from './constants'; +import { isCalendarEnabled } from './functions'; + +/** + * The default state of the calendar feature. + * + * @type {Object} + */ +const DEFAULT_STATE = { + authorization: undefined, + events: [], + integrationReady: false, + integrationType: undefined, + msAuthState: undefined +}; /** * Constant for the Redux subtree of the calendar feature. * - * NOTE: Please do not access this subtree directly outside of this feature. - * This feature can be disabled (see {@code constants.js} for details), and in - * that case, accessing this subtree directly will return undefined and will - * need a bunch of repetitive type checks in other features. Use the - * {@code getCalendarState} function instead, or make sure you take care of - * those checks, or consider using the {@code CALENDAR_ENABLED} const to gate - * features if needed. + * NOTE: This feature can be disabled and in that case, accessing this subtree + * directly will return undefined and will need a bunch of repetitive type + * checks in other features. Make sure you take care of those checks, or + * consider using the {@code isCalendarEnabled} value to gate features if + * needed. */ const STORE_NAME = 'features/calendar-sync'; @@ -31,12 +46,14 @@ const STORE_NAME = 'features/calendar-sync'; * runtime value to see if we need to re-request the calendar permission from * the user. */ -CALENDAR_ENABLED +isCalendarEnabled() && PersistenceRegistry.register(STORE_NAME, { - knownDomains: true + integrationType: true, + knownDomains: true, + msAuthState: true }); -CALENDAR_ENABLED +isCalendarEnabled() && ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STATE, action) => { switch (action.type) { case APP_WILL_MOUNT: @@ -49,11 +66,36 @@ CALENDAR_ENABLED } break; + case CLEAR_CALENDAR_INTEGRATION: + return DEFAULT_STATE; + + case SET_CALENDAR_AUTH_STATE: { + if (!action.msAuthState) { + // received request to delete the state + return set(state, 'msAuthState', undefined); + } + + return set(state, 'msAuthState', { + ...state.msAuthState, + ...action.msAuthState + }); + } + case SET_CALENDAR_AUTHORIZATION: return set(state, 'authorization', action.authorization); case SET_CALENDAR_EVENTS: return set(state, 'events', action.events); + + case SET_CALENDAR_INTEGRATION: + return { + ...state, + integrationReady: action.integrationReady, + integrationType: action.integrationType + }; + + case SET_CALENDAR_PROFILE_EMAIL: + return set(state, 'profileEmail', action.email); } return state; diff --git a/react/features/calendar-sync/web/googleCalendar.js b/react/features/calendar-sync/web/googleCalendar.js new file mode 100644 index 000000000..a19d6c4ff --- /dev/null +++ b/react/features/calendar-sync/web/googleCalendar.js @@ -0,0 +1,66 @@ +/* @flow */ + +import { + getCalendarEntries, + googleApi, + loadGoogleAPI, + signIn, + updateProfile +} from '../../google-api'; + +/** + * A stateless collection of action creators that implements the expected + * interface for interacting with the Google API in order to get calendar data. + * + * @type {Object} + */ +export const googleCalendarApi = { + /** + * Retrieves the current calendar events. + * + * @param {number} fetchStartDays - The number of days to go back + * when fetching. + * @param {number} fetchEndDays - The number of days to fetch. + * @returns {function(): Promise} + */ + getCalendarEntries, + + /** + * Returns the email address for the currently logged in user. + * + * @returns {function(Dispatch<*>): Promise} + */ + getCurrentEmail() { + return updateProfile(); + }, + + /** + * Initializes the google api if needed. + * + * @returns {function(Dispatch<*>, Function): Promise} + */ + load() { + return (dispatch: Dispatch<*>, getState: Function) => { + const { googleApiApplicationClientID } + = getState()['features/base/config']; + + return dispatch(loadGoogleAPI(googleApiApplicationClientID)); + }; + }, + + /** + * Prompts the participant to sign in to the Google API Client Library. + * + * @returns {function(Dispatch<*>): Promise} + */ + signIn, + + /** + * Returns whether or not the user is currently signed in. + * + * @returns {function(): Promise} + */ + _isSignedIn() { + return () => googleApi.isSignedIn(); + } +}; diff --git a/react/features/calendar-sync/web/microsoftCalendar.js b/react/features/calendar-sync/web/microsoftCalendar.js new file mode 100644 index 000000000..5062666cc --- /dev/null +++ b/react/features/calendar-sync/web/microsoftCalendar.js @@ -0,0 +1,531 @@ +/* @flow */ + +import { Client } from '@microsoft/microsoft-graph-client'; +import rs from 'jsrsasign'; + +import { createDeferred } from '../../../../modules/util/helpers'; + +import parseURLParams from '../../base/config/parseURLParams'; +import { parseStandardURIString } from '../../base/util'; + +import { setCalendarAPIAuthState } from '../actions'; + +/** + * Constants used for interacting with the Microsoft API. + * + * @private + * @type {object} + */ +const MS_API_CONFIGURATION = { + /** + * The URL to use when authenticating using Microsoft API. + * @type {string} + */ + AUTH_ENDPOINT: + 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?', + + CALENDAR_ENDPOINT: '/me/calendars', + + /** + * The Microsoft API scopes to request access for calendar. + * + * @type {string} + */ + MS_API_SCOPES: 'openid profile Calendars.Read', + + /** + * See https://docs.microsoft.com/en-us/azure/active-directory/develop/ + * v2-oauth2-implicit-grant-flow#send-the-sign-in-request. This value is + * needed for passing in the proper domain_hint value when trying to refresh + * a token silently. + * + * + * @type {string} + */ + MS_CONSUMER_TENANT: '9188040d-6c67-4c5b-b112-36a304b66dad', + + /** + * The redirect URL to be used by the Microsoft API on successful + * authentication. + * + * @type {string} + */ + REDIRECT_URI: `${window.location.origin}/static/msredirect.html` +}; + +/** + * Store the window from an auth request. That way it can be reused if a new + * request comes in and it can be used to indicate a request is in progress. + * + * @private + * @type {Object|null} + */ +let popupAuthWindow = null; + +/** + * A stateless collection of action creators that implements the expected + * interface for interacting with the Microsoft API in order to get calendar + * data. + * + * @type {Object} + */ +export const microsoftCalendarApi = { + /** + * Retrieves the current calendar events. + * + * @param {number} fetchStartDays - The number of days to go back + * when fetching. + * @param {number} fetchEndDays - The number of days to fetch. + * @returns {function(Dispatch<*>, Function): Promise} + */ + getCalendarEntries(fetchStartDays: ?number, fetchEndDays: ?number) { + return (dispatch: Dispatch<*>, getState: Function): Promise<*> => { + const state = getState()['features/calendar-sync'] || {}; + const token = state.msAuthState && state.msAuthState.accessToken; + + if (!token) { + return Promise.reject('Not authorized, please sign in!'); + } + + const client = Client.init({ + authProvider: done => done(null, token) + }); + + return client + .api(MS_API_CONFIGURATION.CALENDAR_ENDPOINT) + .get() + .then(response => { + const calendarIds = response.value.map(en => en.id); + const getEventsPromises = calendarIds.map(id => + requestCalendarEvents( + client, id, fetchStartDays, fetchEndDays)); + + return Promise.all(getEventsPromises); + }) + + // get .value of every element from the array of results, + // which is an array of events and flatten it to one array + // of events + .then(result => [].concat(...result.map(en => en.value))) + .then(entries => entries.map(e => formatCalendarEntry(e))); + }; + }, + + /** + * Returns the email address for the currently logged in user. + * + * @returns {function(Dispatch<*, Function>): Promise} + */ + getCurrentEmail(): Function { + return (dispatch: Dispatch<*>, getState: Function) => { + const { msAuthState = {} } + = getState()['features/calendar-sync'] || {}; + const email = msAuthState.userSigninName || ''; + + return Promise.resolve(email); + }; + }, + + /** + * Sets the application ID to use for interacting with the Microsoft API. + * + * @returns {function(): Promise} + */ + load(): Function { + return () => Promise.resolve(); + }, + + /** + * Prompts the participant to sign in to the Microsoft API Client Library. + * + * @returns {function(Dispatch<*>, Function): Promise} + */ + signIn(): Function { + return (dispatch: Dispatch<*>, getState: Function) => { + // Ensure only one popup window at a time. + if (popupAuthWindow) { + popupAuthWindow.focus(); + + return Promise.reject('Sign in already in progress.'); + } + + const signInDeferred = createDeferred(); + + const guids = { + authState: generateGuid(), + authNonce: generateGuid() + }; + + dispatch(setCalendarAPIAuthState(guids)); + + const { microsoftApiApplicationClientID } + = getState()['features/base/config']; + const authUrl = getAuthUrl( + microsoftApiApplicationClientID, + guids.authState, + guids.authNonce); + const h = 600; + const w = 480; + + popupAuthWindow = window.open( + authUrl, + 'Auth M$', + `width=${w}, height=${h}, top=${ + (screen.height / 2) - (h / 2)}, left=${ + (screen.width / 2) - (w / 2)}`); + + const windowCloseCheck = setInterval(() => { + if (popupAuthWindow && popupAuthWindow.closed) { + signInDeferred.reject( + 'Popup closed before completing auth.'); + popupAuthWindow = null; + window.removeEventListener('message', handleAuth); + clearInterval(windowCloseCheck); + } else if (!popupAuthWindow) { + // This case probably happened because the user completed + // auth. + clearInterval(windowCloseCheck); + } + }, 500); + + /** + * Callback with scope access to other variables that are part of + * the sign in request. + * + * @param {Object} event - The event from the post message. + * @private + * @returns {void} + */ + function handleAuth({ data }) { + if (!data || data.type !== 'ms-login') { + return; + } + + window.removeEventListener('message', handleAuth); + + popupAuthWindow && popupAuthWindow.close(); + popupAuthWindow = null; + + const params = getParamsFromHash(data.url); + const tokenParts = getValidatedTokenParts( + params, guids, microsoftApiApplicationClientID); + + if (!tokenParts) { + signInDeferred.reject('Invalid token received'); + + return; + } + + dispatch(setCalendarAPIAuthState({ + authState: undefined, + accessToken: tokenParts.accessToken, + idToken: tokenParts.idToken, + tokenExpires: params.tokenExpires, + userDomainType: tokenParts.userDomainType, + userSigninName: tokenParts.userSigninName + })); + + signInDeferred.resolve(); + } + + window.addEventListener('message', handleAuth); + + return signInDeferred.promise; + }; + }, + + /** + * Returns whether or not the user is currently signed in. + * + * @returns {function(Dispatch<*>, Function): Promise} + */ + _isSignedIn(): Function { + return (dispatch: Dispatch<*>, getState: Function) => { + const now = new Date().getTime(); + const state + = getState()['features/calendar-sync'].msAuthState || {}; + const tokenExpires = parseInt(state.tokenExpires, 10); + const isExpired = now > tokenExpires && !isNaN(tokenExpires); + + if (state.accessToken && isExpired) { + // token expired, let's refresh it + return dispatch(this._refreshAuthToken()) + .then(() => true) + .catch(() => false); + } + + return Promise.resolve(state.accessToken && !isExpired); + }; + }, + + /** + * Renews an existing auth token so it can continue to be used. + * + * @private + * @returns {function(Dispatch<*>, Function): Promise} + */ + _refreshAuthToken(): Function { + return (dispatch: Dispatch<*>, getState: Function) => { + const { microsoftApiApplicationClientID } + = getState()['features/base/config']; + const { msAuthState = {} } + = getState()['features/calendar-sync'] || {}; + + const refreshAuthUrl = getAuthRefreshUrl( + microsoftApiApplicationClientID, + msAuthState.userDomainType, + msAuthState.userSigninName); + + const iframe = document.createElement('iframe'); + + iframe.setAttribute('id', 'auth-iframe'); + iframe.setAttribute('name', 'auth-iframe'); + iframe.setAttribute('style', 'display: none'); + iframe.setAttribute('src', refreshAuthUrl); + + const signInPromise = new Promise(resolve => { + iframe.onload = () => { + resolve(iframe.contentWindow.location.hash); + }; + }); + + // The check for body existence is done for flow, which also runs + // against native where document.body may not be defined. + if (!document.body) { + return Promise.reject( + 'Cannot refresh auth token in this environment'); + } + + document.body.appendChild(iframe); + + return signInPromise.then(hash => { + const params = getParamsFromHash(hash); + + dispatch(setCalendarAPIAuthState({ + accessToken: params.access_token, + idToken: params.id_token, + tokenExpires: params.tokenExpires + })); + }); + }; + } +}; + +/** + * Parses the Microsoft calendar entries to a known format. + * + * @param {Object} entry - The Microsoft calendar entry. + * @private + * @returns {{ + * description: string, + * endDate: string, + * id: string, + * location: string, + * startDate: string, + * title: string + * }} + */ +function formatCalendarEntry(entry) { + return { + description: entry.body.content, + endDate: entry.end.dateTime, + id: entry.id, + location: entry.location.displayName, + startDate: entry.start.dateTime, + title: entry.subject + }; +} + +/** + * Generate a guid to be used for verifying token validity. + * + * @private + * @returns {string} The generated string. + */ +function generateGuid() { + const buf = new Uint16Array(8); + + window.crypto.getRandomValues(buf); + + return `${s4(buf[0])}${s4(buf[1])}-${s4(buf[2])}-${s4(buf[3])}-${ + s4(buf[4])}-${s4(buf[5])}${s4(buf[6])}${s4(buf[7])}`; +} + +/** + * Constructs and returns the URL to use for renewing an auth token. + * + * @param {string} appId - The Microsoft application id to log into. + * @param {string} userDomainType - The domain type of the application as + * provided by Microsoft. + * @param {string} userSigninName - The email of the user signed into the + * integration with Microsoft. + * @private + * @returns {string} - The auth URL. + */ +function getAuthRefreshUrl(appId, userDomainType, userSigninName) { + return [ + getAuthUrl(appId, 'undefined', 'undefined'), + 'prompt=none', + `domain_hint=${userDomainType}`, + `login_hint=${userSigninName}` + ].join('&'); +} + +/** + * Constructs and returns the auth URL to use for login. + * + * @param {string} appId - The Microsoft application id to log into. + * @param {string} authState - The authState guid to use. + * @param {string} authNonce - The authNonce guid to use. + * @private + * @returns {string} - The auth URL. + */ +function getAuthUrl(appId, authState, authNonce) { + const authParams = [ + 'response_type=id_token+token', + `client_id=${appId}`, + `redirect_uri=${MS_API_CONFIGURATION.REDIRECT_URI}`, + `scope=${MS_API_CONFIGURATION.MS_API_SCOPES}`, + `state=${authState}`, + `nonce=${authNonce}`, + 'response_mode=fragment' + ].join('&'); + + return `${MS_API_CONFIGURATION.AUTH_ENDPOINT}${authParams}`; +} + +/** + * Converts a url from an auth redirect into an object of parameters passed + * into the url. + * + * @param {string} url - The string to parse. + * @private + * @returns {Object} + */ +function getParamsFromHash(url) { + const params = parseURLParams(parseStandardURIString(url), true, 'hash'); + + // Get the number of seconds the token is valid for, subtract 5 minutes + // to account for differences in clock settings and convert to ms. + const expiresIn = (parseInt(params.expires_in, 10) - 300) * 1000; + const now = new Date(); + const expireDate = new Date(now.getTime() + expiresIn); + + params.tokenExpires = expireDate.getTime().toString(); + + return params; +} + +/** + * Converts the parameters from a Microsoft auth redirect into an object of + * token parts. The value "null" will be returned if the params do not produce + * a valid token. + * + * @param {Object} tokenInfo - The token object. + * @param {Object} guids - The guids for authState and authNonce that should + * match in the token. + * @param {Object} appId - The Microsoft application this token is for. + * @private + * @returns {Object|null} + */ +function getValidatedTokenParts(tokenInfo, guids, appId) { + // Make sure the token matches the request source by matching the GUID. + if (tokenInfo.state !== guids.authState) { + return null; + } + + const idToken = tokenInfo.id_token; + + // A token must exist to be valid. + if (!idToken) { + return null; + } + + const tokenParts = idToken.split('.'); + + if (tokenParts.length !== 3) { + return null; + } + + const payload + = rs.KJUR.jws.JWS.readSafeJSONString(rs.b64utoutf8(tokenParts[1])); + + if (payload.nonce !== guids.authNonce + || payload.aud !== appId + || payload.iss + !== `https://login.microsoftonline.com/${payload.tid}/v2.0`) { + return null; + } + + const now = new Date(); + + // Adjust by 5 minutes to allow for inconsistencies in system clocks. + const notBefore = new Date((payload.nbf - 300) * 1000); + const expires = new Date((payload.exp + 300) * 1000); + + if (now < notBefore || now > expires) { + return null; + } + + return { + accessToken: tokenInfo.access_token, + idToken, + userDisplayName: payload.name, + userDomainType: + payload.tid === MS_API_CONFIGURATION.MS_CONSUMER_TENANT + ? 'consumers' : 'organizations', + userSigninName: payload.preferred_username + }; +} + +/** + * Retrieves calendar entries from a specific calendar. + * + * @param {Object} client - The Microsoft-graph-client initialized. + * @param {string} calendarId - The calendar ID to use. + * @param {number} fetchStartDays - The number of days to go back + * when fetching. + * @param {number} fetchEndDays - The number of days to fetch. + * @returns {Promise | Promise} + * @private + */ +function requestCalendarEvents( // eslint-disable-line max-params + client, + calendarId, + fetchStartDays, + fetchEndDays): Promise<*> { + const startDate = new Date(); + const endDate = new Date(); + + startDate.setDate(startDate.getDate() + fetchStartDays); + endDate.setDate(endDate.getDate() + fetchEndDays); + + const filter = `Start/DateTime ge '${ + startDate.toISOString()}' and End/DateTime lt '${ + endDate.toISOString()}'`; + + return client + .api(`/me/calendars/${calendarId}/events`) + .filter(filter) + .select('id,subject,start,end,location,body') + .orderby('createdDateTime DESC') + .get(); +} + +/** + * Converts the passed in number to a string and ensure it is at least 4 + * characters in length, prepending 0's as needed. + * + * @param {number} num - The number to pad and convert to a string. + * @private + * @returns {string} - The number converted to a string. + */ +function s4(num) { + let ret = num.toString(16); + + while (ret.length < 4) { + ret = `0${ret}`; + } + + return ret; +} diff --git a/react/features/google-api/actions.js b/react/features/google-api/actions.js index a01d24f2d..2897f6084 100644 --- a/react/features/google-api/actions.js +++ b/react/features/google-api/actions.js @@ -7,6 +7,21 @@ import { import { GOOGLE_API_STATES } from './constants'; import googleApi from './googleApi'; +/** + * Retrieves the current calendar events. + * + * @param {number} fetchStartDays - The number of days to go back when fetching. + * @param {number} fetchEndDays - The number of days to fetch. + * @returns {function(Dispatch<*>): Promise} + */ +export function getCalendarEntries( + fetchStartDays: ?number, fetchEndDays: ?number) { + return () => + googleApi.get() + .then(() => + googleApi._getCalendarEntries(fetchStartDays, fetchEndDays)); +} + /** * Loads Google API. * @@ -14,9 +29,16 @@ import googleApi from './googleApi'; * @returns {Function} */ export function loadGoogleAPI(clientId: string) { - return (dispatch: Dispatch<*>) => + return (dispatch: Dispatch<*>, getState: Function) => googleApi.get() - .then(() => googleApi.initializeClient(clientId)) + .then(() => { + if (getState()['features/google-api'].googleAPIState + === GOOGLE_API_STATES.NEEDS_LOADING) { + return googleApi.initializeClient(clientId); + } + + return Promise.resolve(); + }) .then(() => dispatch({ type: SET_GOOGLE_API_STATE, googleAPIState: GOOGLE_API_STATES.LOADED })) @@ -30,39 +52,6 @@ export function loadGoogleAPI(clientId: string) { }); } -/** - * Prompts the participant to sign in to the Google API Client Library. - * - * @returns {function(Dispatch<*>): Promise} - */ -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} - */ -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. @@ -137,3 +126,61 @@ export function showAccountSelection() { return () => googleApi.showAccountSelection(); } + +/** + * Prompts the participant to sign in to the Google API Client Library. + * + * @returns {function(Dispatch<*>): Promise} + */ +export function signIn() { + return (dispatch: Dispatch<*>) => googleApi.get() + .then(() => googleApi.signInIfNotSignedIn()) + .then(() => dispatch({ + type: SET_GOOGLE_API_STATE, + googleAPIState: GOOGLE_API_STATES.SIGNED_IN + })); +} + +/** + * Logs out the user. + * + * @returns {function(Dispatch<*>): Promise} + */ +export function signOut() { + return (dispatch: Dispatch<*>) => + googleApi.get() + .then(() => googleApi.signOut()) + .then(() => { + dispatch({ + type: SET_GOOGLE_API_STATE, + googleAPIState: GOOGLE_API_STATES.LOADED + }); + dispatch({ + type: SET_GOOGLE_API_PROFILE, + profileEmail: '' + }); + }); +} + +/** + * Updates the profile data that is currently used. + * + * @returns {function(Dispatch<*>): Promise} + */ +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() + }); + + return profile.getEmail(); + }); +} diff --git a/react/features/google-api/components/GoogleSignInButton.native.js b/react/features/google-api/components/GoogleSignInButton.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/recording/components/LiveStream/GoogleSignInButton.web.js b/react/features/google-api/components/GoogleSignInButton.web.js similarity index 60% rename from react/features/recording/components/LiveStream/GoogleSignInButton.web.js rename to react/features/google-api/components/GoogleSignInButton.web.js index e6854a857..14380387b 100644 --- a/react/features/recording/components/LiveStream/GoogleSignInButton.web.js +++ b/react/features/google-api/components/GoogleSignInButton.web.js @@ -1,29 +1,25 @@ -import PropTypes from 'prop-types'; +// @flow + import React, { Component } from 'react'; +/** + * The type of the React {@code Component} props of {@link GoogleSignInButton}. + */ +type Props = { + + // The callback to invoke when {@code GoogleSignInButton} is clicked. + onClick: Function, + + // The text to display within {@code GoogleSignInButton}. + text: string +}; + /** * A React Component showing a button to sign in with Google. * * @extends Component */ -export default class GoogleSignInButton extends Component { - /** - * {@code GoogleSignInButton} component's property types. - * - * @static - */ - static propTypes = { - /** - * The callback to invoke when the button is clicked. - */ - onClick: PropTypes.func, - - /** - * The text to display in the button. - */ - text: PropTypes.string - }; - +export default class GoogleSignInButton extends Component { /** * Implements React's {@link Component#render()}. * diff --git a/react/features/google-api/components/index.js b/react/features/google-api/components/index.js new file mode 100644 index 000000000..fa4409239 --- /dev/null +++ b/react/features/google-api/components/index.js @@ -0,0 +1 @@ +export { default as GoogleSignInButton } from './GoogleSignInButton'; diff --git a/react/features/google-api/constants.js b/react/features/google-api/constants.js index 4150353da..ae3e35cf0 100644 --- a/react/features/google-api/constants.js +++ b/react/features/google-api/constants.js @@ -1,14 +1,23 @@ // @flow /** - * The Google API scopes to request access to for streaming. + * The Google API scopes to request access for streaming and calendar. * * @type {Array} */ export const GOOGLE_API_SCOPES = [ - 'https://www.googleapis.com/auth/youtube.readonly' + 'https://www.googleapis.com/auth/youtube.readonly', + 'https://www.googleapis.com/auth/calendar' ]; +/** + * Array of API discovery doc URLs for APIs used by the googleApi. + * + * @type {string[]} + */ +export const DISCOVERY_DOCS + = [ 'https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest' ]; + /** * An enumeration of the different states the Google API can be in. * diff --git a/react/features/google-api/googleApi.js b/react/features/google-api/googleApi.js index 025f01804..d981ab257 100644 --- a/react/features/google-api/googleApi.js +++ b/react/features/google-api/googleApi.js @@ -1,4 +1,4 @@ -import { GOOGLE_API_SCOPES } from './constants'; +import { GOOGLE_API_SCOPES, DISCOVERY_DOCS } from './constants'; const GOOGLE_API_CLIENT_LIBRARY_URL = 'https://apis.google.com/js/api.js'; @@ -67,6 +67,7 @@ const googleApi = { setTimeout(() => { api.client.init({ clientId, + discoveryDocs: DISCOVERY_DOCS, scope: GOOGLE_API_SCOPES.join(' ') }) .then(resolve) @@ -86,6 +87,7 @@ const googleApi = { .then(api => Boolean(api && api.auth2 && api.auth2.getAuthInstance + && api.auth2.getAuthInstance() && api.auth2.getAuthInstance().isSignedIn && api.auth2.getAuthInstance().isSignedIn.get())); }, @@ -183,6 +185,99 @@ const googleApi = { }); }, + /** + * Sign out from the Google API Client Library. + * + * @returns {Promise} + */ + signOut() { + return this.get() + .then(api => + api.auth2 + && api.auth2.getAuthInstance + && api.auth2.getAuthInstance() + && api.auth2.getAuthInstance().signOut()); + }, + + /** + * Parses the google calendar entries to a known format. + * + * @param {Object} entry - The google calendar entry. + * @returns {{ + * id: string, + * startDate: string, + * endDate: string, + * title: string, + * location: string, + * description: string}} + * @private + */ + _convertCalendarEntry(entry) { + return { + id: entry.id, + startDate: entry.start.dateTime, + endDate: entry.end.dateTime, + title: entry.summary, + location: entry.location, + description: entry.description + }; + }, + + /** + * Retrieves calendar entries from all available calendars. + * + * @param {number} fetchStartDays - The number of days to go back + * when fetching. + * @param {number} fetchEndDays - The number of days to fetch. + * @returns {Promise} + * @private + */ + _getCalendarEntries(fetchStartDays, fetchEndDays) { + return this.get() + .then(() => this.isSignedIn()) + .then(isSignedIn => { + if (!isSignedIn) { + return null; + } + + return this._getGoogleApiClient() + .client.calendar.calendarList.list(); + }) + .then(calendarList => { + + // no result, maybe not signed in + if (!calendarList) { + return Promise.resolve(); + } + + const calendarIds + = calendarList.result.items.map(en => en.id); + const promises = calendarIds.map(id => { + const startDate = new Date(); + const endDate = new Date(); + + startDate.setDate(startDate.getDate() + fetchStartDays); + endDate.setDate(endDate.getDate() + fetchEndDays); + + return this._getGoogleApiClient() + .client.calendar.events.list({ + 'calendarId': id, + 'timeMin': startDate.toISOString(), + 'timeMax': endDate.toISOString(), + 'showDeleted': false, + 'singleEvents': true, + 'orderBy': 'startTime' + }); + }); + + return Promise.all(promises) + .then(results => + [].concat(...results.map(rItem => rItem.result.items))) + .then(entries => + entries.map(e => this._convertCalendarEntry(e))); + }); + }, + /** * Returns the global Google API Client Library object. Direct use of this * method is discouraged; instead use the {@link get} method. diff --git a/react/features/google-api/index.js b/react/features/google-api/index.js index dfdbf043a..6663f5dde 100644 --- a/react/features/google-api/index.js +++ b/react/features/google-api/index.js @@ -1,5 +1,6 @@ export { GOOGLE_API_STATES } from './constants'; -export * from './googleApi'; +export { default as googleApi } from './googleApi'; export * from './actions'; +export * from './components'; import './reducer'; diff --git a/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js b/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js index 2807cf9a2..081d0fe1e 100644 --- a/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js +++ b/react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js @@ -7,13 +7,14 @@ import { connect } from 'react-redux'; import { translate } from '../../../base/i18n'; import { - updateProfile, GOOGLE_API_STATES, + GoogleSignInButton, loadGoogleAPI, requestAvailableYouTubeBroadcasts, requestLiveStreamsForYouTubeBroadcast, showAccountSelection, - signIn + signIn, + updateProfile } from '../../../google-api'; import AbstractStartLiveStreamDialog, { @@ -21,7 +22,6 @@ import AbstractStartLiveStreamDialog, { type Props } from './AbstractStartLiveStreamDialog'; import BroadcastsDropdown from './BroadcastsDropdown'; -import GoogleSignInButton from './GoogleSignInButton'; import StreamKeyForm from './StreamKeyForm'; /** diff --git a/react/features/settings/components/web/CalendarTab.js b/react/features/settings/components/web/CalendarTab.js new file mode 100644 index 000000000..4ef5e16b0 --- /dev/null +++ b/react/features/settings/components/web/CalendarTab.js @@ -0,0 +1,301 @@ +// @flow + +import Button from '@atlaskit/button'; +import Spinner from '@atlaskit/spinner'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '../../../base/i18n'; +import { + CALENDAR_TYPE, + MicrosoftSignInButton, + clearCalendarIntegration, + bootstrapCalendarIntegration, + isCalendarEnabled, + signIn +} from '../../../calendar-sync'; +import { GoogleSignInButton } from '../../../google-api'; + +const logger = require('jitsi-meet-logger').getLogger(__filename); + +declare var interfaceConfig: Object; + +/** + * The type of the React {@code Component} props of {@link CalendarTab}. + */ +type Props = { + + /** + * The name given to this Jitsi Application. + */ + _appName: string, + + /** + * Whether or not to display a button to sign in to Google. + */ + _enableGoogleIntegration: boolean, + + /** + * Whether or not to display a button to sign in to Microsoft. + */ + _enableMicrosoftIntegration: boolean, + + /** + * The current calendar integration in use, if any. + */ + _isConnectedToCalendar: boolean, + + /** + * The email address associated with the calendar integration in use. + */ + _profileEmail: string, + + /** + * Invoked to change the configured calendar integration. + */ + dispatch: Function, + + /** + * Invoked to obtain translated strings. + */ + t: Function +}; + +/** + * The type of the React {@code Component} state of {@link CalendarTab}. + */ +type State = { + + /** + * Whether or not any third party APIs are being loaded. + */ + loading: boolean +}; + +/** + * React {@code Component} for modifying calendar integration. + * + * @extends Component + */ +class CalendarTab extends Component { + /** + * Initializes a new {@code CalendarTab} instance. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this.state = { + loading: true + }; + + // Bind event handlers so they are only bound once for every instance. + this._onClickDisconnect = this._onClickDisconnect.bind(this); + this._onClickGoogle = this._onClickGoogle.bind(this); + this._onClickMicrosoft = this._onClickMicrosoft.bind(this); + } + + /** + * Loads third party APIs as needed and bootstraps the initial calendar + * state if not already set. + * + * @inheritdoc + */ + componentDidMount() { + this.props.dispatch(bootstrapCalendarIntegration()) + .catch(err => logger.error('CalendarTab bootstrap failed', err)) + .then(() => this.setState({ loading: false })); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + let view; + + if (this.state.loading) { + view = this._renderLoadingState(); + } else if (this.props._isConnectedToCalendar) { + view = this._renderSignOutState(); + } else { + view = this._renderSignInState(); + } + + return ( +
+ { view } +
+ ); + } + + /** + * Dispatches the action to start the sign in flow for a given calendar + * integration type. + * + * @param {string} type - The calendar type to try integrating with. + * @private + * @returns {void} + */ + _attemptSignIn(type) { + this.props.dispatch(signIn(type)); + } + + _onClickDisconnect: (Object) => void; + + /** + * Dispatches an action to sign out of the currently connected third party + * used for calendar integration. + * + * @private + * @returns {void} + */ + _onClickDisconnect() { + // We clear the integration state instead of actually signing out. This + // is for two primary reasons. Microsoft does not support a sign out and + // instead relies on clearing of local auth data. Google signout can + // also sign the user out of YouTube. So for now we've decided not to + // do an actual sign out. + this.props.dispatch(clearCalendarIntegration()); + } + + _onClickGoogle: () => void; + + /** + * Starts the sign in flow for Google calendar integration. + * + * @private + * @returns {void} + */ + _onClickGoogle() { + this._attemptSignIn(CALENDAR_TYPE.GOOGLE); + } + + _onClickMicrosoft: () => void; + + /** + * Starts the sign in flow for Microsoft calendar integration. + * + * @private + * @returns {void} + */ + _onClickMicrosoft() { + this._attemptSignIn(CALENDAR_TYPE.MICROSOFT); + } + + /** + * Render a React Element to indicate third party APIs are being loaded. + * + * @private + * @returns {ReactElement} + */ + _renderLoadingState() { + return ( + + ); + } + + /** + * Render a React Element to sign into a third party for calendar + * integration. + * + * @private + * @returns {ReactElement} + */ + _renderSignInState() { + const { + _appName, + _enableGoogleIntegration, + _enableMicrosoftIntegration, + t + } = this.props; + + return ( +
+

+ { t('settings.calendar.about', + { appName: _appName || '' }) } +

+ { _enableGoogleIntegration + &&
+ +
} + { _enableMicrosoftIntegration + &&
+ +
} +
+ ); + } + + /** + * Render a React Element to sign out of the currently connected third + * party used for calendar integration. + * + * @private + * @returns {ReactElement} + */ + _renderSignOutState() { + const { _profileEmail, t } = this.props; + + return ( +
+
+ { t('settings.calendar.signedIn', + { email: _profileEmail }) } +
+ +
+ ); + } +} + +/** + * Maps (parts of) the Redux state to the associated props for the + * {@code CalendarTab} component. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * _appName: string, + * _enableGoogleIntegration: boolean, + * _enableMicrosoftIntegration: boolean, + * _isConnectedToCalendar: boolean, + * _profileEmail: string + * }} + */ +function _mapStateToProps(state) { + const calendarState = state['features/calendar-sync'] || {}; + const { + googleApiApplicationClientID, + microsoftApiApplicationClientID + } = state['features/base/config']; + const calendarEnabled = isCalendarEnabled(); + + return { + _appName: interfaceConfig.APP_NAME, + _enableGoogleIntegration: Boolean( + calendarEnabled && googleApiApplicationClientID), + _enableMicrosoftIntegration: Boolean( + calendarEnabled && microsoftApiApplicationClientID), + _isConnectedToCalendar: calendarState.integrationReady, + _profileEmail: calendarState.profileEmail + }; +} + +export default translate(connect(_mapStateToProps)(CalendarTab)); diff --git a/react/features/settings/components/web/SettingsDialog.js b/react/features/settings/components/web/SettingsDialog.js index 9f362ffbd..853b9a467 100644 --- a/react/features/settings/components/web/SettingsDialog.js +++ b/react/features/settings/components/web/SettingsDialog.js @@ -5,12 +5,14 @@ import { connect } from 'react-redux'; import { getAvailableDevices } from '../../../base/devices'; import { DialogWithTabs, hideDialog } from '../../../base/dialog'; +import { isCalendarEnabled } from '../../../calendar-sync'; import { DeviceSelection, getDeviceSelectionDialogProps, submitDeviceSelectionTab } from '../../../device-selection'; +import CalendarTab from './CalendarTab'; import MoreTab from './MoreTab'; import ProfileTab from './ProfileTab'; import { getMoreTabProps, getProfileTabProps } from '../../functions'; @@ -40,7 +42,7 @@ type Props = { /** * Invoked to save changed settings. */ - dispatch: Function, + dispatch: Function }; /** @@ -81,7 +83,8 @@ class SettingsDialog extends Component { onMount: tab.onMount ? (...args) => dispatch(tab.onMount(...args)) : undefined, - submit: (...args) => dispatch(tab.submit(...args)) + submit: (...args) => tab.submit + && dispatch(tab.submit(...args)) }; }); @@ -129,7 +132,8 @@ function _mapStateToProps(state) { const { showModeratorSettings, showLanguageSettings } = moreTabProps; const showProfileSettings = configuredTabs.includes('profile') && jwt.isGuest; - + const showCalendarSettings + = configuredTabs.includes('calendar') && isCalendarEnabled(); const tabs = []; if (showDeviceSettings) { @@ -169,6 +173,15 @@ function _mapStateToProps(state) { }); } + if (showCalendarSettings) { + tabs.push({ + name: SETTINGS_TABS.CALENDAR, + component: CalendarTab, + label: 'settings.calendar.title', + styles: 'settings-pane calendar-pane' + }); + } + if (showModeratorSettings || showLanguageSettings) { tabs.push({ name: SETTINGS_TABS.MORE, diff --git a/react/features/settings/constants.js b/react/features/settings/constants.js index fd25e9c39..654bf3106 100644 --- a/react/features/settings/constants.js +++ b/react/features/settings/constants.js @@ -1,4 +1,5 @@ export const SETTINGS_TABS = { + CALENDAR: 'calendar_tab', DEVICES: 'devices_tab', MORE: 'more_tab', PROFILE: 'profile_tab' diff --git a/static/msredirect.html b/static/msredirect.html new file mode 100644 index 000000000..70947774c --- /dev/null +++ b/static/msredirect.html @@ -0,0 +1,12 @@ + + + + + +