Browse Source

Google & Microsoft calendar API integration (#3340)

* Refactor calendar-sync feature to be loaded on web.

For the web part it just adds new property to enable/disable calendar web integration, disabled by default.

* Initial implementation of retrieving google calendar events.

* Initial implementation of retrieving microsoft calendar events.

* Fixes comments.

* Rework to use the promise part of microsoft-graph-client api.

* Moves dispatching some actions, fixing comments.

* Makes sure we do not initializeClient google-api client multiple times.

* Do not try to login when fetching calendar entries.

The case where there is a calendar type google selected, but not logged in, trying to login on loading welcome page will show a warning that it tried to open a popup, which was denied by browser.

* Updates profile display data on sign in.

* Propagate google-api state to calendar-sync only if we use google cal.

* Adds sign out action.

* Clears the event listener when the popup closes.

* Clears calendarIntegrationInstance on signOut.

* WIP: UI for calendar settings, refactor auth flows

* Clean up some unused constants, functions and exports.

* break circular dependency of function and constant

* Exports only isCalendarEnabled from functions.

* Checks isSignedIn when doing fetchCalendarEntries on web.

* address comments

List microsoftApiApplicationClientID in undocument config.

remove unused SET_CALENDAR_TYPE action

use helper for calendar enabled in bootstrap

reorder actions

reorder imports

change order of signin -> set type -> update profile

add logging for signout error

reword setting dialog desc to avoid redundancy

add jsdoc to microsoft button props

reorder calendar constants

move default state to reducer (not reused anywhere)

update comment about calendar-sync due to removal of getCalendarState

update comment for getCalendarIntegration

remove vague comment

alpha order reducer, return default state on reset

alpha order persistence registry

remove unnecessary getType from apis

update comments in microsoftCalendar

alpha order google-api exports, use api.get in loadGoogleAPI

set jsdoc for google signin props

alpha order googleapi methods

fix calendartab docs

* Moves fetching calendar from APP_WILL_MOUNT to SET_CONFIG.

The web part needs configuration in order to refresh tokens (Microsoft).

* Fixes storing token expire time and refreshing tokens in Microsoft impl.

* Address comments

updateProfile changed to getCurrentEmail

rename result to results

stop storing integration in redux, store if ready for use

use existing helpers to parse redirect url

* update jsdocs, get google app id from redux

* clear integration instead of actual sign out
master
Дамян Минков 6 years ago
parent
commit
7eda31315f
42 changed files with 1960 additions and 414 deletions
  1. 5
    0
      config.js
  2. 0
    33
      css/_recording.scss
  3. 3
    0
      css/main.scss
  4. 18
    0
      css/modals/settings/_settings.scss
  5. 32
    0
      css/third-party-branding/google.scss
  6. 28
    0
      css/third-party-branding/microsoft.scss
  7. 1
    0
      images/microsoftLogo.svg
  8. 8
    2
      lang/main.json
  9. 19
    0
      package-lock.json
  10. 2
    0
      package.json
  11. 1
    1
      react/features/base/dialog/components/DialogWithTabs.web.js
  12. 42
    0
      react/features/calendar-sync/actionTypes.js
  13. 182
    1
      react/features/calendar-sync/actions.js
  14. 2
    2
      react/features/calendar-sync/components/ConferenceNotification.native.js
  15. 0
    0
      react/features/calendar-sync/components/ConferenceNotification.web.js
  16. 2
    2
      react/features/calendar-sync/components/MeetingList.native.js
  17. 0
    0
      react/features/calendar-sync/components/MeetingList.web.js
  18. 0
    0
      react/features/calendar-sync/components/MicrosoftSignInButton.native.js
  19. 44
    0
      react/features/calendar-sync/components/MicrosoftSignInButton.web.js
  20. 1
    0
      react/features/calendar-sync/components/index.js
  21. 14
    25
      react/features/calendar-sync/constants.js
  22. 158
    0
      react/features/calendar-sync/functions.any.js
  23. 0
    18
      react/features/calendar-sync/functions.js
  24. 99
    0
      react/features/calendar-sync/functions.native.js
  25. 93
    0
      react/features/calendar-sync/functions.web.js
  26. 3
    1
      react/features/calendar-sync/index.js
  27. 12
    253
      react/features/calendar-sync/middleware.js
  28. 54
    12
      react/features/calendar-sync/reducer.js
  29. 66
    0
      react/features/calendar-sync/web/googleCalendar.js
  30. 531
    0
      react/features/calendar-sync/web/microsoftCalendar.js
  31. 82
    35
      react/features/google-api/actions.js
  32. 0
    0
      react/features/google-api/components/GoogleSignInButton.native.js
  33. 15
    19
      react/features/google-api/components/GoogleSignInButton.web.js
  34. 1
    0
      react/features/google-api/components/index.js
  35. 11
    2
      react/features/google-api/constants.js
  36. 96
    1
      react/features/google-api/googleApi.js
  37. 2
    1
      react/features/google-api/index.js
  38. 3
    3
      react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js
  39. 301
    0
      react/features/settings/components/web/CalendarTab.js
  40. 16
    3
      react/features/settings/components/web/SettingsDialog.js
  41. 1
    0
      react/features/settings/constants.js
  42. 12
    0
      static/msredirect.html

+ 5
- 0
config.js View File

@@ -256,6 +256,10 @@ var config = {
256 256
     // maintenance at 01:00 AM GMT,
257 257
     // noticeMessage: '',
258 258
 
259
+    // Enables calendar integration, depends on googleApiApplicationClientID
260
+    // and microsoftApiApplicationClientID
261
+    // enableCalendarIntegration: false,
262
+
259 263
     // Stats
260 264
     //
261 265
 
@@ -398,6 +402,7 @@ var config = {
398 402
      googleApiApplicationClientID
399 403
      iAmRecorder
400 404
      iAmSipGateway
405
+     microsoftApiApplicationClientID
401 406
      peopleSearchQueryTypes
402 407
      peopleSearchUrl
403 408
      requireDisplayName

+ 0
- 33
css/_recording.scss View File

@@ -34,39 +34,6 @@
34 34
         color: $errorColor;
35 35
     }
36 36
 
37
-    /**
38
-     * The Google sign in button must follow Google's design guidelines.
39
-     * See: https://developers.google.com/identity/branding-guidelines
40
-     */
41
-    .google-sign-in {
42
-        background-color: #4285f4;
43
-        border-radius: 2px;
44
-        cursor: pointer;
45
-        display: inline-flex;
46
-        font-family: Roboto, arial, sans-serif;
47
-        font-size: 14px;
48
-        padding: 1px;
49
-
50
-        .google-cta {
51
-            color: white;
52
-            display: inline-block;
53
-            /**
54
-             * Hack the line height for vertical centering of text.
55
-             */
56
-            line-height: 32px;
57
-            margin: 0 15px;
58
-        }
59
-
60
-        .google-logo {
61
-            background-color: white;
62
-            border-radius: 2px;
63
-            display: inline-block;
64
-            padding: 8px;
65
-            height: 18px;
66
-            width: 18px;
67
-        }
68
-    }
69
-
70 37
     .google-panel {
71 38
         align-items: center;
72 39
         border-bottom: 2px solid rgba(0, 0, 0, 0.3);

+ 3
- 0
css/main.scss View File

@@ -82,4 +82,7 @@
82 82
 @import 'deep-linking/main';
83 83
 @import 'transcription-subtitles';
84 84
 @import 'navigate_section_list';
85
+@import 'third-party-branding/google';
86
+@import 'third-party-branding/microsoft';
87
+
85 88
 /* Modules END */

+ 18
- 0
css/modals/settings/_settings.scss View File

@@ -10,6 +10,7 @@
10 10
         margin-bottom: 4px;
11 11
     }
12 12
 
13
+    .calendar-tab,
13 14
     .device-selection {
14 15
         margin-top: 20px;
15 16
     }
@@ -22,6 +23,7 @@
22 23
         padding: 20px 0px 4px 0px;
23 24
     }
24 25
 
26
+    .calendar-tab,
25 27
     .more-tab,
26 28
     .profile-edit {
27 29
         display: flex;
@@ -40,4 +42,20 @@
40 42
     .language-settings {
41 43
         max-width: 50%;
42 44
     }
45
+
46
+    .calendar-tab {
47
+        align-items: center;
48
+        flex-direction: column;
49
+        font-size: 14px;
50
+        min-height: 100px;
51
+        text-align: center;
52
+    }
53
+
54
+    .calendar-tab-sign-in {
55
+        margin-top: 20px;
56
+    }
57
+
58
+    .sign-out-cta {
59
+        margin-bottom: 20px;
60
+    }
43 61
 }

+ 32
- 0
css/third-party-branding/google.scss View File

@@ -0,0 +1,32 @@
1
+/**
2
+ * The Google sign in button must follow Google's design guidelines.
3
+ * See: https://developers.google.com/identity/branding-guidelines
4
+ */
5
+.google-sign-in {
6
+    background-color: #4285f4;
7
+    border-radius: 2px;
8
+    cursor: pointer;
9
+    display: inline-flex;
10
+    font-family: Roboto, arial, sans-serif;
11
+    font-size: 14px;
12
+    padding: 1px;
13
+
14
+    .google-cta {
15
+        color: white;
16
+        display: inline-block;
17
+        /**
18
+         * Hack the line height for vertical centering of text.
19
+         */
20
+        line-height: 32px;
21
+        margin: 0 15px;
22
+    }
23
+
24
+    .google-logo {
25
+        background-color: white;
26
+        border-radius: 2px;
27
+        display: inline-block;
28
+        padding: 8px;
29
+        height: 18px;
30
+        width: 18px;
31
+    }
32
+}

+ 28
- 0
css/third-party-branding/microsoft.scss View File

@@ -0,0 +1,28 @@
1
+/**
2
+ * The Microsoft sign in button must follow Microsoft's brand guidelines.
3
+ * See: https://docs.microsoft.com/en-us/azure/active-directory/
4
+ *     develop/active-directory-branding-guidelines
5
+ */
6
+.microsoft-sign-in {
7
+    align-items: center;
8
+    background: #FFFFFF;
9
+    border: 1px solid #8C8C8C;
10
+    box-sizing: border-box;
11
+    cursor: pointer;
12
+    display: inline-flex;
13
+    font-family: Segoe UI, Roboto, arial, sans-serif;
14
+    height: 41px;
15
+    padding: 12px;
16
+
17
+    .microsoft-cta {
18
+        display: inline-block;
19
+        color: #5E5E5E;
20
+        font-size: 15px;
21
+        line-height: 41px;
22
+    }
23
+
24
+    .microsoft-logo {
25
+        display: inline-block;
26
+        margin-right: 12px;
27
+    }
28
+}

+ 1
- 0
images/microsoftLogo.svg View File

@@ -0,0 +1 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21"><title>MS-SymbolLockup</title><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>

+ 8
- 2
lang/main.json View File

@@ -157,8 +157,14 @@
157 157
         },
158 158
         "messagebox": "Enter text..."
159 159
     },
160
-    "settings":
161
-    {
160
+    "settings": {
161
+        "calendar": {
162
+            "about": "The __appName__ calendar integration is used to securely access your calendar so it can read upcoming events.",
163
+            "disconnect": "Disconnect",
164
+            "microsoftSignIn": "Sign in with Microsoft",
165
+            "signedIn": "Currently accessing calendar events for __email__. Click the Disconnect button below to stop accessing calendar events.",
166
+            "title": "Calendar"
167
+        },
162 168
         "title": "Settings",
163 169
         "update": "Update",
164 170
         "name": "Name",

+ 19
- 0
package-lock.json View File

@@ -3003,6 +3003,15 @@
3003 3003
         "sdp-transform": "2.3.0"
3004 3004
       }
3005 3005
     },
3006
+    "@microsoft/microsoft-graph-client": {
3007
+      "version": "1.1.0",
3008
+      "resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-1.1.0.tgz",
3009
+      "integrity": "sha512-sDgchKZz1l3QJVNdkE1P1KpwTjupNt1mS9h1T0CiP+ayMN7IeFKfElB8IYtxFplNalZTmEq+iqoQFqUVpVMLfQ==",
3010
+      "requires": {
3011
+        "es6-promise": "^4.1.0",
3012
+        "isomorphic-fetch": "^2.2.1"
3013
+      }
3014
+    },
3006 3015
     "@webcomponents/url": {
3007 3016
       "version": "0.7.1",
3008 3017
       "resolved": "https://registry.npmjs.org/@webcomponents/url/-/url-0.7.1.tgz",
@@ -6263,6 +6272,11 @@
6263 6272
         "event-emitter": "~0.3.5"
6264 6273
       }
6265 6274
     },
6275
+    "es6-promise": {
6276
+      "version": "4.2.4",
6277
+      "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.4.tgz",
6278
+      "integrity": "sha512-/NdNZVJg+uZgtm9eS3O6lrOLYmQag2DjdEXuPaHlZ6RuVqgqaVZfgYCepEIKsLqwdQArOPtC3XzRLqGGfT8KQQ=="
6279
+    },
6266 6280
     "es6-set": {
6267 6281
       "version": "0.1.5",
6268 6282
       "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz",
@@ -9643,6 +9657,11 @@
9643 9657
         "verror": "1.10.0"
9644 9658
       }
9645 9659
     },
9660
+    "jsrsasign": {
9661
+      "version": "8.0.12",
9662
+      "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-8.0.12.tgz",
9663
+      "integrity": "sha1-Iqu5ZW00owuVMENnIINeicLlwxY="
9664
+    },
9646 9665
     "jssha": {
9647 9666
       "version": "2.3.1",
9648 9667
       "resolved": "https://registry.npmjs.org/jssha/-/jssha-2.3.1.tgz",

+ 2
- 0
package.json View File

@@ -34,6 +34,7 @@
34 34
     "@atlaskit/tabs": "4.0.1",
35 35
     "@atlaskit/theme": "2.4.0",
36 36
     "@atlaskit/tooltip": "9.1.1",
37
+    "@microsoft/microsoft-graph-client": "1.1.0",
37 38
     "@webcomponents/url": "0.7.1",
38 39
     "autosize": "1.18.13",
39 40
     "i18next": "8.4.3",
@@ -46,6 +47,7 @@
46 47
     "jquery-i18next": "1.2.0",
47 48
     "js-md5": "0.6.1",
48 49
     "jsc-android": "224109.1.0",
50
+    "jsrsasign": "8.0.12",
49 51
     "jwt-decode": "2.2.0",
50 52
     "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#4a28a196160411d657518022de8bded7c02ad679",
51 53
     "libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d",

+ 1
- 1
react/features/base/dialog/components/DialogWithTabs.web.js View File

@@ -212,7 +212,7 @@ class DialogWithTabs extends Component<Props, State> {
212 212
         const { onSubmit, tabs } = this.props;
213 213
 
214 214
         tabs.forEach(({ submit }, idx) => {
215
-            submit(this.state.tabStates[idx]);
215
+            submit && submit(this.state.tabStates[idx]);
216 216
         });
217 217
 
218 218
         onSubmit();

+ 42
- 0
react/features/calendar-sync/actionTypes.js View File

@@ -1,5 +1,15 @@
1 1
 // @flow
2 2
 
3
+/**
4
+ * Resets the state of calendar integration so stored events and selected
5
+ * calendar type are cleared.
6
+ *
7
+ * {
8
+ *     type: CLEAR_CALENDAR_INTEGRATION
9
+ * }
10
+ */
11
+export const CLEAR_CALENDAR_INTEGRATION = Symbol('CLEAR_CALENDAR_INTEGRATION');
12
+
3 13
 /**
4 14
  * Action to refresh (re-fetch) the entry list.
5 15
  *
@@ -32,3 +42,35 @@ export const SET_CALENDAR_AUTHORIZATION = Symbol('SET_CALENDAR_AUTHORIZATION');
32 42
  * }
33 43
  */
34 44
 export const SET_CALENDAR_EVENTS = Symbol('SET_CALENDAR_EVENTS');
45
+
46
+/**
47
+ * Action to update calendar type to be used for web.
48
+ *
49
+ * {
50
+ *     type: SET_CALENDAR_INTEGRATION,
51
+ *     integrationReady: boolean,
52
+ *     integrationType: string
53
+ * }
54
+ */
55
+export const SET_CALENDAR_INTEGRATION = Symbol('SET_CALENDAR_INTEGRATION');
56
+
57
+/**
58
+ * The type of Redux action which changes Calendar API auth state.
59
+ *
60
+ * {
61
+ *     type: SET_CALENDAR_AUTH_STATE
62
+ * }
63
+ * @public
64
+ */
65
+export const SET_CALENDAR_AUTH_STATE = Symbol('SET_CALENDAR_AUTH_STATE');
66
+
67
+/**
68
+ * The type of Redux action which changes Calendar Profile email state.
69
+ *
70
+ * {
71
+ *     type: SET_CALENDAR_PROFILE_EMAIL,
72
+ *     email: string
73
+ * }
74
+ * @public
75
+ */
76
+export const SET_CALENDAR_PROFILE_EMAIL = Symbol('SET_CALENDAR_PROFILE_EMAIL');

+ 182
- 1
react/features/calendar-sync/actions.js View File

@@ -1,10 +1,87 @@
1 1
 // @flow
2 2
 
3
+import { loadGoogleAPI } from '../google-api';
4
+
3 5
 import {
6
+    CLEAR_CALENDAR_INTEGRATION,
4 7
     REFRESH_CALENDAR,
8
+    SET_CALENDAR_AUTH_STATE,
5 9
     SET_CALENDAR_AUTHORIZATION,
6
-    SET_CALENDAR_EVENTS
10
+    SET_CALENDAR_EVENTS,
11
+    SET_CALENDAR_INTEGRATION,
12
+    SET_CALENDAR_PROFILE_EMAIL
7 13
 } from './actionTypes';
14
+import { _getCalendarIntegration, isCalendarEnabled } from './functions';
15
+
16
+const logger = require('jitsi-meet-logger').getLogger(__filename);
17
+
18
+/**
19
+ * Sets the initial state of calendar integration by loading third party APIs
20
+ * and filling out any data that needs to be fetched.
21
+ *
22
+ * @returns {Function}
23
+ */
24
+export function bootstrapCalendarIntegration(): Function {
25
+    return (dispatch, getState) => {
26
+        const {
27
+            googleApiApplicationClientID
28
+        } = getState()['features/base/config'];
29
+        const {
30
+            integrationReady,
31
+            integrationType
32
+        } = getState()['features/calendar-sync'];
33
+
34
+        if (!isCalendarEnabled()) {
35
+            return Promise.reject();
36
+        }
37
+
38
+        return Promise.resolve()
39
+            .then(() => {
40
+                if (googleApiApplicationClientID) {
41
+                    return dispatch(
42
+                        loadGoogleAPI(googleApiApplicationClientID));
43
+                }
44
+            })
45
+            .then(() => {
46
+                if (!integrationType || integrationReady) {
47
+                    return;
48
+                }
49
+
50
+                const integrationToLoad
51
+                    = _getCalendarIntegration(integrationType);
52
+
53
+                if (!integrationToLoad) {
54
+                    dispatch(clearCalendarIntegration());
55
+
56
+                    return;
57
+                }
58
+
59
+                return dispatch(integrationToLoad._isSignedIn())
60
+                    .then(signedIn => {
61
+                        if (signedIn) {
62
+                            dispatch(setIntegrationReady(integrationType));
63
+                            dispatch(updateProfile(integrationType));
64
+                        } else {
65
+                            dispatch(clearCalendarIntegration());
66
+                        }
67
+                    });
68
+            });
69
+    };
70
+}
71
+
72
+/**
73
+ * Resets the state of calendar integration so stored events and selected
74
+ * calendar type are cleared.
75
+ *
76
+ * @returns {{
77
+ *     type: CLEAR_CALENDAR_INTEGRATION
78
+ * }}
79
+ */
80
+export function clearCalendarIntegration() {
81
+    return {
82
+        type: CLEAR_CALENDAR_INTEGRATION
83
+    };
84
+}
8 85
 
9 86
 /**
10 87
  * Sends an action to refresh the entry list (fetches new data).
@@ -28,6 +105,23 @@ export function refreshCalendar(
28 105
     };
29 106
 }
30 107
 
108
+/**
109
+ * Sends an action to update the current calendar api auth state in redux.
110
+ * This is used only for microsoft implementation to store it auth state.
111
+ *
112
+ * @param {number} newState - The new state.
113
+ * @returns {{
114
+ *     type: SET_CALENDAR_AUTH_STATE,
115
+ *     msAuthState: Object
116
+ * }}
117
+ */
118
+export function setCalendarAPIAuthState(newState: ?Object) {
119
+    return {
120
+        type: SET_CALENDAR_AUTH_STATE,
121
+        msAuthState: newState
122
+    };
123
+}
124
+
31 125
 /**
32 126
  * Sends an action to signal that a calendar access has been requested. For more
33 127
  * info, see {@link SET_CALENDAR_AUTHORIZATION}.
@@ -61,3 +155,90 @@ export function setCalendarEvents(events: Array<Object>) {
61 155
         events
62 156
     };
63 157
 }
158
+
159
+/**
160
+ * Sends an action to update the current calendar profile email state in redux.
161
+ *
162
+ * @param {number} newEmail - The new email.
163
+ * @returns {{
164
+ *     type: SET_CALENDAR_PROFILE_EMAIL,
165
+ *     email: string
166
+ * }}
167
+ */
168
+export function setCalendarProfileEmail(newEmail: ?string) {
169
+    return {
170
+        type: SET_CALENDAR_PROFILE_EMAIL,
171
+        email: newEmail
172
+    };
173
+}
174
+
175
+/**
176
+ * Sets the calendar integration type to be used by web and signals that the
177
+ * integration is ready to be used.
178
+ *
179
+ * @param {string|undefined} integrationType - The calendar type.
180
+ * @returns {{
181
+ *      type: SET_CALENDAR_INTEGRATION,
182
+ *      integrationReady: boolean,
183
+ *      integrationType: string
184
+ * }}
185
+ */
186
+export function setIntegrationReady(integrationType: string) {
187
+    return {
188
+        type: SET_CALENDAR_INTEGRATION,
189
+        integrationReady: true,
190
+        integrationType
191
+    };
192
+}
193
+
194
+/**
195
+ * Signals signing in to the specified calendar integration.
196
+ *
197
+ * @param {string} calendarType - The calendar integration which should be
198
+ * signed into.
199
+ * @returns {Function}
200
+ */
201
+export function signIn(calendarType: string): Function {
202
+    return (dispatch: Dispatch<*>) => {
203
+        const integration = _getCalendarIntegration(calendarType);
204
+
205
+        if (!integration) {
206
+            return Promise.reject('No supported integration found');
207
+        }
208
+
209
+        return dispatch(integration.load())
210
+            .then(() => dispatch(integration.signIn()))
211
+            .then(() => dispatch(setIntegrationReady(calendarType)))
212
+            .then(() => dispatch(updateProfile(calendarType)))
213
+            .catch(error => {
214
+                logger.error(
215
+                    'Error occurred while signing into calendar integration',
216
+                    error);
217
+
218
+                return Promise.reject(error);
219
+            });
220
+    };
221
+}
222
+
223
+/**
224
+ * Signals to get current profile data linked to the current calendar
225
+ * integration that is in use.
226
+ *
227
+ * @param {string} calendarType - The calendar integration to which the profile
228
+ * should be updated.
229
+ * @returns {Function}
230
+ */
231
+export function updateProfile(calendarType: string): Function {
232
+    return (dispatch: Dispatch<*>) => {
233
+        const integration = _getCalendarIntegration(calendarType);
234
+
235
+        if (!integration) {
236
+            return Promise.reject('No integration found');
237
+        }
238
+
239
+        return dispatch(integration.getCurrentEmail())
240
+            .then(email => {
241
+                dispatch(setCalendarProfileEmail(email));
242
+            });
243
+    };
244
+}

+ 2
- 2
react/features/calendar-sync/components/ConferenceNotification.native.js View File

@@ -10,7 +10,7 @@ import { Icon } from '../../base/font-icons';
10 10
 import { getLocalizedDateFormatter, translate } from '../../base/i18n';
11 11
 import { ASPECT_RATIO_NARROW } from '../../base/responsive-ui';
12 12
 
13
-import { CALENDAR_ENABLED } from '../constants';
13
+import { isCalendarEnabled } from '../functions';
14 14
 import styles from './styles';
15 15
 
16 16
 const ALERT_MILLISECONDS = 5 * 60 * 1000;
@@ -293,6 +293,6 @@ function _mapStateToProps(state: Object) {
293 293
     };
294 294
 }
295 295
 
296
-export default CALENDAR_ENABLED
296
+export default isCalendarEnabled()
297 297
     ? translate(connect(_mapStateToProps)(ConferenceNotification))
298 298
     : undefined;

react/features/recording/components/LiveStream/GoogleSignInButton.native.js → react/features/calendar-sync/components/ConferenceNotification.web.js View File


+ 2
- 2
react/features/calendar-sync/components/MeetingList.native.js View File

@@ -10,7 +10,7 @@ import { NavigateSectionList } from '../../base/react';
10 10
 import { openSettings } from '../../mobile/permissions';
11 11
 
12 12
 import { refreshCalendar } from '../actions';
13
-import { CALENDAR_ENABLED } from '../constants';
13
+import { isCalendarEnabled } from '../functions';
14 14
 import styles from './styles';
15 15
 
16 16
 /**
@@ -275,6 +275,6 @@ function _mapStateToProps(state: Object) {
275 275
     };
276 276
 }
277 277
 
278
-export default CALENDAR_ENABLED
278
+export default isCalendarEnabled()
279 279
     ? translate(connect(_mapStateToProps)(MeetingList))
280 280
     : undefined;

+ 0
- 0
react/features/calendar-sync/components/MeetingList.web.js View File


+ 0
- 0
react/features/calendar-sync/components/MicrosoftSignInButton.native.js View File


+ 44
- 0
react/features/calendar-sync/components/MicrosoftSignInButton.web.js View File

@@ -0,0 +1,44 @@
1
+// @flow
2
+
3
+import React, { Component } from 'react';
4
+
5
+/**
6
+ * The type of the React {@code Component} props of
7
+ * {@link MicrosoftSignInButton}.
8
+ */
9
+type Props = {
10
+
11
+    // The callback to invoke when {@code MicrosoftSignInButton} is clicked.
12
+    onClick: Function,
13
+
14
+    // The text to display within {@code MicrosoftSignInButton}.
15
+    text: string
16
+};
17
+
18
+/**
19
+ * A React Component showing a button to sign in with Microsoft.
20
+ *
21
+ * @extends Component
22
+ */
23
+export default class MicrosoftSignInButton extends Component<Props> {
24
+    /**
25
+     * Implements React's {@link Component#render()}.
26
+     *
27
+     * @inheritdoc
28
+     * @returns {ReactElement}
29
+     */
30
+    render() {
31
+        return (
32
+            <div
33
+                className = 'microsoft-sign-in'
34
+                onClick = { this.props.onClick }>
35
+                <img
36
+                    className = 'microsoft-logo'
37
+                    src = 'images/microsoftLogo.svg' />
38
+                <div className = 'microsoft-cta'>
39
+                    { this.props.text }
40
+                </div>
41
+            </div>
42
+        );
43
+    }
44
+}

+ 1
- 0
react/features/calendar-sync/components/index.js View File

@@ -1,2 +1,3 @@
1 1
 export { default as ConferenceNotification } from './ConferenceNotification';
2 2
 export { default as MeetingList } from './MeetingList';
3
+export { default as MicrosoftSignInButton } from './MicrosoftSignInButton';

+ 14
- 25
react/features/calendar-sync/constants.js View File

@@ -1,37 +1,26 @@
1 1
 // @flow
2 2
 
3
-import { NativeModules } from 'react-native';
4
-
5 3
 /**
6
- * The indicator which determines whether the calendar feature is enabled by the
7
- * app.
4
+ * An enumeration of support calendar integration types.
8 5
  *
9
- * @type {boolean}
6
+ * @enum {string}
10 7
  */
11
-export const CALENDAR_ENABLED = _isCalendarEnabled();
8
+export const CALENDAR_TYPE = {
9
+    GOOGLE: 'google',
10
+    MICROSOFT: 'microsoft'
11
+};
12 12
 
13 13
 /**
14
- * The default state of the calendar.
15
- *
16
- * NOTE: This is defined here, to be reusable by functions.js as well (see file
17
- * for details).
14
+ * The number of days to fetch.
18 15
  */
19
-export const DEFAULT_STATE = {
20
-    authorization: undefined,
21
-    events: []
22
-};
16
+export const FETCH_END_DAYS = 10;
23 17
 
24 18
 /**
25
- * Determines whether the calendar feature is enabled by the app. For
26
- * example, Apple through its App Store requires
27
- * {@code NSCalendarsUsageDescription} in the app's Info.plist or App Store
28
- * rejects the app.
29
- *
30
- * @returns {boolean} If the app has enabled the calendar feature, {@code true};
31
- * otherwise, {@code false}.
19
+ * The number of days to go back when fetching.
32 20
  */
33
-function _isCalendarEnabled() {
34
-    const { calendarEnabled } = NativeModules.AppInfo;
21
+export const FETCH_START_DAYS = -1;
35 22
 
36
-    return typeof calendarEnabled === 'undefined' ? true : calendarEnabled;
37
-}
23
+/**
24
+ * The max number of events to fetch from the calendar.
25
+ */
26
+export const MAX_LIST_LENGTH = 10;

+ 158
- 0
react/features/calendar-sync/functions.any.js View File

@@ -0,0 +1,158 @@
1
+// @flow
2
+
3
+import md5 from 'js-md5';
4
+
5
+import { setCalendarEvents } from './actions';
6
+import { APP_LINK_SCHEME, parseURIString } from '../base/util';
7
+import { MAX_LIST_LENGTH } from './constants';
8
+
9
+const logger = require('jitsi-meet-logger').getLogger(__filename);
10
+
11
+/**
12
+ * Updates the calendar entries in redux when new list is received. The feature
13
+ * calendar-sync doesn't display all calendar events, it displays unique
14
+ * title, URL, and start time tuples i.e. it doesn't display subsequent
15
+ * occurrences of recurring events, and the repetitions of events coming from
16
+ * multiple calendars.
17
+ *
18
+ * XXX The function's {@code this} is the redux store.
19
+ *
20
+ * @param {Array<CalendarEntry>} events - The new event list.
21
+ * @private
22
+ * @returns {void}
23
+ */
24
+export function _updateCalendarEntries(events: Array<Object>) {
25
+    if (!events || !events.length) {
26
+        return;
27
+    }
28
+
29
+    // eslint-disable-next-line no-invalid-this
30
+    const { dispatch, getState } = this;
31
+    const knownDomains = getState()['features/base/known-domains'];
32
+    const now = Date.now();
33
+    const entryMap = new Map();
34
+
35
+    for (const event of events) {
36
+        const entry = _parseCalendarEntry(event, knownDomains);
37
+
38
+        if (entry && entry.endDate > now) {
39
+            // As was stated above, we don't display subsequent occurrences of
40
+            // recurring events, and the repetitions of events coming from
41
+            // multiple calendars.
42
+            const key = md5.hex(JSON.stringify([
43
+
44
+                // Obviously, we want to display different conference/meetings
45
+                // URLs. URLs are the very reason why we implemented the feature
46
+                // calendar-sync in the first place.
47
+                entry.url,
48
+
49
+                // We probably want to display one and the same URL to people if
50
+                // they have it under different titles in their Calendar.
51
+                // Because maybe they remember the title of the meeting, not the
52
+                // URL so they expect to see the title without realizing that
53
+                // they have the same URL already under a different title.
54
+                entry.title,
55
+
56
+                // XXX Eventually, given that the URL and the title are the
57
+                // same, what sets one event apart from another is the start
58
+                // time of the day (note the use of toTimeString() bellow)! The
59
+                // day itself is not important because we don't want multiple
60
+                // occurrences of a recurring event or repetitions of an even
61
+                // from multiple calendars.
62
+                new Date(entry.startDate).toTimeString()
63
+            ]));
64
+            const existingEntry = entryMap.get(key);
65
+
66
+            // We want only the earliest occurrence (which hasn't ended in the
67
+            // past, that is) of a recurring event.
68
+            if (!existingEntry || existingEntry.startDate > entry.startDate) {
69
+                entryMap.set(key, entry);
70
+            }
71
+        }
72
+    }
73
+
74
+    dispatch(
75
+        setCalendarEvents(
76
+            Array.from(entryMap.values())
77
+                .sort((a, b) => a.startDate - b.startDate)
78
+                .slice(0, MAX_LIST_LENGTH)));
79
+}
80
+
81
+/**
82
+ * Updates the calendar entries in Redux when new list is received.
83
+ *
84
+ * @param {Object} event - An event returned from the native calendar.
85
+ * @param {Array<string>} knownDomains - The known domain list.
86
+ * @private
87
+ * @returns {CalendarEntry}
88
+ */
89
+function _parseCalendarEntry(event, knownDomains) {
90
+    if (event) {
91
+        const url = _getURLFromEvent(event, knownDomains);
92
+
93
+        if (url) {
94
+            const startDate = Date.parse(event.startDate);
95
+            const endDate = Date.parse(event.endDate);
96
+
97
+            if (isNaN(startDate) || isNaN(endDate)) {
98
+                logger.warn(
99
+                    'Skipping invalid calendar event',
100
+                    event.title,
101
+                    event.startDate,
102
+                    event.endDate
103
+                );
104
+            } else {
105
+                return {
106
+                    endDate,
107
+                    id: event.id,
108
+                    startDate,
109
+                    title: event.title,
110
+                    url
111
+                };
112
+            }
113
+        }
114
+    }
115
+
116
+    return null;
117
+}
118
+
119
+/**
120
+ * Retrieves a Jitsi Meet URL from an event if present.
121
+ *
122
+ * @param {Object} event - The event to parse.
123
+ * @param {Array<string>} knownDomains - The known domain names.
124
+ * @private
125
+ * @returns {string}
126
+ */
127
+function _getURLFromEvent(event, knownDomains) {
128
+    const linkTerminatorPattern = '[^\\s<>$]';
129
+    const urlRegExp
130
+        = new RegExp(
131
+        `http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`,
132
+        'gi');
133
+    const schemeRegExp
134
+        = new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi');
135
+    const fieldsToSearch = [
136
+        event.title,
137
+        event.url,
138
+        event.location,
139
+        event.notes,
140
+        event.description
141
+    ];
142
+
143
+    for (const field of fieldsToSearch) {
144
+        if (typeof field === 'string') {
145
+            const matches = urlRegExp.exec(field) || schemeRegExp.exec(field);
146
+
147
+            if (matches) {
148
+                const url = parseURIString(matches[0]);
149
+
150
+                if (url) {
151
+                    return url.toString();
152
+                }
153
+            }
154
+        }
155
+    }
156
+
157
+    return null;
158
+}

+ 0
- 18
react/features/calendar-sync/functions.js View File

@@ -1,18 +0,0 @@
1
-// @flow
2
-import { toState } from '../base/redux';
3
-
4
-import { CALENDAR_ENABLED, DEFAULT_STATE } from './constants';
5
-
6
-/**
7
- * Returns the calendar state, considering the enabled/disabled state of the
8
- * feature. Since that is the normal Redux behaviour, this function will always
9
- * return an object (the default state if the feature is disabled).
10
- *
11
- * @param {Object | Function} stateful - An object or a function that can be
12
- * resolved to a Redux state by {@code toState}.
13
- * @returns {Object}
14
- */
15
-export function getCalendarState(stateful: Object | Function) {
16
-    return CALENDAR_ENABLED
17
-        ? toState(stateful)['features/calendar-sync'] : DEFAULT_STATE;
18
-}

+ 99
- 0
react/features/calendar-sync/functions.native.js View File

@@ -0,0 +1,99 @@
1
+import { NativeModules } from 'react-native';
2
+import RNCalendarEvents from 'react-native-calendar-events';
3
+
4
+import { setCalendarAuthorization } from './actions';
5
+import { FETCH_END_DAYS, FETCH_START_DAYS } from './constants';
6
+import { _updateCalendarEntries } from './functions';
7
+
8
+export * from './functions.any';
9
+
10
+const logger = require('jitsi-meet-logger').getLogger(__filename);
11
+
12
+/**
13
+ * Determines whether the calendar feature is enabled by the app. For
14
+ * example, Apple through its App Store requires
15
+ * {@code NSCalendarsUsageDescription} in the app's Info.plist or App Store
16
+ * rejects the app.
17
+ *
18
+ * @returns {boolean} If the app has enabled the calendar feature, {@code true};
19
+ * otherwise, {@code false}.
20
+ */
21
+export function isCalendarEnabled() {
22
+    const { calendarEnabled } = NativeModules.AppInfo;
23
+
24
+    return typeof calendarEnabled === 'undefined' ? true : calendarEnabled;
25
+}
26
+
27
+/**
28
+ * Reads the user's calendar and updates the stored entries if need be.
29
+ *
30
+ * @param {Object} store - The redux store.
31
+ * @param {boolean} maybePromptForPermission - Flag to tell the app if it should
32
+ * prompt for a calendar permission if it wasn't granted yet.
33
+ * @param {boolean|undefined} forcePermission - Whether to force to re-ask for
34
+ * the permission or not.
35
+ * @private
36
+ * @returns {void}
37
+ */
38
+export function _fetchCalendarEntries(
39
+        store,
40
+        maybePromptForPermission,
41
+        forcePermission) {
42
+    const { dispatch, getState } = store;
43
+    const promptForPermission
44
+        = (maybePromptForPermission
45
+        && !getState()['features/calendar-sync'].authorization)
46
+        || forcePermission;
47
+
48
+    _ensureCalendarAccess(promptForPermission, dispatch)
49
+        .then(accessGranted => {
50
+            if (accessGranted) {
51
+                const startDate = new Date();
52
+                const endDate = new Date();
53
+
54
+                startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
55
+                endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
56
+
57
+                RNCalendarEvents.fetchAllEvents(
58
+                    startDate.getTime(),
59
+                    endDate.getTime(),
60
+                    [])
61
+                    .then(_updateCalendarEntries.bind(store))
62
+                    .catch(error =>
63
+                        logger.error('Error fetching calendar.', error));
64
+            } else {
65
+                logger.warn('Calendar access not granted.');
66
+            }
67
+        })
68
+        .catch(reason => logger.error('Error accessing calendar.', reason));
69
+}
70
+
71
+/**
72
+ * Ensures calendar access if possible and resolves the promise if it's granted.
73
+ *
74
+ * @param {boolean} promptForPermission - Flag to tell the app if it should
75
+ * prompt for a calendar permission if it wasn't granted yet.
76
+ * @param {Function} dispatch - The Redux dispatch function.
77
+ * @private
78
+ * @returns {Promise}
79
+ */
80
+function _ensureCalendarAccess(promptForPermission, dispatch) {
81
+    return new Promise((resolve, reject) => {
82
+        RNCalendarEvents.authorizationStatus()
83
+            .then(status => {
84
+                if (status === 'authorized') {
85
+                    resolve(true);
86
+                } else if (promptForPermission) {
87
+                    RNCalendarEvents.authorizeEventStore()
88
+                        .then(result => {
89
+                            dispatch(setCalendarAuthorization(result));
90
+                            resolve(result === 'authorized');
91
+                        })
92
+                        .catch(reject);
93
+                } else {
94
+                    resolve(false);
95
+                }
96
+            })
97
+            .catch(reject);
98
+    });
99
+}

+ 93
- 0
react/features/calendar-sync/functions.web.js View File

@@ -0,0 +1,93 @@
1
+// @flow
2
+
3
+export * from './functions.any';
4
+
5
+import {
6
+    CALENDAR_TYPE,
7
+    FETCH_END_DAYS,
8
+    FETCH_START_DAYS
9
+} from './constants';
10
+import { _updateCalendarEntries } from './functions';
11
+import { googleCalendarApi } from './web/googleCalendar';
12
+import { microsoftCalendarApi } from './web/microsoftCalendar';
13
+
14
+const logger = require('jitsi-meet-logger').getLogger(__filename);
15
+
16
+declare var config: Object;
17
+
18
+/**
19
+ * Determines whether the calendar feature is enabled by the web.
20
+ *
21
+ * @returns {boolean} If the app has enabled the calendar feature, {@code true};
22
+ * otherwise, {@code false}.
23
+ */
24
+export function isCalendarEnabled() {
25
+    return Boolean(
26
+        config.enableCalendarIntegration
27
+            && (config.googleApiApplicationClientID
28
+                || config.microsoftApiApplicationClientID));
29
+}
30
+
31
+/* eslint-disable no-unused-vars */
32
+/**
33
+ * Reads the user's calendar and updates the stored entries if need be.
34
+ *
35
+ * @param {Object} store - The redux store.
36
+ * @param {boolean} maybePromptForPermission - Flag to tell the app if it should
37
+ * prompt for a calendar permission if it wasn't granted yet.
38
+ * @param {boolean|undefined} forcePermission - Whether to force to re-ask for
39
+ * the permission or not.
40
+ * @private
41
+ * @returns {void}
42
+ */
43
+export function _fetchCalendarEntries(
44
+        store,
45
+        maybePromptForPermission,
46
+        forcePermission) {
47
+    /* eslint-enable no-unused-vars */
48
+    const { dispatch, getState } = store;
49
+
50
+    const { integrationType } = getState()['features/calendar-sync'];
51
+    const integration = _getCalendarIntegration(integrationType);
52
+
53
+    if (!integration) {
54
+        logger.debug('No calendar type available');
55
+
56
+        return;
57
+    }
58
+
59
+    dispatch(integration.load())
60
+        .then(() => dispatch(integration._isSignedIn()))
61
+        .then(signedIn => {
62
+            if (signedIn) {
63
+                return Promise.resolve();
64
+            }
65
+
66
+            return Promise.reject('Not authorized, please sign in!');
67
+        })
68
+        .then(() => dispatch(integration.getCalendarEntries(
69
+            FETCH_START_DAYS, FETCH_END_DAYS)))
70
+        .then(events => _updateCalendarEntries.call({
71
+            dispatch,
72
+            getState
73
+        }, events))
74
+        .catch(error =>
75
+            logger.error('Error fetching calendar.', error));
76
+}
77
+
78
+/**
79
+ * Returns the calendar API implementation by specified type.
80
+ *
81
+ * @param {string} calendarType - The calendar type API as defined in
82
+ * the constant {@link CALENDAR_TYPE}.
83
+ * @private
84
+ * @returns {Object|undefined}
85
+ */
86
+export function _getCalendarIntegration(calendarType: string) {
87
+    switch (calendarType) {
88
+    case CALENDAR_TYPE.GOOGLE:
89
+        return googleCalendarApi;
90
+    case CALENDAR_TYPE.MICROSOFT:
91
+        return microsoftCalendarApi;
92
+    }
93
+}

+ 3
- 1
react/features/calendar-sync/index.js View File

@@ -1,5 +1,7 @@
1
+export * from './actions';
1 2
 export * from './components';
2
-export * from './functions';
3
+export * from './constants';
4
+export { isCalendarEnabled } from './functions';
3 5
 
4 6
 import './middleware';
5 7
 import './reducer';

+ 12
- 253
react/features/calendar-sync/middleware.js View File

@@ -1,36 +1,15 @@
1 1
 // @flow
2 2
 
3
-import md5 from 'js-md5';
4
-import RNCalendarEvents from 'react-native-calendar-events';
5
-
6
-import { APP_WILL_MOUNT } from '../base/app';
3
+import { SET_CONFIG } from '../base/config';
7 4
 import { ADD_KNOWN_DOMAINS, addKnownDomains } from '../base/known-domains';
8
-import { MiddlewareRegistry } from '../base/redux';
9
-import { APP_LINK_SCHEME, parseURIString } from '../base/util';
10
-import { APP_STATE_CHANGED } from '../mobile/background';
5
+import { equals, MiddlewareRegistry } from '../base/redux';
6
+import { APP_STATE_CHANGED } from '../mobile/background/actionTypes';
11 7
 
12
-import { setCalendarAuthorization, setCalendarEvents } from './actions';
8
+import { setCalendarAuthorization } from './actions';
13 9
 import { REFRESH_CALENDAR } from './actionTypes';
14
-import { CALENDAR_ENABLED } from './constants';
15
-
16
-const logger = require('jitsi-meet-logger').getLogger(__filename);
17
-
18
-/**
19
- * The number of days to fetch.
20
- */
21
-const FETCH_END_DAYS = 10;
10
+import { _fetchCalendarEntries, isCalendarEnabled } from './functions';
22 11
 
23
-/**
24
- * The number of days to go back when fetching.
25
- */
26
-const FETCH_START_DAYS = -1;
27
-
28
-/**
29
- * The max number of events to fetch from the calendar.
30
- */
31
-const MAX_LIST_LENGTH = 10;
32
-
33
-CALENDAR_ENABLED
12
+isCalendarEnabled()
34 13
     && MiddlewareRegistry.register(store => next => action => {
35 14
         switch (action.type) {
36 15
         case ADD_KNOWN_DOMAINS: {
@@ -41,7 +20,8 @@ CALENDAR_ENABLED
41 20
             const result = next(action);
42 21
             const newValue = getState()['features/base/known-domains'];
43 22
 
44
-            oldValue === newValue || _fetchCalendarEntries(store, false, false);
23
+            equals(oldValue, newValue)
24
+                || _fetchCalendarEntries(store, false, false);
45 25
 
46 26
             return result;
47 27
         }
@@ -54,7 +34,9 @@ CALENDAR_ENABLED
54 34
             return result;
55 35
         }
56 36
 
57
-        case APP_WILL_MOUNT: {
37
+        case SET_CONFIG: {
38
+            const result = next(action);
39
+
58 40
             // For legacy purposes, we've allowed the deserialization of
59 41
             // knownDomains and now we're to translate it to base/known-domains.
60 42
             const state = store.getState()['features/calendar-sync'];
@@ -69,7 +51,7 @@ CALENDAR_ENABLED
69 51
 
70 52
             _fetchCalendarEntries(store, false, false);
71 53
 
72
-            return next(action);
54
+            return result;
73 55
         }
74 56
 
75 57
         case REFRESH_CALENDAR: {
@@ -85,121 +67,6 @@ CALENDAR_ENABLED
85 67
         return next(action);
86 68
     });
87 69
 
88
-/**
89
- * Ensures calendar access if possible and resolves the promise if it's granted.
90
- *
91
- * @param {boolean} promptForPermission - Flag to tell the app if it should
92
- * prompt for a calendar permission if it wasn't granted yet.
93
- * @param {Function} dispatch - The Redux dispatch function.
94
- * @private
95
- * @returns {Promise}
96
- */
97
-function _ensureCalendarAccess(promptForPermission, dispatch) {
98
-    return new Promise((resolve, reject) => {
99
-        RNCalendarEvents.authorizationStatus()
100
-            .then(status => {
101
-                if (status === 'authorized') {
102
-                    resolve(true);
103
-                } else if (promptForPermission) {
104
-                    RNCalendarEvents.authorizeEventStore()
105
-                        .then(result => {
106
-                            dispatch(setCalendarAuthorization(result));
107
-                            resolve(result === 'authorized');
108
-                        })
109
-                        .catch(reject);
110
-                } else {
111
-                    resolve(false);
112
-                }
113
-            })
114
-            .catch(reject);
115
-    });
116
-}
117
-
118
-/**
119
- * Reads the user's calendar and updates the stored entries if need be.
120
- *
121
- * @param {Object} store - The redux store.
122
- * @param {boolean} maybePromptForPermission - Flag to tell the app if it should
123
- * prompt for a calendar permission if it wasn't granted yet.
124
- * @param {boolean|undefined} forcePermission - Whether to force to re-ask for
125
- * the permission or not.
126
- * @private
127
- * @returns {void}
128
- */
129
-function _fetchCalendarEntries(
130
-        store,
131
-        maybePromptForPermission,
132
-        forcePermission) {
133
-    const { dispatch, getState } = store;
134
-    const promptForPermission
135
-        = (maybePromptForPermission
136
-                && !getState()['features/calendar-sync'].authorization)
137
-            || forcePermission;
138
-
139
-    _ensureCalendarAccess(promptForPermission, dispatch)
140
-        .then(accessGranted => {
141
-            if (accessGranted) {
142
-                const startDate = new Date();
143
-                const endDate = new Date();
144
-
145
-                startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
146
-                endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
147
-
148
-                RNCalendarEvents.fetchAllEvents(
149
-                        startDate.getTime(),
150
-                        endDate.getTime(),
151
-                        [])
152
-                    .then(_updateCalendarEntries.bind(store))
153
-                    .catch(error =>
154
-                        logger.error('Error fetching calendar.', error));
155
-            } else {
156
-                logger.warn('Calendar access not granted.');
157
-            }
158
-        })
159
-        .catch(reason => logger.error('Error accessing calendar.', reason));
160
-}
161
-
162
-/**
163
- * Retrieves a Jitsi Meet URL from an event if present.
164
- *
165
- * @param {Object} event - The event to parse.
166
- * @param {Array<string>} knownDomains - The known domain names.
167
- * @private
168
- * @returns {string}
169
- */
170
-function _getURLFromEvent(event, knownDomains) {
171
-    const linkTerminatorPattern = '[^\\s<>$]';
172
-    const urlRegExp
173
-        = new RegExp(
174
-            `http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`,
175
-            'gi');
176
-    const schemeRegExp
177
-        = new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi');
178
-    const fieldsToSearch = [
179
-        event.title,
180
-        event.url,
181
-        event.location,
182
-        event.notes,
183
-        event.description
184
-    ];
185
-
186
-    for (const field of fieldsToSearch) {
187
-        if (typeof field === 'string') {
188
-            const matches = urlRegExp.exec(field) || schemeRegExp.exec(field);
189
-
190
-            if (matches) {
191
-                const url = parseURIString(matches[0]);
192
-
193
-                if (url) {
194
-                    return url.toString();
195
-                }
196
-            }
197
-        }
198
-    }
199
-
200
-    return null;
201
-}
202
-
203 70
 /**
204 71
  * Clears the calendar access status when the app comes back from the
205 72
  * background. This is needed as some users may never quit the app, but puts it
@@ -215,111 +82,3 @@ function _maybeClearAccessStatus(store, { appState }) {
215 82
     appState === 'background'
216 83
         && store.dispatch(setCalendarAuthorization(undefined));
217 84
 }
218
-
219
-/**
220
- * Updates the calendar entries in Redux when new list is received.
221
- *
222
- * @param {Object} event - An event returned from the native calendar.
223
- * @param {Array<string>} knownDomains - The known domain list.
224
- * @private
225
- * @returns {CalendarEntry}
226
- */
227
-function _parseCalendarEntry(event, knownDomains) {
228
-    if (event) {
229
-        const url = _getURLFromEvent(event, knownDomains);
230
-
231
-        if (url) {
232
-            const startDate = Date.parse(event.startDate);
233
-            const endDate = Date.parse(event.endDate);
234
-
235
-            if (isNaN(startDate) || isNaN(endDate)) {
236
-                logger.warn(
237
-                    'Skipping invalid calendar event',
238
-                    event.title,
239
-                    event.startDate,
240
-                    event.endDate
241
-                );
242
-            } else {
243
-                return {
244
-                    endDate,
245
-                    id: event.id,
246
-                    startDate,
247
-                    title: event.title,
248
-                    url
249
-                };
250
-            }
251
-        }
252
-    }
253
-
254
-    return null;
255
-}
256
-
257
-/**
258
- * Updates the calendar entries in redux when new list is received. The feature
259
- * calendar-sync doesn't display all calendar events, it displays unique
260
- * title, URL, and start time tuples i.e. it doesn't display subsequent
261
- * occurrences of recurring events, and the repetitions of events coming from
262
- * multiple calendars.
263
- *
264
- * XXX The function's {@code this} is the redux store.
265
- *
266
- * @param {Array<CalendarEntry>} events - The new event list.
267
- * @private
268
- * @returns {void}
269
- */
270
-function _updateCalendarEntries(events) {
271
-    if (!events || !events.length) {
272
-        return;
273
-    }
274
-
275
-    // eslint-disable-next-line no-invalid-this
276
-    const { dispatch, getState } = this;
277
-    const knownDomains = getState()['features/base/known-domains'];
278
-    const now = Date.now();
279
-    const entryMap = new Map();
280
-
281
-    for (const event of events) {
282
-        const entry = _parseCalendarEntry(event, knownDomains);
283
-
284
-        if (entry && entry.endDate > now) {
285
-            // As was stated above, we don't display subsequent occurrences of
286
-            // recurring events, and the repetitions of events coming from
287
-            // multiple calendars.
288
-            const key = md5.hex(JSON.stringify([
289
-
290
-                // Obviously, we want to display different conference/meetings
291
-                // URLs. URLs are the very reason why we implemented the feature
292
-                // calendar-sync in the first place.
293
-                entry.url,
294
-
295
-                // We probably want to display one and the same URL to people if
296
-                // they have it under different titles in their Calendar.
297
-                // Because maybe they remember the title of the meeting, not the
298
-                // URL so they expect to see the title without realizing that
299
-                // they have the same URL already under a different title.
300
-                entry.title,
301
-
302
-                // XXX Eventually, given that the URL and the title are the
303
-                // same, what sets one event apart from another is the start
304
-                // time of the day (note the use of toTimeString() bellow)! The
305
-                // day itself is not important because we don't want multiple
306
-                // occurrences of a recurring event or repetitions of an even
307
-                // from multiple calendars.
308
-                new Date(entry.startDate).toTimeString()
309
-            ]));
310
-            const existingEntry = entryMap.get(key);
311
-
312
-            // We want only the earliest occurrence (which hasn't ended in the
313
-            // past, that is) of a recurring event.
314
-            if (!existingEntry || existingEntry.startDate > entry.startDate) {
315
-                entryMap.set(key, entry);
316
-            }
317
-        }
318
-    }
319
-
320
-    dispatch(
321
-        setCalendarEvents(
322
-            Array.from(entryMap.values())
323
-                .sort((a, b) => a.startDate - b.startDate)
324
-                .slice(0, MAX_LIST_LENGTH)));
325
-}

+ 54
- 12
react/features/calendar-sync/reducer.js View File

@@ -5,21 +5,36 @@ import { ReducerRegistry, set } from '../base/redux';
5 5
 import { PersistenceRegistry } from '../base/storage';
6 6
 
7 7
 import {
8
+    CLEAR_CALENDAR_INTEGRATION,
9
+    SET_CALENDAR_AUTH_STATE,
8 10
     SET_CALENDAR_AUTHORIZATION,
9
-    SET_CALENDAR_EVENTS
11
+    SET_CALENDAR_EVENTS,
12
+    SET_CALENDAR_INTEGRATION,
13
+    SET_CALENDAR_PROFILE_EMAIL
10 14
 } from './actionTypes';
11
-import { CALENDAR_ENABLED, DEFAULT_STATE } from './constants';
15
+import { isCalendarEnabled } from './functions';
16
+
17
+/**
18
+ * The default state of the calendar feature.
19
+ *
20
+ * @type {Object}
21
+ */
22
+const DEFAULT_STATE = {
23
+    authorization: undefined,
24
+    events: [],
25
+    integrationReady: false,
26
+    integrationType: undefined,
27
+    msAuthState: undefined
28
+};
12 29
 
13 30
 /**
14 31
  * Constant for the Redux subtree of the calendar feature.
15 32
  *
16
- * NOTE: Please do not access this subtree directly outside of this feature.
17
- * This feature can be disabled (see {@code constants.js} for details), and in
18
- * that case, accessing this subtree directly will return undefined and will
19
- * need a bunch of repetitive type checks in other features. Use the
20
- * {@code getCalendarState} function instead, or make sure you take care of
21
- * those checks, or consider using the {@code CALENDAR_ENABLED} const to gate
22
- * features if needed.
33
+ * NOTE: This feature can be disabled and in that case, accessing this subtree
34
+ * directly will return undefined and will need a bunch of repetitive type
35
+ * checks in other features. Make sure you take care of those checks, or
36
+ * consider using the {@code isCalendarEnabled} value to gate features if
37
+ * needed.
23 38
  */
24 39
 const STORE_NAME = 'features/calendar-sync';
25 40
 
@@ -31,12 +46,14 @@ const STORE_NAME = 'features/calendar-sync';
31 46
  * runtime value to see if we need to re-request the calendar permission from
32 47
  * the user.
33 48
  */
34
-CALENDAR_ENABLED
49
+isCalendarEnabled()
35 50
     && PersistenceRegistry.register(STORE_NAME, {
36
-        knownDomains: true
51
+        integrationType: true,
52
+        knownDomains: true,
53
+        msAuthState: true
37 54
     });
38 55
 
39
-CALENDAR_ENABLED
56
+isCalendarEnabled()
40 57
     && ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STATE, action) => {
41 58
         switch (action.type) {
42 59
         case APP_WILL_MOUNT:
@@ -49,11 +66,36 @@ CALENDAR_ENABLED
49 66
             }
50 67
             break;
51 68
 
69
+        case CLEAR_CALENDAR_INTEGRATION:
70
+            return DEFAULT_STATE;
71
+
72
+        case SET_CALENDAR_AUTH_STATE: {
73
+            if (!action.msAuthState) {
74
+                // received request to delete the state
75
+                return set(state, 'msAuthState', undefined);
76
+            }
77
+
78
+            return set(state, 'msAuthState', {
79
+                ...state.msAuthState,
80
+                ...action.msAuthState
81
+            });
82
+        }
83
+
52 84
         case SET_CALENDAR_AUTHORIZATION:
53 85
             return set(state, 'authorization', action.authorization);
54 86
 
55 87
         case SET_CALENDAR_EVENTS:
56 88
             return set(state, 'events', action.events);
89
+
90
+        case SET_CALENDAR_INTEGRATION:
91
+            return {
92
+                ...state,
93
+                integrationReady: action.integrationReady,
94
+                integrationType: action.integrationType
95
+            };
96
+
97
+        case SET_CALENDAR_PROFILE_EMAIL:
98
+            return set(state, 'profileEmail', action.email);
57 99
         }
58 100
 
59 101
         return state;

+ 66
- 0
react/features/calendar-sync/web/googleCalendar.js View File

@@ -0,0 +1,66 @@
1
+/* @flow */
2
+
3
+import {
4
+    getCalendarEntries,
5
+    googleApi,
6
+    loadGoogleAPI,
7
+    signIn,
8
+    updateProfile
9
+} from '../../google-api';
10
+
11
+/**
12
+ * A stateless collection of action creators that implements the expected
13
+ * interface for interacting with the Google API in order to get calendar data.
14
+ *
15
+ * @type {Object}
16
+ */
17
+export const googleCalendarApi = {
18
+    /**
19
+     * Retrieves the current calendar events.
20
+     *
21
+     * @param {number} fetchStartDays - The number of days to go back
22
+     * when fetching.
23
+     * @param {number} fetchEndDays - The number of days to fetch.
24
+     * @returns {function(): Promise<CalendarEntries>}
25
+     */
26
+    getCalendarEntries,
27
+
28
+    /**
29
+     * Returns the email address for the currently logged in user.
30
+     *
31
+     * @returns {function(Dispatch<*>): Promise<string|never>}
32
+     */
33
+    getCurrentEmail() {
34
+        return updateProfile();
35
+    },
36
+
37
+    /**
38
+     * Initializes the google api if needed.
39
+     *
40
+     * @returns {function(Dispatch<*>, Function): Promise<void>}
41
+     */
42
+    load() {
43
+        return (dispatch: Dispatch<*>, getState: Function) => {
44
+            const { googleApiApplicationClientID }
45
+                = getState()['features/base/config'];
46
+
47
+            return dispatch(loadGoogleAPI(googleApiApplicationClientID));
48
+        };
49
+    },
50
+
51
+    /**
52
+     * Prompts the participant to sign in to the Google API Client Library.
53
+     *
54
+     * @returns {function(Dispatch<*>): Promise<string|never>}
55
+     */
56
+    signIn,
57
+
58
+    /**
59
+     * Returns whether or not the user is currently signed in.
60
+     *
61
+     * @returns {function(): Promise<boolean>}
62
+     */
63
+    _isSignedIn() {
64
+        return () => googleApi.isSignedIn();
65
+    }
66
+};

+ 531
- 0
react/features/calendar-sync/web/microsoftCalendar.js View File

@@ -0,0 +1,531 @@
1
+/* @flow */
2
+
3
+import { Client } from '@microsoft/microsoft-graph-client';
4
+import rs from 'jsrsasign';
5
+
6
+import { createDeferred } from '../../../../modules/util/helpers';
7
+
8
+import parseURLParams from '../../base/config/parseURLParams';
9
+import { parseStandardURIString } from '../../base/util';
10
+
11
+import { setCalendarAPIAuthState } from '../actions';
12
+
13
+/**
14
+ * Constants used for interacting with the Microsoft API.
15
+ *
16
+ * @private
17
+ * @type {object}
18
+ */
19
+const MS_API_CONFIGURATION = {
20
+    /**
21
+     * The URL to use when authenticating using Microsoft API.
22
+     * @type {string}
23
+     */
24
+    AUTH_ENDPOINT:
25
+        'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?',
26
+
27
+    CALENDAR_ENDPOINT: '/me/calendars',
28
+
29
+    /**
30
+     * The Microsoft API scopes to request access for calendar.
31
+     *
32
+     * @type {string}
33
+     */
34
+    MS_API_SCOPES: 'openid profile Calendars.Read',
35
+
36
+    /**
37
+     * See https://docs.microsoft.com/en-us/azure/active-directory/develop/
38
+     * v2-oauth2-implicit-grant-flow#send-the-sign-in-request. This value is
39
+     * needed for passing in the proper domain_hint value when trying to refresh
40
+     * a token silently.
41
+     *
42
+     *
43
+     * @type {string}
44
+     */
45
+    MS_CONSUMER_TENANT: '9188040d-6c67-4c5b-b112-36a304b66dad',
46
+
47
+    /**
48
+     * The redirect URL to be used by the Microsoft API on successful
49
+     * authentication.
50
+     *
51
+     * @type {string}
52
+     */
53
+    REDIRECT_URI: `${window.location.origin}/static/msredirect.html`
54
+};
55
+
56
+/**
57
+ * Store the window from an auth request. That way it can be reused if a new
58
+ * request comes in and it can be used to indicate a request is in progress.
59
+ *
60
+ * @private
61
+ * @type {Object|null}
62
+ */
63
+let popupAuthWindow = null;
64
+
65
+/**
66
+ * A stateless collection of action creators that implements the expected
67
+ * interface for interacting with the Microsoft API in order to get calendar
68
+ * data.
69
+ *
70
+ * @type {Object}
71
+ */
72
+export const microsoftCalendarApi = {
73
+    /**
74
+     * Retrieves the current calendar events.
75
+     *
76
+     * @param {number} fetchStartDays - The number of days to go back
77
+     * when fetching.
78
+     * @param {number} fetchEndDays - The number of days to fetch.
79
+     * @returns {function(Dispatch<*>, Function): Promise<CalendarEntries>}
80
+     */
81
+    getCalendarEntries(fetchStartDays: ?number, fetchEndDays: ?number) {
82
+        return (dispatch: Dispatch<*>, getState: Function): Promise<*> => {
83
+            const state = getState()['features/calendar-sync'] || {};
84
+            const token = state.msAuthState && state.msAuthState.accessToken;
85
+
86
+            if (!token) {
87
+                return Promise.reject('Not authorized, please sign in!');
88
+            }
89
+
90
+            const client = Client.init({
91
+                authProvider: done => done(null, token)
92
+            });
93
+
94
+            return client
95
+                .api(MS_API_CONFIGURATION.CALENDAR_ENDPOINT)
96
+                .get()
97
+                .then(response => {
98
+                    const calendarIds = response.value.map(en => en.id);
99
+                    const getEventsPromises = calendarIds.map(id =>
100
+                        requestCalendarEvents(
101
+                            client, id, fetchStartDays, fetchEndDays));
102
+
103
+                    return Promise.all(getEventsPromises);
104
+                })
105
+
106
+                // get .value of every element from the array of results,
107
+                // which is an array of events and flatten it to one array
108
+                // of events
109
+                .then(result => [].concat(...result.map(en => en.value)))
110
+                .then(entries => entries.map(e => formatCalendarEntry(e)));
111
+        };
112
+    },
113
+
114
+    /**
115
+     * Returns the email address for the currently logged in user.
116
+     *
117
+     * @returns {function(Dispatch<*, Function>): Promise<string>}
118
+     */
119
+    getCurrentEmail(): Function {
120
+        return (dispatch: Dispatch<*>, getState: Function) => {
121
+            const { msAuthState = {} }
122
+                = getState()['features/calendar-sync'] || {};
123
+            const email = msAuthState.userSigninName || '';
124
+
125
+            return Promise.resolve(email);
126
+        };
127
+    },
128
+
129
+    /**
130
+     * Sets the application ID to use for interacting with the Microsoft API.
131
+     *
132
+     * @returns {function(): Promise<void>}
133
+     */
134
+    load(): Function {
135
+        return () => Promise.resolve();
136
+    },
137
+
138
+    /**
139
+     * Prompts the participant to sign in to the Microsoft API Client Library.
140
+     *
141
+     * @returns {function(Dispatch<*>, Function): Promise<void>}
142
+     */
143
+    signIn(): Function {
144
+        return (dispatch: Dispatch<*>, getState: Function) => {
145
+            // Ensure only one popup window at a time.
146
+            if (popupAuthWindow) {
147
+                popupAuthWindow.focus();
148
+
149
+                return Promise.reject('Sign in already in progress.');
150
+            }
151
+
152
+            const signInDeferred = createDeferred();
153
+
154
+            const guids = {
155
+                authState: generateGuid(),
156
+                authNonce: generateGuid()
157
+            };
158
+
159
+            dispatch(setCalendarAPIAuthState(guids));
160
+
161
+            const { microsoftApiApplicationClientID }
162
+                = getState()['features/base/config'];
163
+            const authUrl = getAuthUrl(
164
+                microsoftApiApplicationClientID,
165
+                guids.authState,
166
+                guids.authNonce);
167
+            const h = 600;
168
+            const w = 480;
169
+
170
+            popupAuthWindow = window.open(
171
+                authUrl,
172
+                'Auth M$',
173
+                `width=${w}, height=${h}, top=${
174
+                    (screen.height / 2) - (h / 2)}, left=${
175
+                    (screen.width / 2) - (w / 2)}`);
176
+
177
+            const windowCloseCheck = setInterval(() => {
178
+                if (popupAuthWindow && popupAuthWindow.closed) {
179
+                    signInDeferred.reject(
180
+                        'Popup closed before completing auth.');
181
+                    popupAuthWindow = null;
182
+                    window.removeEventListener('message', handleAuth);
183
+                    clearInterval(windowCloseCheck);
184
+                } else if (!popupAuthWindow) {
185
+                    // This case probably happened because the user completed
186
+                    // auth.
187
+                    clearInterval(windowCloseCheck);
188
+                }
189
+            }, 500);
190
+
191
+            /**
192
+             * Callback with scope access to other variables that are part of
193
+             * the sign in request.
194
+             *
195
+             * @param {Object} event - The event from the post message.
196
+             * @private
197
+             * @returns {void}
198
+             */
199
+            function handleAuth({ data }) {
200
+                if (!data || data.type !== 'ms-login') {
201
+                    return;
202
+                }
203
+
204
+                window.removeEventListener('message', handleAuth);
205
+
206
+                popupAuthWindow && popupAuthWindow.close();
207
+                popupAuthWindow = null;
208
+
209
+                const params = getParamsFromHash(data.url);
210
+                const tokenParts = getValidatedTokenParts(
211
+                    params, guids, microsoftApiApplicationClientID);
212
+
213
+                if (!tokenParts) {
214
+                    signInDeferred.reject('Invalid token received');
215
+
216
+                    return;
217
+                }
218
+
219
+                dispatch(setCalendarAPIAuthState({
220
+                    authState: undefined,
221
+                    accessToken: tokenParts.accessToken,
222
+                    idToken: tokenParts.idToken,
223
+                    tokenExpires: params.tokenExpires,
224
+                    userDomainType: tokenParts.userDomainType,
225
+                    userSigninName: tokenParts.userSigninName
226
+                }));
227
+
228
+                signInDeferred.resolve();
229
+            }
230
+
231
+            window.addEventListener('message', handleAuth);
232
+
233
+            return signInDeferred.promise;
234
+        };
235
+    },
236
+
237
+    /**
238
+     * Returns whether or not the user is currently signed in.
239
+     *
240
+     * @returns {function(Dispatch<*>, Function): Promise<boolean>}
241
+     */
242
+    _isSignedIn(): Function {
243
+        return (dispatch: Dispatch<*>, getState: Function) => {
244
+            const now = new Date().getTime();
245
+            const state
246
+                = getState()['features/calendar-sync'].msAuthState || {};
247
+            const tokenExpires = parseInt(state.tokenExpires, 10);
248
+            const isExpired = now > tokenExpires && !isNaN(tokenExpires);
249
+
250
+            if (state.accessToken && isExpired) {
251
+                // token expired, let's refresh it
252
+                return dispatch(this._refreshAuthToken())
253
+                    .then(() => true)
254
+                    .catch(() => false);
255
+            }
256
+
257
+            return Promise.resolve(state.accessToken && !isExpired);
258
+        };
259
+    },
260
+
261
+    /**
262
+     * Renews an existing auth token so it can continue to be used.
263
+     *
264
+     * @private
265
+     * @returns {function(Dispatch<*>, Function): Promise<void>}
266
+     */
267
+    _refreshAuthToken(): Function {
268
+        return (dispatch: Dispatch<*>, getState: Function) => {
269
+            const { microsoftApiApplicationClientID }
270
+                = getState()['features/base/config'];
271
+            const { msAuthState = {} }
272
+                = getState()['features/calendar-sync'] || {};
273
+
274
+            const refreshAuthUrl = getAuthRefreshUrl(
275
+                microsoftApiApplicationClientID,
276
+                msAuthState.userDomainType,
277
+                msAuthState.userSigninName);
278
+
279
+            const iframe = document.createElement('iframe');
280
+
281
+            iframe.setAttribute('id', 'auth-iframe');
282
+            iframe.setAttribute('name', 'auth-iframe');
283
+            iframe.setAttribute('style', 'display: none');
284
+            iframe.setAttribute('src', refreshAuthUrl);
285
+
286
+            const signInPromise = new Promise(resolve => {
287
+                iframe.onload = () => {
288
+                    resolve(iframe.contentWindow.location.hash);
289
+                };
290
+            });
291
+
292
+            // The check for body existence is done for flow, which also runs
293
+            // against native where document.body may not be defined.
294
+            if (!document.body) {
295
+                return Promise.reject(
296
+                    'Cannot refresh auth token in this environment');
297
+            }
298
+
299
+            document.body.appendChild(iframe);
300
+
301
+            return signInPromise.then(hash => {
302
+                const params = getParamsFromHash(hash);
303
+
304
+                dispatch(setCalendarAPIAuthState({
305
+                    accessToken: params.access_token,
306
+                    idToken: params.id_token,
307
+                    tokenExpires: params.tokenExpires
308
+                }));
309
+            });
310
+        };
311
+    }
312
+};
313
+
314
+/**
315
+ * Parses the Microsoft calendar entries to a known format.
316
+ *
317
+ * @param {Object} entry - The Microsoft calendar entry.
318
+ * @private
319
+ * @returns {{
320
+ *     description: string,
321
+ *     endDate: string,
322
+ *     id: string,
323
+ *     location: string,
324
+ *     startDate: string,
325
+ *     title: string
326
+ * }}
327
+ */
328
+function formatCalendarEntry(entry) {
329
+    return {
330
+        description: entry.body.content,
331
+        endDate: entry.end.dateTime,
332
+        id: entry.id,
333
+        location: entry.location.displayName,
334
+        startDate: entry.start.dateTime,
335
+        title: entry.subject
336
+    };
337
+}
338
+
339
+/**
340
+ * Generate a guid to be used for verifying token validity.
341
+ *
342
+ * @private
343
+ * @returns {string} The generated string.
344
+ */
345
+function generateGuid() {
346
+    const buf = new Uint16Array(8);
347
+
348
+    window.crypto.getRandomValues(buf);
349
+
350
+    return `${s4(buf[0])}${s4(buf[1])}-${s4(buf[2])}-${s4(buf[3])}-${
351
+        s4(buf[4])}-${s4(buf[5])}${s4(buf[6])}${s4(buf[7])}`;
352
+}
353
+
354
+/**
355
+ * Constructs and returns the URL to use for renewing an auth token.
356
+ *
357
+ * @param {string} appId - The Microsoft application id to log into.
358
+ * @param {string} userDomainType - The domain type of the application as
359
+ * provided by Microsoft.
360
+ * @param {string} userSigninName - The email of the user signed into the
361
+ * integration with Microsoft.
362
+ * @private
363
+ * @returns {string} - The auth URL.
364
+ */
365
+function getAuthRefreshUrl(appId, userDomainType, userSigninName) {
366
+    return [
367
+        getAuthUrl(appId, 'undefined', 'undefined'),
368
+        'prompt=none',
369
+        `domain_hint=${userDomainType}`,
370
+        `login_hint=${userSigninName}`
371
+    ].join('&');
372
+}
373
+
374
+/**
375
+ * Constructs and returns the auth URL to use for login.
376
+ *
377
+ * @param {string} appId - The Microsoft application id to log into.
378
+ * @param {string} authState - The authState guid to use.
379
+ * @param {string} authNonce - The authNonce guid to use.
380
+ * @private
381
+ * @returns {string} - The auth URL.
382
+ */
383
+function getAuthUrl(appId, authState, authNonce) {
384
+    const authParams = [
385
+        'response_type=id_token+token',
386
+        `client_id=${appId}`,
387
+        `redirect_uri=${MS_API_CONFIGURATION.REDIRECT_URI}`,
388
+        `scope=${MS_API_CONFIGURATION.MS_API_SCOPES}`,
389
+        `state=${authState}`,
390
+        `nonce=${authNonce}`,
391
+        'response_mode=fragment'
392
+    ].join('&');
393
+
394
+    return `${MS_API_CONFIGURATION.AUTH_ENDPOINT}${authParams}`;
395
+}
396
+
397
+/**
398
+ * Converts a url from an auth redirect into an object of parameters passed
399
+ * into the url.
400
+ *
401
+ * @param {string} url - The string to parse.
402
+ * @private
403
+ * @returns {Object}
404
+ */
405
+function getParamsFromHash(url) {
406
+    const params = parseURLParams(parseStandardURIString(url), true, 'hash');
407
+
408
+    // Get the number of seconds the token is valid for, subtract 5 minutes
409
+    // to account for differences in clock settings and convert to ms.
410
+    const expiresIn = (parseInt(params.expires_in, 10) - 300) * 1000;
411
+    const now = new Date();
412
+    const expireDate = new Date(now.getTime() + expiresIn);
413
+
414
+    params.tokenExpires = expireDate.getTime().toString();
415
+
416
+    return params;
417
+}
418
+
419
+/**
420
+ * Converts the parameters from a Microsoft auth redirect into an object of
421
+ * token parts. The value "null" will be returned if the params do not produce
422
+ * a valid token.
423
+ *
424
+ * @param {Object} tokenInfo - The token object.
425
+ * @param {Object} guids - The guids for authState and authNonce that should
426
+ * match in the token.
427
+ * @param {Object} appId - The Microsoft application this token is for.
428
+ * @private
429
+ * @returns {Object|null}
430
+ */
431
+function getValidatedTokenParts(tokenInfo, guids, appId) {
432
+    // Make sure the token matches the request source by matching the GUID.
433
+    if (tokenInfo.state !== guids.authState) {
434
+        return null;
435
+    }
436
+
437
+    const idToken = tokenInfo.id_token;
438
+
439
+    // A token must exist to be valid.
440
+    if (!idToken) {
441
+        return null;
442
+    }
443
+
444
+    const tokenParts = idToken.split('.');
445
+
446
+    if (tokenParts.length !== 3) {
447
+        return null;
448
+    }
449
+
450
+    const payload
451
+         = rs.KJUR.jws.JWS.readSafeJSONString(rs.b64utoutf8(tokenParts[1]));
452
+
453
+    if (payload.nonce !== guids.authNonce
454
+        || payload.aud !== appId
455
+        || payload.iss
456
+            !== `https://login.microsoftonline.com/${payload.tid}/v2.0`) {
457
+        return null;
458
+    }
459
+
460
+    const now = new Date();
461
+
462
+    // Adjust by 5 minutes to allow for inconsistencies in system clocks.
463
+    const notBefore = new Date((payload.nbf - 300) * 1000);
464
+    const expires = new Date((payload.exp + 300) * 1000);
465
+
466
+    if (now < notBefore || now > expires) {
467
+        return null;
468
+    }
469
+
470
+    return {
471
+        accessToken: tokenInfo.access_token,
472
+        idToken,
473
+        userDisplayName: payload.name,
474
+        userDomainType:
475
+            payload.tid === MS_API_CONFIGURATION.MS_CONSUMER_TENANT
476
+                ? 'consumers' : 'organizations',
477
+        userSigninName: payload.preferred_username
478
+    };
479
+}
480
+
481
+/**
482
+ * Retrieves calendar entries from a specific calendar.
483
+ *
484
+ * @param {Object} client - The Microsoft-graph-client initialized.
485
+ * @param {string} calendarId - The calendar ID to use.
486
+ * @param {number} fetchStartDays - The number of days to go back
487
+ * when fetching.
488
+ * @param {number} fetchEndDays - The number of days to fetch.
489
+ * @returns {Promise<any> | Promise}
490
+ * @private
491
+ */
492
+function requestCalendarEvents( // eslint-disable-line max-params
493
+        client,
494
+        calendarId,
495
+        fetchStartDays,
496
+        fetchEndDays): Promise<*> {
497
+    const startDate = new Date();
498
+    const endDate = new Date();
499
+
500
+    startDate.setDate(startDate.getDate() + fetchStartDays);
501
+    endDate.setDate(endDate.getDate() + fetchEndDays);
502
+
503
+    const filter = `Start/DateTime ge '${
504
+        startDate.toISOString()}' and End/DateTime lt '${
505
+        endDate.toISOString()}'`;
506
+
507
+    return client
508
+        .api(`/me/calendars/${calendarId}/events`)
509
+        .filter(filter)
510
+        .select('id,subject,start,end,location,body')
511
+        .orderby('createdDateTime DESC')
512
+        .get();
513
+}
514
+
515
+/**
516
+ * Converts the passed in number to a string and ensure it is at least 4
517
+ * characters in length, prepending 0's as needed.
518
+ *
519
+ * @param {number} num - The number to pad and convert to a string.
520
+ * @private
521
+ * @returns {string} - The number converted to a string.
522
+ */
523
+function s4(num) {
524
+    let ret = num.toString(16);
525
+
526
+    while (ret.length < 4) {
527
+        ret = `0${ret}`;
528
+    }
529
+
530
+    return ret;
531
+}

+ 82
- 35
react/features/google-api/actions.js View File

@@ -7,6 +7,21 @@ import {
7 7
 import { GOOGLE_API_STATES } from './constants';
8 8
 import googleApi from './googleApi';
9 9
 
10
+/**
11
+ * Retrieves the current calendar events.
12
+ *
13
+ * @param {number} fetchStartDays - The number of days to go back when fetching.
14
+ * @param {number} fetchEndDays - The number of days to fetch.
15
+ * @returns {function(Dispatch<*>): Promise<CalendarEntries>}
16
+ */
17
+export function getCalendarEntries(
18
+        fetchStartDays: ?number, fetchEndDays: ?number) {
19
+    return () =>
20
+        googleApi.get()
21
+        .then(() =>
22
+            googleApi._getCalendarEntries(fetchStartDays, fetchEndDays));
23
+}
24
+
10 25
 /**
11 26
  * Loads Google API.
12 27
  *
@@ -14,9 +29,16 @@ import googleApi from './googleApi';
14 29
  * @returns {Function}
15 30
  */
16 31
 export function loadGoogleAPI(clientId: string) {
17
-    return (dispatch: Dispatch<*>) =>
32
+    return (dispatch: Dispatch<*>, getState: Function) =>
18 33
         googleApi.get()
19
-        .then(() => googleApi.initializeClient(clientId))
34
+        .then(() => {
35
+            if (getState()['features/google-api'].googleAPIState
36
+                    === GOOGLE_API_STATES.NEEDS_LOADING) {
37
+                return googleApi.initializeClient(clientId);
38
+            }
39
+
40
+            return Promise.resolve();
41
+        })
20 42
         .then(() => dispatch({
21 43
             type: SET_GOOGLE_API_STATE,
22 44
             googleAPIState: GOOGLE_API_STATES.LOADED }))
@@ -30,39 +52,6 @@ export function loadGoogleAPI(clientId: string) {
30 52
         });
31 53
 }
32 54
 
33
-/**
34
- * Prompts the participant to sign in to the Google API Client Library.
35
- *
36
- * @returns {function(Dispatch<*>): Promise<string | never>}
37
- */
38
-export function signIn() {
39
-    return (dispatch: Dispatch<*>) => googleApi.get()
40
-            .then(() => googleApi.signInIfNotSignedIn())
41
-            .then(() => dispatch({
42
-                type: SET_GOOGLE_API_STATE,
43
-                googleAPIState: GOOGLE_API_STATES.SIGNED_IN
44
-            }));
45
-}
46
-
47
-/**
48
- * Updates the profile data that is currently used.
49
- *
50
- * @returns {function(Dispatch<*>): Promise<string | never>}
51
- */
52
-export function updateProfile() {
53
-    return (dispatch: Dispatch<*>) => googleApi.get()
54
-        .then(() => googleApi.signInIfNotSignedIn())
55
-        .then(() => dispatch({
56
-            type: SET_GOOGLE_API_STATE,
57
-            googleAPIState: GOOGLE_API_STATES.SIGNED_IN
58
-        }))
59
-        .then(() => googleApi.getCurrentUserProfile())
60
-        .then(profile => dispatch({
61
-            type: SET_GOOGLE_API_PROFILE,
62
-            profileEmail: profile.getEmail()
63
-        }));
64
-}
65
-
66 55
 /**
67 56
  * Executes a request for a list of all YouTube broadcasts associated with
68 57
  * user currently signed in to the Google API Client Library.
@@ -137,3 +126,61 @@ export function showAccountSelection() {
137 126
     return () =>
138 127
         googleApi.showAccountSelection();
139 128
 }
129
+
130
+/**
131
+ * Prompts the participant to sign in to the Google API Client Library.
132
+ *
133
+ * @returns {function(Dispatch<*>): Promise<string | never>}
134
+ */
135
+export function signIn() {
136
+    return (dispatch: Dispatch<*>) => googleApi.get()
137
+            .then(() => googleApi.signInIfNotSignedIn())
138
+            .then(() => dispatch({
139
+                type: SET_GOOGLE_API_STATE,
140
+                googleAPIState: GOOGLE_API_STATES.SIGNED_IN
141
+            }));
142
+}
143
+
144
+/**
145
+ * Logs out the user.
146
+ *
147
+ * @returns {function(Dispatch<*>): Promise<string | never>}
148
+ */
149
+export function signOut() {
150
+    return (dispatch: Dispatch<*>) =>
151
+        googleApi.get()
152
+            .then(() => googleApi.signOut())
153
+            .then(() => {
154
+                dispatch({
155
+                    type: SET_GOOGLE_API_STATE,
156
+                    googleAPIState: GOOGLE_API_STATES.LOADED
157
+                });
158
+                dispatch({
159
+                    type: SET_GOOGLE_API_PROFILE,
160
+                    profileEmail: ''
161
+                });
162
+            });
163
+}
164
+
165
+/**
166
+ * Updates the profile data that is currently used.
167
+ *
168
+ * @returns {function(Dispatch<*>): Promise<string | never>}
169
+ */
170
+export function updateProfile() {
171
+    return (dispatch: Dispatch<*>) => googleApi.get()
172
+        .then(() => googleApi.signInIfNotSignedIn())
173
+        .then(() => dispatch({
174
+            type: SET_GOOGLE_API_STATE,
175
+            googleAPIState: GOOGLE_API_STATES.SIGNED_IN
176
+        }))
177
+        .then(() => googleApi.getCurrentUserProfile())
178
+        .then(profile => {
179
+            dispatch({
180
+                type: SET_GOOGLE_API_PROFILE,
181
+                profileEmail: profile.getEmail()
182
+            });
183
+
184
+            return profile.getEmail();
185
+        });
186
+}

+ 0
- 0
react/features/google-api/components/GoogleSignInButton.native.js View File


react/features/recording/components/LiveStream/GoogleSignInButton.web.js → react/features/google-api/components/GoogleSignInButton.web.js View File

@@ -1,29 +1,25 @@
1
-import PropTypes from 'prop-types';
1
+// @flow
2
+
2 3
 import React, { Component } from 'react';
3 4
 
5
+/**
6
+ * The type of the React {@code Component} props of {@link GoogleSignInButton}.
7
+ */
8
+type Props = {
9
+
10
+    // The callback to invoke when {@code GoogleSignInButton} is clicked.
11
+    onClick: Function,
12
+
13
+    // The text to display within {@code GoogleSignInButton}.
14
+    text: string
15
+};
16
+
4 17
 /**
5 18
  * A React Component showing a button to sign in with Google.
6 19
  *
7 20
  * @extends Component
8 21
  */
9
-export default class GoogleSignInButton extends Component {
10
-    /**
11
-     * {@code GoogleSignInButton} component's property types.
12
-     *
13
-     * @static
14
-     */
15
-    static propTypes = {
16
-        /**
17
-         * The callback to invoke when the button is clicked.
18
-         */
19
-        onClick: PropTypes.func,
20
-
21
-        /**
22
-         * The text to display in the button.
23
-         */
24
-        text: PropTypes.string
25
-    };
26
-
22
+export default class GoogleSignInButton extends Component<Props> {
27 23
     /**
28 24
      * Implements React's {@link Component#render()}.
29 25
      *

+ 1
- 0
react/features/google-api/components/index.js View File

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

+ 11
- 2
react/features/google-api/constants.js View File

@@ -1,14 +1,23 @@
1 1
 // @flow
2 2
 
3 3
 /**
4
- * The Google API scopes to request access to for streaming.
4
+ * The Google API scopes to request access for streaming and calendar.
5 5
  *
6 6
  * @type {Array<string>}
7 7
  */
8 8
 export const GOOGLE_API_SCOPES = [
9
-    'https://www.googleapis.com/auth/youtube.readonly'
9
+    'https://www.googleapis.com/auth/youtube.readonly',
10
+    'https://www.googleapis.com/auth/calendar'
10 11
 ];
11 12
 
13
+/**
14
+ * Array of API discovery doc URLs for APIs used by the googleApi.
15
+ *
16
+ * @type {string[]}
17
+ */
18
+export const DISCOVERY_DOCS
19
+    = [ 'https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest' ];
20
+
12 21
 /**
13 22
  * An enumeration of the different states the Google API can be in.
14 23
  *

+ 96
- 1
react/features/google-api/googleApi.js View File

@@ -1,4 +1,4 @@
1
-import { GOOGLE_API_SCOPES } from './constants';
1
+import { GOOGLE_API_SCOPES, DISCOVERY_DOCS } from './constants';
2 2
 
3 3
 const GOOGLE_API_CLIENT_LIBRARY_URL = 'https://apis.google.com/js/api.js';
4 4
 
@@ -67,6 +67,7 @@ const googleApi = {
67 67
                 setTimeout(() => {
68 68
                     api.client.init({
69 69
                         clientId,
70
+                        discoveryDocs: DISCOVERY_DOCS,
70 71
                         scope: GOOGLE_API_SCOPES.join(' ')
71 72
                     })
72 73
                     .then(resolve)
@@ -86,6 +87,7 @@ const googleApi = {
86 87
             .then(api => Boolean(api
87 88
                 && api.auth2
88 89
                 && api.auth2.getAuthInstance
90
+                && api.auth2.getAuthInstance()
89 91
                 && api.auth2.getAuthInstance().isSignedIn
90 92
                 && api.auth2.getAuthInstance().isSignedIn.get()));
91 93
     },
@@ -183,6 +185,99 @@ const googleApi = {
183 185
             });
184 186
     },
185 187
 
188
+    /**
189
+     * Sign out from the Google API Client Library.
190
+     *
191
+     * @returns {Promise}
192
+     */
193
+    signOut() {
194
+        return this.get()
195
+            .then(api =>
196
+                api.auth2
197
+                && api.auth2.getAuthInstance
198
+                && api.auth2.getAuthInstance()
199
+                && api.auth2.getAuthInstance().signOut());
200
+    },
201
+
202
+    /**
203
+     * Parses the google calendar entries to a known format.
204
+     *
205
+     * @param {Object} entry - The google calendar entry.
206
+     * @returns {{
207
+     *  id: string,
208
+     *  startDate: string,
209
+     *  endDate: string,
210
+     *  title: string,
211
+     *  location: string,
212
+     *  description: string}}
213
+     * @private
214
+     */
215
+    _convertCalendarEntry(entry) {
216
+        return {
217
+            id: entry.id,
218
+            startDate: entry.start.dateTime,
219
+            endDate: entry.end.dateTime,
220
+            title: entry.summary,
221
+            location: entry.location,
222
+            description: entry.description
223
+        };
224
+    },
225
+
226
+    /**
227
+     * Retrieves calendar entries from all available calendars.
228
+     *
229
+     * @param {number} fetchStartDays - The number of days to go back
230
+     * when fetching.
231
+     * @param {number} fetchEndDays - The number of days to fetch.
232
+     * @returns {Promise<CalendarEntry>}
233
+     * @private
234
+     */
235
+    _getCalendarEntries(fetchStartDays, fetchEndDays) {
236
+        return this.get()
237
+            .then(() => this.isSignedIn())
238
+            .then(isSignedIn => {
239
+                if (!isSignedIn) {
240
+                    return null;
241
+                }
242
+
243
+                return this._getGoogleApiClient()
244
+                    .client.calendar.calendarList.list();
245
+            })
246
+            .then(calendarList => {
247
+
248
+                // no result, maybe not signed in
249
+                if (!calendarList) {
250
+                    return Promise.resolve();
251
+                }
252
+
253
+                const calendarIds
254
+                    = calendarList.result.items.map(en => en.id);
255
+                const promises = calendarIds.map(id => {
256
+                    const startDate = new Date();
257
+                    const endDate = new Date();
258
+
259
+                    startDate.setDate(startDate.getDate() + fetchStartDays);
260
+                    endDate.setDate(endDate.getDate() + fetchEndDays);
261
+
262
+                    return this._getGoogleApiClient()
263
+                        .client.calendar.events.list({
264
+                            'calendarId': id,
265
+                            'timeMin': startDate.toISOString(),
266
+                            'timeMax': endDate.toISOString(),
267
+                            'showDeleted': false,
268
+                            'singleEvents': true,
269
+                            'orderBy': 'startTime'
270
+                        });
271
+                });
272
+
273
+                return Promise.all(promises)
274
+                    .then(results =>
275
+                        [].concat(...results.map(rItem => rItem.result.items)))
276
+                    .then(entries =>
277
+                        entries.map(e => this._convertCalendarEntry(e)));
278
+            });
279
+    },
280
+
186 281
     /**
187 282
      * Returns the global Google API Client Library object. Direct use of this
188 283
      * method is discouraged; instead use the {@link get} method.

+ 2
- 1
react/features/google-api/index.js View File

@@ -1,5 +1,6 @@
1 1
 export { GOOGLE_API_STATES } from './constants';
2
-export * from './googleApi';
2
+export { default as googleApi } from './googleApi';
3 3
 export * from './actions';
4
+export * from './components';
4 5
 
5 6
 import './reducer';

+ 3
- 3
react/features/recording/components/LiveStream/StartLiveStreamDialog.web.js View File

@@ -7,13 +7,14 @@ import { connect } from 'react-redux';
7 7
 import { translate } from '../../../base/i18n';
8 8
 
9 9
 import {
10
-    updateProfile,
11 10
     GOOGLE_API_STATES,
11
+    GoogleSignInButton,
12 12
     loadGoogleAPI,
13 13
     requestAvailableYouTubeBroadcasts,
14 14
     requestLiveStreamsForYouTubeBroadcast,
15 15
     showAccountSelection,
16
-    signIn
16
+    signIn,
17
+    updateProfile
17 18
 } from '../../../google-api';
18 19
 
19 20
 import AbstractStartLiveStreamDialog, {
@@ -21,7 +22,6 @@ import AbstractStartLiveStreamDialog, {
21 22
     type Props
22 23
 } from './AbstractStartLiveStreamDialog';
23 24
 import BroadcastsDropdown from './BroadcastsDropdown';
24
-import GoogleSignInButton from './GoogleSignInButton';
25 25
 import StreamKeyForm from './StreamKeyForm';
26 26
 
27 27
 /**

+ 301
- 0
react/features/settings/components/web/CalendarTab.js View File

@@ -0,0 +1,301 @@
1
+// @flow
2
+
3
+import Button from '@atlaskit/button';
4
+import Spinner from '@atlaskit/spinner';
5
+import React, { Component } from 'react';
6
+import { connect } from 'react-redux';
7
+
8
+import { translate } from '../../../base/i18n';
9
+import {
10
+    CALENDAR_TYPE,
11
+    MicrosoftSignInButton,
12
+    clearCalendarIntegration,
13
+    bootstrapCalendarIntegration,
14
+    isCalendarEnabled,
15
+    signIn
16
+} from '../../../calendar-sync';
17
+import { GoogleSignInButton } from '../../../google-api';
18
+
19
+const logger = require('jitsi-meet-logger').getLogger(__filename);
20
+
21
+declare var interfaceConfig: Object;
22
+
23
+/**
24
+ * The type of the React {@code Component} props of {@link CalendarTab}.
25
+ */
26
+type Props = {
27
+
28
+    /**
29
+     * The name given to this Jitsi Application.
30
+     */
31
+    _appName: string,
32
+
33
+    /**
34
+     * Whether or not to display a button to sign in to Google.
35
+     */
36
+    _enableGoogleIntegration: boolean,
37
+
38
+    /**
39
+     * Whether or not to display a button to sign in to Microsoft.
40
+     */
41
+    _enableMicrosoftIntegration: boolean,
42
+
43
+    /**
44
+     * The current calendar integration in use, if any.
45
+     */
46
+    _isConnectedToCalendar: boolean,
47
+
48
+    /**
49
+     * The email address associated with the calendar integration in use.
50
+     */
51
+    _profileEmail: string,
52
+
53
+    /**
54
+     * Invoked to change the configured calendar integration.
55
+     */
56
+    dispatch: Function,
57
+
58
+    /**
59
+     * Invoked to obtain translated strings.
60
+     */
61
+    t: Function
62
+};
63
+
64
+/**
65
+ * The type of the React {@code Component} state of {@link CalendarTab}.
66
+ */
67
+type State = {
68
+
69
+    /**
70
+     * Whether or not any third party APIs are being loaded.
71
+     */
72
+    loading: boolean
73
+};
74
+
75
+/**
76
+ * React {@code Component} for modifying calendar integration.
77
+ *
78
+ * @extends Component
79
+ */
80
+class CalendarTab extends Component<Props, State> {
81
+    /**
82
+     * Initializes a new {@code CalendarTab} instance.
83
+     *
84
+     * @inheritdoc
85
+     */
86
+    constructor(props: Props) {
87
+        super(props);
88
+
89
+        this.state = {
90
+            loading: true
91
+        };
92
+
93
+        // Bind event handlers so they are only bound once for every instance.
94
+        this._onClickDisconnect = this._onClickDisconnect.bind(this);
95
+        this._onClickGoogle = this._onClickGoogle.bind(this);
96
+        this._onClickMicrosoft = this._onClickMicrosoft.bind(this);
97
+    }
98
+
99
+    /**
100
+     * Loads third party APIs as needed and bootstraps the initial calendar
101
+     * state if not already set.
102
+     *
103
+     * @inheritdoc
104
+     */
105
+    componentDidMount() {
106
+        this.props.dispatch(bootstrapCalendarIntegration())
107
+            .catch(err => logger.error('CalendarTab bootstrap failed', err))
108
+            .then(() => this.setState({ loading: false }));
109
+    }
110
+
111
+    /**
112
+     * Implements React's {@link Component#render()}.
113
+     *
114
+     * @inheritdoc
115
+     * @returns {ReactElement}
116
+     */
117
+    render() {
118
+        let view;
119
+
120
+        if (this.state.loading) {
121
+            view = this._renderLoadingState();
122
+        } else if (this.props._isConnectedToCalendar) {
123
+            view = this._renderSignOutState();
124
+        } else {
125
+            view = this._renderSignInState();
126
+        }
127
+
128
+        return (
129
+            <div className = 'calendar-tab'>
130
+                { view }
131
+            </div>
132
+        );
133
+    }
134
+
135
+    /**
136
+     * Dispatches the action to start the sign in flow for a given calendar
137
+     * integration type.
138
+     *
139
+     * @param {string} type - The calendar type to try integrating with.
140
+     * @private
141
+     * @returns {void}
142
+     */
143
+    _attemptSignIn(type) {
144
+        this.props.dispatch(signIn(type));
145
+    }
146
+
147
+    _onClickDisconnect: (Object) => void;
148
+
149
+    /**
150
+     * Dispatches an action to sign out of the currently connected third party
151
+     * used for calendar integration.
152
+     *
153
+     * @private
154
+     * @returns {void}
155
+     */
156
+    _onClickDisconnect() {
157
+        // We clear the integration state instead of actually signing out. This
158
+        // is for two primary reasons. Microsoft does not support a sign out and
159
+        // instead relies on clearing of local auth data. Google signout can
160
+        // also sign the user out of YouTube. So for now we've decided not to
161
+        // do an actual sign out.
162
+        this.props.dispatch(clearCalendarIntegration());
163
+    }
164
+
165
+    _onClickGoogle: () => void;
166
+
167
+    /**
168
+     * Starts the sign in flow for Google calendar integration.
169
+     *
170
+     * @private
171
+     * @returns {void}
172
+     */
173
+    _onClickGoogle() {
174
+        this._attemptSignIn(CALENDAR_TYPE.GOOGLE);
175
+    }
176
+
177
+    _onClickMicrosoft: () => void;
178
+
179
+    /**
180
+     * Starts the sign in flow for Microsoft calendar integration.
181
+     *
182
+     * @private
183
+     * @returns {void}
184
+     */
185
+    _onClickMicrosoft() {
186
+        this._attemptSignIn(CALENDAR_TYPE.MICROSOFT);
187
+    }
188
+
189
+    /**
190
+     * Render a React Element to indicate third party APIs are being loaded.
191
+     *
192
+     * @private
193
+     * @returns {ReactElement}
194
+     */
195
+    _renderLoadingState() {
196
+        return (
197
+            <Spinner
198
+                isCompleting = { false }
199
+                size = 'medium' />
200
+        );
201
+    }
202
+
203
+    /**
204
+     * Render a React Element to sign into a third party for calendar
205
+     * integration.
206
+     *
207
+     * @private
208
+     * @returns {ReactElement}
209
+     */
210
+    _renderSignInState() {
211
+        const {
212
+            _appName,
213
+            _enableGoogleIntegration,
214
+            _enableMicrosoftIntegration,
215
+            t
216
+        } = this.props;
217
+
218
+        return (
219
+            <div>
220
+                <p>
221
+                    { t('settings.calendar.about',
222
+                        { appName: _appName || '' }) }
223
+                </p>
224
+                { _enableGoogleIntegration
225
+                    && <div className = 'calendar-tab-sign-in'>
226
+                        <GoogleSignInButton
227
+                            onClick = { this._onClickGoogle }
228
+                            text = { t('liveStreaming.signIn') } />
229
+                    </div> }
230
+                { _enableMicrosoftIntegration
231
+                    && <div className = 'calendar-tab-sign-in'>
232
+                        <MicrosoftSignInButton
233
+                            onClick = { this._onClickMicrosoft }
234
+                            text = { t('settings.calendar.microsoftSignIn') } />
235
+                    </div> }
236
+            </div>
237
+        );
238
+    }
239
+
240
+    /**
241
+     * Render a React Element to sign out of the currently connected third
242
+     * party used for calendar integration.
243
+     *
244
+     * @private
245
+     * @returns {ReactElement}
246
+     */
247
+    _renderSignOutState() {
248
+        const { _profileEmail, t } = this.props;
249
+
250
+        return (
251
+            <div>
252
+                <div className = 'sign-out-cta'>
253
+                    { t('settings.calendar.signedIn',
254
+                        { email: _profileEmail }) }
255
+                </div>
256
+                <Button
257
+                    appearance = 'primary'
258
+                    id = 'calendar_logout'
259
+                    onClick = { this._onClickDisconnect }
260
+                    type = 'button'>
261
+                    { t('settings.calendar.disconnect') }
262
+                </Button>
263
+            </div>
264
+        );
265
+    }
266
+}
267
+
268
+/**
269
+ * Maps (parts of) the Redux state to the associated props for the
270
+ * {@code CalendarTab} component.
271
+ *
272
+ * @param {Object} state - The Redux state.
273
+ * @private
274
+ * @returns {{
275
+ *     _appName: string,
276
+ *     _enableGoogleIntegration: boolean,
277
+ *     _enableMicrosoftIntegration: boolean,
278
+ *     _isConnectedToCalendar: boolean,
279
+ *     _profileEmail: string
280
+ * }}
281
+ */
282
+function _mapStateToProps(state) {
283
+    const calendarState = state['features/calendar-sync'] || {};
284
+    const {
285
+        googleApiApplicationClientID,
286
+        microsoftApiApplicationClientID
287
+    } = state['features/base/config'];
288
+    const calendarEnabled = isCalendarEnabled();
289
+
290
+    return {
291
+        _appName: interfaceConfig.APP_NAME,
292
+        _enableGoogleIntegration: Boolean(
293
+            calendarEnabled && googleApiApplicationClientID),
294
+        _enableMicrosoftIntegration: Boolean(
295
+            calendarEnabled && microsoftApiApplicationClientID),
296
+        _isConnectedToCalendar: calendarState.integrationReady,
297
+        _profileEmail: calendarState.profileEmail
298
+    };
299
+}
300
+
301
+export default translate(connect(_mapStateToProps)(CalendarTab));

+ 16
- 3
react/features/settings/components/web/SettingsDialog.js View File

@@ -5,12 +5,14 @@ import { connect } from 'react-redux';
5 5
 
6 6
 import { getAvailableDevices } from '../../../base/devices';
7 7
 import { DialogWithTabs, hideDialog } from '../../../base/dialog';
8
+import { isCalendarEnabled } from '../../../calendar-sync';
8 9
 import {
9 10
     DeviceSelection,
10 11
     getDeviceSelectionDialogProps,
11 12
     submitDeviceSelectionTab
12 13
 } from '../../../device-selection';
13 14
 
15
+import CalendarTab from './CalendarTab';
14 16
 import MoreTab from './MoreTab';
15 17
 import ProfileTab from './ProfileTab';
16 18
 import { getMoreTabProps, getProfileTabProps } from '../../functions';
@@ -40,7 +42,7 @@ type Props = {
40 42
     /**
41 43
      * Invoked to save changed settings.
42 44
      */
43
-    dispatch: Function,
45
+    dispatch: Function
44 46
 };
45 47
 
46 48
 /**
@@ -81,7 +83,8 @@ class SettingsDialog extends Component<Props> {
81 83
                 onMount: tab.onMount
82 84
                     ? (...args) => dispatch(tab.onMount(...args))
83 85
                     : undefined,
84
-                submit: (...args) => dispatch(tab.submit(...args))
86
+                submit: (...args) => tab.submit
87
+                    && dispatch(tab.submit(...args))
85 88
             };
86 89
         });
87 90
 
@@ -129,7 +132,8 @@ function _mapStateToProps(state) {
129 132
     const { showModeratorSettings, showLanguageSettings } = moreTabProps;
130 133
     const showProfileSettings
131 134
         = configuredTabs.includes('profile') && jwt.isGuest;
132
-
135
+    const showCalendarSettings
136
+        = configuredTabs.includes('calendar') && isCalendarEnabled();
133 137
     const tabs = [];
134 138
 
135 139
     if (showDeviceSettings) {
@@ -169,6 +173,15 @@ function _mapStateToProps(state) {
169 173
         });
170 174
     }
171 175
 
176
+    if (showCalendarSettings) {
177
+        tabs.push({
178
+            name: SETTINGS_TABS.CALENDAR,
179
+            component: CalendarTab,
180
+            label: 'settings.calendar.title',
181
+            styles: 'settings-pane calendar-pane'
182
+        });
183
+    }
184
+
172 185
     if (showModeratorSettings || showLanguageSettings) {
173 186
         tabs.push({
174 187
             name: SETTINGS_TABS.MORE,

+ 1
- 0
react/features/settings/constants.js View File

@@ -1,4 +1,5 @@
1 1
 export const SETTINGS_TABS = {
2
+    CALENDAR: 'calendar_tab',
2 3
     DEVICES: 'devices_tab',
3 4
     MORE: 'more_tab',
4 5
     PROFILE: 'profile_tab'

+ 12
- 0
static/msredirect.html View File

@@ -0,0 +1,12 @@
1
+<!DOCTYPE html>
2
+<html>
3
+<body>
4
+<script>
5
+window.opener
6
+    && window.opener.postMessage({
7
+            type: 'ms-login',
8
+            url: window.location.href
9
+        }, window.location.origin);
10
+</script>
11
+</body>
12
+</html>

Loading…
Cancel
Save