Compare commits

..

69 Commits

Author SHA1 Message Date
yanas
86caf52d08 Welcome page calendar ui improvements (#3405)
* Welcome page calendar ui improvements

* Addressing PR review comments.
2018-08-27 17:56:17 -07:00
yanas
f2cb15ba44 [WiP] Calendar integration ui (#3395)
Calendar integration ui
2018-08-27 10:13:59 -05:00
Lyubo Marinov
d62974b433 [RN] Update react-native-calendar-events (continued)
PR https://github.com/wmcmahan/react-native-calendar-events/pull/186 has
been merged upstream.
2018-08-26 21:53:42 -05:00
Saúl Ibarra Corretgé
8ff33684f7 [RN] Update react-native-calendar-events
Fixes a crash on Android. Upstream PR: https://github.com/wmcmahan/react-native-calendar-events/pull/186
2018-08-26 20:30:30 -05:00
bgrozev
b8179102c5 Merge pull request #3396 from nikvaessen/pr_independend_subtitles_cherry
independently display subtitles based on participants choice
2018-08-24 12:38:11 -05:00
linkmauve
c23c798f7a Display the correct display name in the menu (#3388)
The current code was splitting it on a space, which made nicknames such as “Link Mauve” appear as “Link”, whereas it gets displayed correctly everywhere else in the UI.
2018-08-22 12:35:48 -07:00
Nik
3c27d2ee54 independently display subtitles based on participants choice 2018-08-22 19:49:58 +02:00
Дамян Минков
7267f386dc Implements calendar entries edit. (#3382)
* Implements calendar entries edit.

Share text generation between calendar-sync and the share-room feature.

* Fixing comments.

* Clone the event element we modify on update.
2018-08-17 12:34:41 -07:00
Saúl Ibarra Corretgé
dba7f2d429 [RN] Remove no longer needed polyfills (#3377)
- navigator.{platform,plugins} were needed by the no longer existing screenshare
  adapter
- document.implementation is already polyfilled by xmldom
2018-08-16 23:03:53 -05:00
Saúl Ibarra Corretgé
a896d8f076 [RN] Fix normalizing BOSH URLs (#3376)
If a relative BOSH URL is found (as docker-jitsi-meet does) construct a full URL
based on the location URL and context root.

Also remove some default options since we need the config file anyway, so I see
no point in doing the extra work.
2018-08-16 23:03:15 -05:00
Hristo Terezov
99d285519d chore(lib-jitsi-meet): Update version. (#3381) 2018-08-16 16:58:54 -07:00
hristoterezov
2704b2f822 fix(dropbox): Address code review comments. 2018-08-16 13:53:43 -05:00
hristoterezov
62544188bd feat(recording): Add analytics event and logging. 2018-08-16 13:53:43 -05:00
hristoterezov
df0e107ea6 feat(recording): Implement dropbox integration 2018-08-16 13:53:43 -05:00
Saúl Ibarra Corretgé
f10d42f8e4 Fix processing context root
Yours truly refactored routing in https://github.com/jitsi/jitsi-meet/pull/3222
and broke it. When a bare room is entered the pathname was not updated when
applying the default URL.
2018-08-16 12:02:14 +02:00
Дамян Минков
7eda31315f 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
2018-08-15 13:11:54 -07:00
virtuacoplenny
87c010a9bd fix(subtitles): adjust styling for tile view (#3365)
- Increase z-index so the subtitles display over tiles.
- Add a background to the subtitle text.
- In general make the subtitles narrower.
2018-08-14 17:44:21 -07:00
linkmauve
8d0d92a437 Log the amount of local tracks properly
This changes a log message from “initialized with %s local tracks 2” to “initialized with 2 local tracks”.
2018-08-14 10:53:47 +02:00
linkmauve
faada0abae Print a nicer log message on participant join/part
This makes the logs more readable.
2018-08-14 10:53:18 +02:00
Ritwik Heda
1d99abc4a4 removes need for eslint-disable-next-line react/jsx-wrap-multilines and eslint-diable-line no extra-parens 2018-08-12 17:06:35 -05:00
Lyubo Marinov
9aed4df6d2 react-native-webrtc: android: pass correct constraints map to VideoCaptureController 2018-08-11 18:03:05 -05:00
Saúl Ibarra Corretgé
d92b720704 [RN] Update calendar-events dependency
Includes a fix for not running expensive operations on the main thread.
2018-08-10 15:11:37 +02:00
bgrozev
25aaa74edc Merge pull request #3223 from ztl8702/local-recording
Feature: Local recording (Ready for review)
2018-08-08 19:35:11 -05:00
Boris Grozev
195462a1a8 Merge branch 'master' into pr/3223 2018-08-08 15:35:40 -05:00
bgrozev
9c03e95bf1 npm: Updates lib-jitsi-meet to 4a28a196160411d657518022de8bded7c02ad679. (#3357) 2018-08-08 14:42:32 -05:00
virtuacoplenny
c353e9377f feat(tile-view): initial implementation for tile view (#3317)
* feat(tile-view): initial implementation for tile view

- Modify the classname on the app root so layout can adjust
  depending on the desired layout mode--vertical filmstrip,
  horizontal filmstrip, and tile view.
- Create a button for toggling tile view.
- Add a StateListenerRegistry to automatically update the
  selected participant and max receiver frame height on tile
  view toggle.
- Rezise thumbnails when switching in and out of tile view.
- Move the local video when switching in and out of tile view.
- Update reactified pieces of thumbnails when switching in and
  out of tile view.
- Cap the max receiver video quality in tile view based on tile
  size.
- Use CSS to hide UI components that should not display in tile
  view.
- Signal follow me changes.

* change local video id for tests

* change approach: leverage more css

* squash: fix some formatting

* squash: prevent pinning, hide pin border in tile view

* squash: change logic for maxReceiverQuality due to sidestepping resizing logic

* squash: fix typo, columns configurable, remove unused constants

* squash: resize with js again

* squash: use yana's math for calculating tile size
2018-08-08 13:48:23 -05:00
Radium Zheng
913c56c408 fix comments and docs 2018-08-08 11:58:38 +10:00
bgrozev
2f1223f721 fix: Handles the case of e2eRtt being undefined. (#3354) 2018-08-07 18:39:10 -07:00
Radium Zheng
4f1aaf89bf update package-lock.json 2018-08-08 09:26:49 +10:00
Radium Zheng
df6df1c6c3 refactor: AbstractAudioContextAdapter
move duplicate code from WavAdapter and FlacAdapter to a base class
2018-08-08 09:19:53 +10:00
Radium Zheng
1e804e552e fix: FlacAdapter get sampleRate 2018-08-08 09:19:53 +10:00
Radium Zheng
b284f25fde Refactor how download works. Cleaner filenames. 2018-08-08 09:19:53 +10:00
Radium Zheng
49bdd53bee Fix issue on mobile platforms 2018-08-08 09:19:53 +10:00
Radium Zheng
0827e02de9 use official repo for libflac.js 2018-08-08 09:19:53 +10:00
Radium Zheng
0410af9e5e add guard before APP in middleware.js 2018-08-08 09:19:28 +10:00
Radium Zheng
5a051024e6 clean up WavAdapter 2018-08-08 09:19:28 +10:00
Radium Zheng
e2def5f88b simplify Promise chaining in FlacAdapter 2018-08-08 09:19:28 +10:00
Radium Zheng
1078fa9d05 remove 'localRecording' from interface_config.js 2018-08-08 09:19:28 +10:00
Radium Zheng
dda7568a48 UI: refine LocalRecordingInfoDialog 2018-08-08 09:19:28 +10:00
Radium Zheng
4550848eac fix comments in flac-related codebase 2018-08-08 09:19:28 +10:00
Radium Zheng
7822831b1e UI: add a "Local Recording" label 2018-08-08 09:19:28 +10:00
Radium Zheng
e03126e422 fix sampleRate issues in flac and wav 2018-08-08 09:19:28 +10:00
Radium Zheng
61652c69b3 SessionManager 2018-08-08 09:19:28 +10:00
Radium Zheng
b6e1a49d33 Switching microphone on the fly: flac and wav support 2018-08-08 09:19:28 +10:00
Radium Zheng
e0ac3efb5c comment out section in config.js 2018-08-08 09:19:28 +10:00
Radium Zheng
65c76dcde5 Muting support
fix Promise in setMuted
2018-08-08 09:19:28 +10:00
Radium Zheng
5daa91ec1b update libflac.js to 4 and use proper fork 2018-08-08 09:19:28 +10:00
Radium Zheng
473ba28171 feature flag 2018-08-08 09:18:16 +10:00
Radium Zheng
52b55d65a0 change LocalRecordingInfoDialog 2018-08-08 09:18:16 +10:00
Radium Zheng
8ebf2b7e47 analytics: keyboard shortcut 2018-08-08 09:18:16 +10:00
Radium Zheng
cc38fcc5d0 register shortcuts in the middleware 2018-08-08 09:18:16 +10:00
Radium Zheng
a277421ecb WIP: Convert inline dialog to modal dialog 2018-08-08 09:18:16 +10:00
Radium Zheng
2f2e69a6f5 Add keyboard shortcuts for LocalRecordingInfoDialog
Which key should we use? Using "L" for now.
2018-08-08 09:18:16 +10:00
Radium Zheng
0490a3cf73 Refactor RecordingController 2018-08-08 09:18:16 +10:00
Radium Zheng
bfc8ecfaa6 changed one comment line 2018-08-08 09:18:16 +10:00
Radium Zheng
42c827434c clean up in LocalRecordingInfoDialog 2018-08-08 09:18:16 +10:00
Radium Zheng
0f3b67e53e reducer should be a pure function 2018-08-08 09:18:16 +10:00
Radium Zheng
2dfb107c57 UI strings: durationNA and moderater's finish message 2018-08-08 09:18:16 +10:00
Radium Zheng
f8c01646c7 Temp fix: newly joined clients miss the commands
When newly joined clients register for XMPP events upon
CONFERENCE_JOINED, those events that is carried by presence (e.g. START_COMMAND) was
already fired.
Temporary solution is to let the client send a ping message after
registering XMPP event listeners. The moderator will respond with
pong, which forces the presence to be resent.
2018-08-08 09:18:16 +10:00
Radium Zheng
0f0f9ea1b2 bug fix: multiple StartCommands
Situation when the RecordingController receives a new START_COMMAND
while it is initializing the recording adapter for the previous
START_COMMAND.
2018-08-08 09:18:16 +10:00
Radium Zheng
ce308eaa8b refactor: remove ensureInitialized 2018-08-08 09:18:16 +10:00
Radium Zheng
337cea6488 don't use params to switch actionType 2018-08-08 09:18:16 +10:00
Radium Zheng
e125861b29 refactor: use createLocalTracks instead of gUM; fix some docs; 2018-08-08 09:18:16 +10:00
Radium Zheng
3241c7a929 guard LocalRecordingButton with _shouldShowButton 2018-08-08 09:18:16 +10:00
Radium Zheng
55a2ef30a0 a11y label 2018-08-08 09:18:16 +10:00
Radium Zheng
ae0bd9e64e remove excessive comments in flacEncodeWorker.js 2018-08-08 09:18:16 +10:00
Radium Zheng
9c769a650e fix a missing doc string in Toolbox.js; reorder props alphabetically 2018-08-08 09:18:16 +10:00
Radium Zheng
07bc70c2f5 Implement local recording
index.js of local recording

local-recording(ui): recording button

local-recording(encoding): flac support with libflac.js

Fixes in RecordingController; integration with UI

local-recording(controller): coordinate recording on different clients

local-recording(controller): allow recording on remote participants

local-recording(controller): global singleton

local-recording(controller): use middleware to init LocalRecording

cleanup and documentation in RecordingController

local-recording(refactor): "Delegate" -> "Adapter"

code style

stop eslint and flow from complaining

temp save: client status

fix linter issues

fix some docs; remove global LocalRecording instance

use node.js packaging for libflac.js; remove vendor/ folder

code style: flacEncodeWorker.js

use moment.js to do time diff

remove the use of console.log

code style: flac related files

remove excessive empty lines; and more docs

remove the use of clockTick for UI updates

initalize flacEncodeWorker properly, to avoid premature audio data transmission

move the realization of recordingController events
from LocalRecordingButton to middleware

i18n strings

minor markup changes in LocalRecordingInfoDialog

fix documentation
2018-08-08 09:18:16 +10:00
bgrozev
2ee1bf9351 feat: Displays the E2E RTT in the connection stats table. (#3344)
* feat: Displays the E2E RTT in the connection stats table.

* fix: Whitelists the ping config properties.

* ref: Addresses feedback.

* npm: Updates lib-jitsi-meet to e097a1189ed99838605d90b959e129155bc0e50a.

* ref: Moves the e2ertt and region to the existing stats object.
2018-08-07 11:31:51 -07:00
183 changed files with 8673 additions and 1978 deletions

View File

@@ -2,6 +2,7 @@ BUILD_DIR = build
CLEANCSS = ./node_modules/.bin/cleancss
DEPLOY_DIR = libs
LIBJITSIMEET_DIR = node_modules/lib-jitsi-meet/
LIBFLAC_DIR = node_modules/libflacjs/dist/min/
NODE_SASS = ./node_modules/.bin/node-sass
NPM = npm
OUTPUT_DIR = .
@@ -19,7 +20,7 @@ compile:
clean:
rm -fr $(BUILD_DIR)
deploy: deploy-init deploy-appbundle deploy-lib-jitsi-meet deploy-css deploy-local
deploy: deploy-init deploy-appbundle deploy-lib-jitsi-meet deploy-libflac deploy-css deploy-local
deploy-init:
rm -fr $(DEPLOY_DIR)
@@ -33,6 +34,8 @@ deploy-appbundle:
$(BUILD_DIR)/do_external_connect.min.map \
$(BUILD_DIR)/external_api.min.js \
$(BUILD_DIR)/external_api.min.map \
$(BUILD_DIR)/flacEncodeWorker.min.js \
$(BUILD_DIR)/flacEncodeWorker.min.map \
$(BUILD_DIR)/device_selection_popup_bundle.min.js \
$(BUILD_DIR)/device_selection_popup_bundle.min.map \
$(BUILD_DIR)/dial_in_info_bundle.min.js \
@@ -50,6 +53,12 @@ deploy-lib-jitsi-meet:
$(LIBJITSIMEET_DIR)/modules/browser/capabilities.json \
$(DEPLOY_DIR)
deploy-libflac:
cp \
$(LIBFLAC_DIR)/libflac4-1.3.2.min.js \
$(LIBFLAC_DIR)/libflac4-1.3.2.min.js.mem \
$(DEPLOY_DIR)
deploy-css:
$(NODE_SASS) $(STYLES_MAIN) $(STYLES_BUNDLE) && \
$(CLEANCSS) $(STYLES_BUNDLE) > $(STYLES_DESTINATION) ; \
@@ -58,7 +67,7 @@ deploy-css:
deploy-local:
([ ! -x deploy-local.sh ] || ./deploy-local.sh)
dev: deploy-init deploy-css deploy-lib-jitsi-meet
dev: deploy-init deploy-css deploy-lib-jitsi-meet deploy-libflac
$(WEBPACK_DEV_SERVER)
source-package:

View File

@@ -704,7 +704,7 @@ export default {
track.mute();
}
});
logger.log('initialized with %s local tracks', tracks.length);
logger.log(`initialized with ${tracks.length} local tracks`);
this._localTracksInitialized = true;
con.addEventListener(
JitsiConnectionEvents.CONNECTION_FAILED,
@@ -1678,7 +1678,7 @@ export default {
role: user.getRole()
}));
logger.log('USER %s connnected', id, user);
logger.log(`USER ${id} connnected:`, user);
APP.API.notifyUserJoined(id, {
displayName,
formattedDisplayName: appendSuffix(
@@ -1698,7 +1698,7 @@ export default {
}
APP.store.dispatch(participantLeft(id, room));
logger.log('USER %s LEFT', id, user);
logger.log(`USER ${id} LEFT:`, user);
APP.API.notifyUserLeft(id);
APP.UI.messageHandler.participantNotification(
user.getDisplayName(),

View File

@@ -256,6 +256,10 @@ var config = {
// maintenance at 01:00 AM GMT,
// noticeMessage: '',
// Enables calendar integration, depends on googleApiApplicationClientID
// and microsoftApiApplicationClientID
// enableCalendarIntegration: false,
// Stats
//
@@ -347,6 +351,36 @@ var config = {
// userRegion: "asia"
}
// Local Recording
//
// localRecording: {
// Enables local recording.
// Additionally, 'localrecording' (all lowercase) needs to be added to
// TOOLBAR_BUTTONS in interface_config.js for the Local Recording
// button to show up on the toolbar.
//
// enabled: true,
//
// The recording format, can be one of 'ogg', 'flac' or 'wav'.
// format: 'flac'
//
// }
// Options related to end-to-end (participant to participant) ping.
// e2eping: {
// // The interval in milliseconds at which pings will be sent.
// // Defaults to 10000, set to <= 0 to disable.
// pingInterval: 10000,
//
// // The interval in milliseconds at which analytics events
// // with the measured RTT will be sent. Defaults to 60000, set
// // to <= 0 to disable.
// analyticsInterval: 60000,
// }
// List of undocumented settings used in jitsi-meet
/**
_immediateReloadThreshold
@@ -368,6 +402,7 @@ var config = {
googleApiApplicationClientID
iAmRecorder
iAmSipGateway
microsoftApiApplicationClientID
peopleSearchQueryTypes
peopleSearchUrl
requireDisplayName
@@ -396,6 +431,7 @@ var config = {
nick
startBitrate
*/
};
/* eslint-enable no-unused-vars, no-var */

View File

@@ -10,3 +10,31 @@
-ms-transform: translateX(0) translateY(100%) translateY(16px) !important;
-webkit-transform: translateX(0) translateY(100%) translateY(16px) !important;
}
/**
* Welcome page tab color adjustments.
*/
.welcome {
/**
* The text color of the selected tab and hovered tabs.
*/
li.bcVmZW,
li.bcVmZW:hover,
li.kheoEp:hover {
color: #172B4D;
}
/**
* The color of the inactive tab text.
*/
li.kheoEp {
color: #FFFFFF;
}
/**
* The color of the underline of a selected tab.
*/
li>span.kByArU {
background-color: #172B4D;
}
}

View File

@@ -13,20 +13,35 @@
float: left;
}
.navigate-section-list-tile {
height: 90px;
width: 260px;
border-radius: 4px;
background-color: #1754A9;
border-radius: 4px;
box-sizing: border-box;
display: inline-flex;
height: 100px;
margin-bottom: 8px;
margin-right: 8px;
padding: 16px;
display: inline-block;
box-sizing: border-box;
cursor: pointer;
width: 100%;
&.with-click-handler {
cursor: pointer;
}
&.with-click-handler:hover {
background-color: #1a5dbb;
}
i {
cursor: inherit;
}
}
.navigate-section-tile-body {
@extend %navigate-section-list-tile-text;
font-weight: normal;
}
.navigate-section-list-tile-info {
flex: 1;
}
.navigate-section-tile-title {
@extend %navigate-section-list-tile-text;
font-weight: bold;
@@ -40,4 +55,8 @@
position: relative;
margin-top: 36px;
margin-bottom: 36px;
width: 100%;
}
.navigate-section-list-empty {
text-align: center;
}

View File

@@ -2,6 +2,42 @@
vertical-align: top;
}
.recording-dialog {
.authorization-panel {
border-bottom: 2px solid rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
margin-bottom: 10px;
padding-bottom: 10px;
.dropbox-sign-in {
align-items: center;
border: 1px solid #4285f4;
background-color: white;
border-radius: 2px;
cursor: pointer;
display: inline-flex;
padding: 10px;
font-size: 18px;
font-weight: 600;
margin: 10px 0px;
color: #4285f4;
.dropbox-logo {
background-color: white;
border-radius: 2px;
display: inline-block;
padding-right: 5px;
height: 18px;
}
}
.logged-in-pannel {
padding: 10px;
}
}
}
.live-stream-dialog {
/**
* Set font-size to be consistent with Atlaskit FieldText.
@@ -34,39 +70,6 @@
color: $errorColor;
}
/**
* The Google sign in button must follow Google's design guidelines.
* See: https://developers.google.com/identity/branding-guidelines
*/
.google-sign-in {
background-color: #4285f4;
border-radius: 2px;
cursor: pointer;
display: inline-flex;
font-family: Roboto, arial, sans-serif;
font-size: 14px;
padding: 1px;
.google-cta {
color: white;
display: inline-block;
/**
* Hack the line height for vertical centering of text.
*/
line-height: 32px;
margin: 0 15px;
}
.google-logo {
background-color: white;
border-radius: 2px;
display: inline-block;
padding: 8px;
height: 18px;
width: 18px;
}
}
.google-panel {
align-items: center;
border-bottom: 2px solid rgba(0, 0, 0, 0.3);

View File

@@ -2,12 +2,19 @@
bottom: 10%;
font-size: 16px;
font-weight: 1000;
left: 50%;
max-width: 50vw;
opacity: 0.80;
pointer-events: none;
position: absolute;
text-shadow: 0px 0px 1px rgba(0,0,0,0.3),
0px 1px 1px rgba(0,0,0,0.3),
1px 0px 1px rgba(0,0,0,0.3),
0px 0px 1px rgba(0,0,0,0.3);
width: 100%;
z-index: $zindex2;
transform: translateX(-50%);
z-index: $filmstripVideosZ + 1;
span {
background: black;
}
}

View File

@@ -45,6 +45,7 @@ body.welcome-page {
font-size: 1rem;
font-weight: 400;
line-height: 24px;
margin-bottom: 20px;
}
#enter_room {
@@ -62,12 +63,30 @@ body.welcome-page {
width: 100%;
}
}
.tab-container {
font-size: 16px;
position: relative;
text-align: left;
width: 650px;
}
}
.welcome-page-button {
font-size: 16px;
}
.welcome-page-settings {
color: $welcomePageDescriptionColor;
position: absolute;
right: 10px;
z-index: $zindex2;
* {
cursor: pointer;
}
}
.welcome-watermark {
position: absolute;
width: 100%;

View File

@@ -14,14 +14,9 @@
* Focused video thumbnail.
*/
&.videoContainerFocused {
transition-duration: 0.5s;
-webkit-transition-duration: 0.5s;
-webkit-animation-name: greyPulse;
-webkit-animation-duration: 2s;
-webkit-animation-iteration-count: 1;
border: $thumbnailVideoBorder solid $videoThumbnailSelected !important;
border: $thumbnailVideoBorder solid $videoThumbnailSelected;
box-shadow: inset 0 0 3px $videoThumbnailSelected,
0 0 3px $videoThumbnailSelected !important;
0 0 3px $videoThumbnailSelected;
}
.remotevideomenu > .icon-menu {
@@ -31,7 +26,7 @@
/**
* Hovered video thumbnail.
*/
&:hover {
&:hover:not(.videoContainerFocused):not(.active-speaker) {
cursor: hand;
border: $thumbnailVideoBorder solid $videoThumbnailHovered;
box-shadow: inset 0 0 3px $videoThumbnailHovered,

View File

@@ -0,0 +1,113 @@
/**
* CSS styles that are specific to the filmstrip that shows the thumbnail tiles.
*/
.tile-view {
/**
* Add a border around the active speaker to make the thumbnail easier to
* see.
*/
.active-speaker {
border: $thumbnailVideoBorder solid $videoThumbnailSelected;
box-shadow: inset 0 0 3px $videoThumbnailSelected,
0 0 3px $videoThumbnailSelected;
}
#filmstripRemoteVideos {
align-items: center;
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
}
.filmstrip__videos .videocontainer {
&:not(.active-speaker),
&:hover:not(.active-speaker) {
border: none;
box-shadow: none;
}
}
#remoteVideos {
/**
* Height is modified with an inline style in horizontal filmstrip mode
* so !important is used to override that.
*/
height: 100% !important;
width: 100%;
}
.filmstrip {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: $filmstripVideosZ
}
/**
* Regardless of the user setting, do not let the filmstrip be in a hidden
* state.
*/
.filmstrip__videos.hidden {
display: block;
}
#filmstripRemoteVideos {
box-sizing: border-box;
/**
* Allow scrolling of the thumbnails.
*/
overflow: auto;
}
/**
* The size of the thumbnails should be set with javascript, based on
* desired column count and window width. The rows are created using flex
* and allowing the thumbnails to wrap.
*/
#filmstripRemoteVideosContainer {
align-content: center;
align-items: center;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
height: 100vh;
justify-content: center;
padding: 100px 0;
.videocontainer {
box-sizing: border-box;
display: block;
margin: 5px;
}
video {
object-fit: contain;
}
}
.has-overflow#filmstripRemoteVideosContainer {
align-content: baseline;
}
.has-overflow .videocontainer {
align-self: baseline;
}
/**
* Firefox flex acts a little differently. To make sure the bottom row of
* thumbnails is not overlapped by the horizontal toolbar, margin is added
* to the local thumbnail to keep it from the bottom of the screen. It is
* assumed the local thumbnail will always be on the bottom row.
*/
.has-overflow #localVideoContainer {
margin-bottom: 100px !important;
}
}

View File

@@ -0,0 +1,47 @@
/**
* Various overrides outside of the filmstrip to style the app to support a
* tiled thumbnail experience.
*/
.tile-view {
/**
* Let the avatar grow with the tile.
*/
.userAvatar {
max-height: initial;
max-width: initial;
}
/**
* Hide various features that should not be displayed while in tile view.
*/
#dominantSpeaker,
#filmstripLocalVideoThumbnail,
#largeVideoElementsContainer,
#sharedVideo,
.filmstrip__toolbar {
display: none;
}
#localConnectionMessage,
#remoteConnectionMessage,
.watermark {
z-index: $filmstripVideosZ + 1;
}
/**
* The follow styling uses !important to override inline styles set with
* javascript.
*
* TODO: These overrides should be more easy to remove and should be removed
* when the components are in react so their rendering done declaratively,
* making conditional styling easier to apply.
*/
#largeVideoElementsContainer,
#remoteConnectionMessage,
#remotePresenceMessage {
display: none !important;
}
#largeVideoContainer {
background-color: $defaultBackground !important;
}
}

View File

@@ -45,6 +45,7 @@
@import 'modals/settings/settings';
@import 'modals/speaker_stats/speaker_stats';
@import 'modals/video-quality/video-quality';
@import 'modals/local-recording/local-recording';
@import 'videolayout_default';
@import 'notice';
@import 'popup_menu';
@@ -72,6 +73,8 @@
@import 'filmstrip/filmstrip_toolbar';
@import 'filmstrip/horizontal_filmstrip';
@import 'filmstrip/small_video';
@import 'filmstrip/tile_view';
@import 'filmstrip/tile_view_overrides';
@import 'filmstrip/vertical_filmstrip';
@import 'filmstrip/vertical_filmstrip_overrides';
@import 'unsupported-browser/main';
@@ -79,4 +82,7 @@
@import 'deep-linking/main';
@import 'transcription-subtitles';
@import 'navigate_section_list';
@import 'third-party-branding/google';
@import 'third-party-branding/microsoft';
/* Modules END */

View File

@@ -0,0 +1,92 @@
.localrec-participant-stats {
list-style: none;
padding: 0;
width: 100%;
font-weight: 500;
.localrec-participant-stats-item__status-dot {
position: relative;
display: block;
width: 9px;
height: 9px;
border-radius: 50%;
margin: 0 auto;
&.status-on {
background: green;
}
&.status-off {
background: gray;
}
&.status-unknown {
background: darkgoldenrod;
}
&.status-error {
background: darkred;
}
}
.localrec-participant-stats-item__status,
.localrec-participant-stats-item__name,
.localrec-participant-stats-item__sessionid {
display: inline-block;
margin: 5px 0;
vertical-align: middle;
}
.localrec-participant-stats-item__status {
width: 5%;
}
.localrec-participant-stats-item__name {
width: 40%;
}
.localrec-participant-stats-item__sessionid {
width: 55%;
}
.localrec-participant-stats-item__name,
.localrec-participant-stats-item__sessionid {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.localrec-control-info-label {
font-weight: bold;
}
.localrec-control-info-label:after {
content: ' ';
}
.localrec-control-action-link {
display: inline-block;
line-height: 1.5em;
a {
cursor: pointer;
vertical-align: middle;
}
}
.localrec-control-action-link:before {
color: $linkFontColor;
content: '\2022';
font-size: 1.5em;
padding: 0 10px;
vertical-align: middle;
}
.localrec-control-action-link:first-child:before {
content: '';
padding: 0;
}
.localrec-control-action-links {
font-weight: bold;
margin-top: 10px;
white-space: nowrap;
}

View File

@@ -10,6 +10,7 @@
margin-bottom: 4px;
}
.calendar-tab,
.device-selection {
margin-top: 20px;
}
@@ -22,6 +23,7 @@
padding: 20px 0px 4px 0px;
}
.calendar-tab,
.more-tab,
.profile-edit {
display: flex;
@@ -40,4 +42,20 @@
.language-settings {
max-width: 50%;
}
.calendar-tab {
align-items: center;
flex-direction: column;
font-size: 14px;
min-height: 100px;
text-align: center;
}
.calendar-tab-sign-in {
margin-top: 20px;
}
.sign-out-cta {
margin-bottom: 20px;
}
}

View File

@@ -168,6 +168,10 @@
background: #FF5630;
}
.circular-label.local-rec {
background: #FF5630;
}
.circular-label.stream {
background: #0065FF;
}

View File

@@ -0,0 +1,32 @@
/**
* The Google sign in button must follow Google's design guidelines.
* See: https://developers.google.com/identity/branding-guidelines
*/
.google-sign-in {
background-color: #4285f4;
border-radius: 2px;
cursor: pointer;
display: inline-flex;
font-family: Roboto, arial, sans-serif;
font-size: 14px;
padding: 1px;
.google-cta {
color: white;
display: inline-block;
/**
* Hack the line height for vertical centering of text.
*/
line-height: 32px;
margin: 0 15px;
}
.google-logo {
background-color: white;
border-radius: 2px;
display: inline-block;
padding: 8px;
height: 18px;
width: 18px;
}
}

View File

@@ -0,0 +1,28 @@
/**
* The Microsoft sign in button must follow Microsoft's brand guidelines.
* See: https://docs.microsoft.com/en-us/azure/active-directory/
* develop/active-directory-branding-guidelines
*/
.microsoft-sign-in {
align-items: center;
background: #FFFFFF;
border: 1px solid #8C8C8C;
box-sizing: border-box;
cursor: pointer;
display: inline-flex;
font-family: Segoe UI, Roboto, arial, sans-serif;
height: 41px;
padding: 12px;
.microsoft-cta {
display: inline-block;
color: #5E5E5E;
font-size: 15px;
line-height: 41px;
}
.microsoft-logo {
display: inline-block;
margin-right: 12px;
}
}

42
images/dropboxLogo.svg Normal file
View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="324px" height="63.8px" viewBox="0 0 324 63.8" style="enable-background:new 0 0 324 63.8;" xml:space="preserve">
<style type="text/css">
.st0{fill:#0061FF;}
.st1{display:none;}
.st2{display:inline;}
.st3{fill:none;}
</style>
<path class="st0" d="M37.6,12L18.8,24l18.8,12L18.8,48L0,35.9l18.8-12L0,12L18.8,0L37.6,12z M18.7,51.8l18.8-12l18.8,12l-18.8,12
L18.7,51.8z M37.6,35.9l18.8-12L37.6,12L56.3,0l18.8,12L56.3,24l18.8,12L56.3,48L37.6,35.9z"/>
<path d="M89.8,12H105c9.7,0,17.7,5.6,17.7,18.4v2.7c0,12.9-7.5,18.7-17.4,18.7H89.8V12z M98.3,19.2v25.3h6.5c5.5,0,9.2-3.6,9.2-11.6
v-2.1c0-8-3.9-11.6-9.5-11.6H98.3z M127.2,19.6h6.8l1.1,7.5c1.3-5.1,4.6-7.8,10.6-7.8h2.1v8.6h-3.5c-6.9,0-8.6,2.4-8.6,9.2v14.8
h-8.4V19.6H127.2z M149.5,36.4v-0.9c0-10.8,6.9-16.7,16.3-16.7c9.6,0,16.3,5.9,16.3,16.7v0.9c0,10.6-6.5,16.3-16.3,16.3
C155.4,52.6,149.5,47,149.5,36.4z M173.5,36.3v-0.8c0-6-3-9.6-7.8-9.6c-4.7,0-7.8,3.3-7.8,9.6v0.8c0,5.8,3,9.1,7.8,9.1
C170.5,45.3,173.5,42.1,173.5,36.3z M186.5,19.6h7l0.8,6.1c1.7-4.1,5.3-6.9,10.6-6.9c8.2,0,13.6,5.9,13.6,16.8v0.9
c0,10.6-6,16.2-13.6,16.2c-5.1,0-8.6-2.3-10.3-6V63h-8.2L186.5,19.6L186.5,19.6z M210,36.3v-0.7c0-6.4-3.3-9.6-7.7-9.6
c-4.7,0-7.8,3.6-7.8,9.6v0.6c0,5.7,3,9.3,7.7,9.3C207,45.4,210,42.3,210,36.3z M230.9,45.9l-0.7,5.9H223v-43h8.2v16.5
c1.8-4.2,5.4-6.5,10.5-6.5c7.7,0.1,13.4,5.4,13.4,16.1v1c0,10.7-5.4,16.8-13.6,16.8C236.1,52.6,232.6,50.1,230.9,45.9z M246.5,35.9
v-0.8c0-5.9-3.2-9.2-7.7-9.2c-4.6,0-7.8,3.7-7.8,9.3v0.7c0,6,3.1,9.5,7.7,9.5C243.6,45.4,246.5,42.3,246.5,35.9z M258.7,36.4v-0.9
c0-10.8,6.9-16.7,16.3-16.7c9.6,0,16.3,5.9,16.3,16.7v0.9c0,10.6-6.6,16.3-16.3,16.3C264.6,52.6,258.7,47,258.7,36.4z M282.8,36.3
v-0.8c0-6-3-9.6-7.8-9.6c-4.7,0-7.8,3.3-7.8,9.6v0.8c0,5.8,3,9.1,7.8,9.1C279.8,45.3,282.8,42.1,282.8,36.3z M302.3,35.1L291,19.6
h9.7l6.5,9.7l6.6-9.7h9.6L311.9,35L324,51.8h-9.5l-7.4-10.7l-7.2,10.7H290L302.3,35.1z"/>
<g id="Editble" class="st1">
<g class="st2">
<rect x="-105" y="5" class="st3" width="506" height="71.8"/>
<path d="M0.2,13.6h16.3c10.4,0,19,6.1,19,19.8v2.9c0,13.8-8,20-18.7,20H0.2V13.6z M9.4,21.3v27.2h7c5.9,0,9.9-3.9,9.9-12.5v-2.2
c0-8.6-4.1-12.5-10.2-12.5H9.4z M40.4,21.8h7.3l1.1,8c1.4-5.5,4.9-8.3,11.3-8.3h2.2v9.2h-3.7c-7.4,0-9.2,2.6-9.2,9.9v15.8h-9
C40.4,56.4,40.4,21.8,40.4,21.8z M64.3,39.8v-1c0-11.6,7.4-17.9,17.5-17.9c10.3,0,17.5,6.4,17.5,17.9v1c0,11.4-7,17.5-17.5,17.5
C70.6,57.3,64.3,51.2,64.3,39.8z M90.1,39.7v-0.8c0-6.5-3.2-10.3-8.3-10.3c-5,0-8.4,3.5-8.4,10.3v0.8c0,6.2,3.2,9.7,8.3,9.7
C86.9,49.4,90.1,46,90.1,39.7z M104,21.8h7.6l0.9,6.6c1.9-4.4,5.7-7.4,11.4-7.4c8.8,0,14.6,6.4,14.6,18v1
c0,11.4-6.4,17.3-14.6,17.3c-5.5,0-9.2-2.5-11-6.5v17.5H104V21.8z M129.3,39.8V39c0-6.9-3.5-10.3-8.3-10.3c-5,0-8.4,3.8-8.4,10.3
v0.7c0,6.1,3.2,10,8.2,10C126,49.5,129.3,46.1,129.3,39.8z M151.7,50.1l-0.7,6.3h-7.8V10.2h8.8V28c1.9-4.5,5.8-7,11.2-7
c8.2,0.1,14.3,5.8,14.3,17.3v1c0,11.5-5.8,18-14.6,18C157.3,57.3,153.5,54.5,151.7,50.1z M168.5,39.3v-0.8c0-6.4-3.5-9.8-8.3-9.8
c-5,0-8.4,4-8.4,10v0.7c0,6.5,3.3,10.2,8.3,10.2C165.3,49.5,168.5,46.1,168.5,39.3z M181.6,39.8v-1c0-11.6,7.4-17.9,17.5-17.9
c10.3,0,17.5,6.4,17.5,17.9v1c0,11.4-7.1,17.5-17.5,17.5C187.9,57.3,181.6,51.2,181.6,39.8z M207.4,39.7v-0.8
c0-6.5-3.2-10.3-8.3-10.3c-5,0-8.4,3.5-8.4,10.3v0.8c0,6.2,3.2,9.7,8.3,9.7C204.2,49.4,207.4,46,207.4,39.7z M228.3,38.4
l-12.1-16.7h10.4l7,10.4l7.1-10.4H251l-12.3,16.6l13,18h-10.2l-8-11.5l-7.7,11.5h-10.6L228.3,38.4z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

1
images/microsoftLogo.svg Normal file
View File

@@ -0,0 +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>

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -48,10 +48,11 @@ var interfaceConfig = {
'microphone', 'camera', 'closedcaptions', 'desktop', 'fullscreen',
'fodeviceselection', 'hangup', 'profile', 'info', 'chat', 'recording',
'livestreaming', 'etherpad', 'sharedvideo', 'settings', 'raisehand',
'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts'
'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts',
'tileview'
],
SETTINGS_SECTIONS: [ 'devices', 'language', 'moderator', 'profile' ],
SETTINGS_SECTIONS: [ 'devices', 'language', 'moderator', 'profile', 'calendar' ],
// Determines how the video would fit the screen. 'both' would fit the whole
// screen, 'height' would fit the original video height to the height of the
@@ -172,6 +173,12 @@ var interfaceConfig = {
*/
RECENT_LIST_ENABLED: true
/**
* How many columns the tile view can expand to. The respected range is
* between 1 and 5.
*/
// TILE_VIEW_MAX_COLUMNS: 5,
/**
* Specify custom URL for downloading android mobile app.
*/

View File

@@ -11,7 +11,7 @@ PODS:
- React/Core (= 0.55.4)
- react-native-background-timer (2.0.0):
- React
- react-native-calendar-events (1.6.0):
- react-native-calendar-events (1.6.2):
- React
- react-native-fast-image (4.0.14):
- FLAnimatedImage

View File

@@ -43,7 +43,8 @@
"mute": "Mute or unmute your microphone",
"fullScreen": "View or exit full screen",
"videoMute": "Start or stop your camera",
"showSpeakerStats": "Show speaker stats"
"showSpeakerStats": "Show speaker stats",
"localRecording": "Show or hide local recording controls"
},
"welcomepage":{
"accessibilityLabel": {
@@ -56,6 +57,8 @@
"video": "Video"
},
"calendar": "Calendar",
"connectCalendarText": "Connect your calendar to view all your meetings in __app__. Plus, add __app__ meetings to your calendar and start them with one click.",
"connectCalendarButton": "Connect your calendar",
"go": "GO",
"join": "JOIN",
"privacy": "Privacy",
@@ -87,6 +90,7 @@
"fullScreen": "Toggle full screen",
"hangup": "Leave the call",
"invite": "Invite people",
"localRecording": "Toggle local recording controls",
"lockRoom": "Toggle room lock",
"moreActions": "Toggle more actions menu",
"moreActionsMenu": "More actions menu",
@@ -102,6 +106,7 @@
"shortcuts": "Toggle shortcuts",
"speakerStats": "Toggle speaker statistics",
"toggleCamera": "Toggle camera",
"tileView": "Toggle tile view",
"videomute": "Toggle mute video"
},
"addPeople": "Add people to your call",
@@ -144,6 +149,7 @@
"raiseHand": "Raise / Lower your hand",
"shortcuts": "View shortcuts",
"speakerStats": "Speaker stats",
"tileViewToggle": "Toggle tile view",
"invite": "Invite people"
},
"chat":{
@@ -153,8 +159,14 @@
},
"messagebox": "Enter text..."
},
"settings":
{
"settings": {
"calendar": {
"about": "The __appName__ calendar integration is used to securely access your calendar so it can read upcoming events.",
"disconnect": "Disconnect",
"microsoftSignIn": "Sign in with Microsoft",
"signedIn": "Currently accessing calendar events for __email__. Click the Disconnect button below to stop accessing calendar events.",
"title": "Calendar"
},
"title": "Settings",
"update": "Update",
"name": "Name",
@@ -198,6 +210,7 @@
"packetloss": "Packet loss:",
"resolution": "Resolution:",
"framerate": "Frame rate:",
"e2e_rtt": "E2E RTT:",
"less": "Show less",
"more": "Show more",
"address": "Address:",
@@ -413,6 +426,11 @@
],
"and": "and"
},
"share":
{
"mainText": "Click the following link to join the meeting:\n__roomUrl__",
"dialInfoText": "\n\n=====\n\nJust want to dial in on your phone?\n\nClick this link to see the dial in phone numbers for this meetings\n__dialInfoPageUrl__"
},
"connection":
{
"ERROR": "Error",
@@ -445,7 +463,13 @@
"on": "Recording",
"pending": "Preparing to record the meeting...",
"rec": "REC",
"authDropboxText": "Upload your recording to Dropbox.",
"authDropboxCompletedText": "Your recording file will appear in your Dropbox shortly after the recording has finished.",
"serviceName": "Recording service",
"signOut": "Sign Out",
"signIn": "sign in",
"loggedIn": "Logged in as __userName__",
"availableSpace": "Available space: __spaceLeft__ MB (approximately __duration__ minutes of recording)",
"startRecordingBody": "Are you sure you would like to start recording?",
"unavailable": "Oops! The __serviceName__ is currently unavailable. We're working on resolving the issue. Please try again later.",
"unavailableTitle": "Recording unavailable"
@@ -617,13 +641,14 @@
"startWithVideoMuted": "Start with video muted"
},
"calendarSync": {
"later": "Later",
"next": "Upcoming",
"addMeetingURL": "Add a meeting link",
"today": "Today",
"nextMeeting": "next meeting",
"now": "Now",
"noEvents": "There are no upcoming events scheduled.",
"ongoingMeeting": "ongoing meeting",
"permissionButton": "Open settings",
"permissionMessage": "The Calendar permission is required to see your meetings in the app."
"permissionMessage": "The Calendar permission is required to see your meetings in the app.",
"refresh": "Refresh calendar"
},
"recentList": {
"joinPastMeeting": "Join A Past Meeting"
@@ -665,5 +690,34 @@
"decline": "Dismiss",
"productLabel": "from Jitsi Meet",
"videoCallTitle": "Incoming video call"
},
"localRecording": {
"localRecording": "Local Recording",
"dialogTitle": "Local Recording Controls",
"start": "Start Recording",
"stop": "Stop Recording",
"moderator": "Moderator",
"me": "Me",
"duration": "Duration",
"durationNA": "N/A",
"encoding": "Encoding",
"participantStats": "Participant Stats",
"participant": "Participant",
"sessionToken": "Session Token",
"clientState": {
"on": "On",
"off": "Off",
"unknown": "Unknown"
},
"messages": {
"engaged": "Local recording engaged.",
"finished": "Recording session __token__ finished. Please send the recorded file to the moderator.",
"finishedModerator": "Recording session __token__ finished. The recording of the local track has been saved. Please ask the other participants to submit their recordings.",
"notModerator": "You are not the moderator. You cannot start or stop local recording."
},
"yes": "Yes",
"no": "No",
"label": "LOR",
"labelToolTip": "Local recording is engaged"
}
}

View File

@@ -21,6 +21,7 @@ import {
getPinnedParticipant,
pinParticipant
} from '../react/features/base/participants';
import { setTileView } from '../react/features/video-layout';
import UIEvents from '../service/UI/UIEvents';
import VideoLayout from './UI/videolayout/VideoLayout';
@@ -117,6 +118,31 @@ class State {
}
}
/**
* A getter for this object instance to know the state of tile view.
*
* @returns {boolean} True if tile view is enabled.
*/
get tileViewEnabled() {
return this._tileViewEnabled;
}
/**
* A setter for {@link tileViewEnabled}. Fires a property change event for
* other participants to follow.
*
* @param {boolean} b - Whether or not tile view is enabled.
* @returns {void}
*/
set tileViewEnabled(b) {
const oldValue = this._tileViewEnabled;
if (oldValue !== b) {
this._tileViewEnabled = b;
this._firePropertyChange('tileViewEnabled', oldValue, b);
}
}
/**
* Invokes {_propertyChangeCallback} to notify it that {property} had its
* value changed from {oldValue} to {newValue}.
@@ -189,6 +215,10 @@ class FollowMe {
this._sharedDocumentToggled
.bind(this, this._UI.getSharedDocumentManager().isVisible());
}
this._tileViewToggled.bind(
this,
APP.store.getState()['features/video-layout'].tileViewEnabled);
}
/**
@@ -214,6 +244,10 @@ class FollowMe {
this.sharedDocEventHandler = this._sharedDocumentToggled.bind(this);
this._UI.addListener(UIEvents.TOGGLED_SHARED_DOCUMENT,
this.sharedDocEventHandler);
this.tileViewEventHandler = this._tileViewToggled.bind(this);
this._UI.addListener(UIEvents.TOGGLED_TILE_VIEW,
this.tileViewEventHandler);
}
/**
@@ -227,6 +261,8 @@ class FollowMe {
this.sharedDocEventHandler);
this._UI.removeListener(UIEvents.PINNED_ENDPOINT,
this.pinnedEndpointEventHandler);
this._UI.removeListener(UIEvents.TOGGLED_TILE_VIEW,
this.tileViewEventHandler);
}
/**
@@ -266,6 +302,18 @@ class FollowMe {
this._local.sharedDocumentVisible = sharedDocumentVisible;
}
/**
* Notifies this instance that the tile view mode has been enabled or
* disabled.
*
* @param {boolean} enabled - True if tile view has been enabled, false
* if has been disabled.
* @returns {void}
*/
_tileViewToggled(enabled) {
this._local.tileViewEnabled = enabled;
}
/**
* Changes the nextOnStage property value.
*
@@ -316,7 +364,8 @@ class FollowMe {
attributes: {
filmstripVisible: local.filmstripVisible,
nextOnStage: local.nextOnStage,
sharedDocumentVisible: local.sharedDocumentVisible
sharedDocumentVisible: local.sharedDocumentVisible,
tileViewEnabled: local.tileViewEnabled
}
});
}
@@ -355,6 +404,7 @@ class FollowMe {
this._onFilmstripVisible(attributes.filmstripVisible);
this._onNextOnStage(attributes.nextOnStage);
this._onSharedDocumentVisible(attributes.sharedDocumentVisible);
this._onTileViewEnabled(attributes.tileViewEnabled);
}
/**
@@ -434,6 +484,21 @@ class FollowMe {
}
}
/**
* Process a tile view enabled / disabled event received from FOLLOW-ME.
*
* @param {boolean} enabled - Whether or not tile view should be shown.
* @private
* @returns {void}
*/
_onTileViewEnabled(enabled) {
if (typeof enabled === 'undefined') {
return;
}
APP.store.dispatch(setTileView(enabled === 'true'));
}
/**
* Pins / unpins the video thumbnail given by clickId.
*

View File

@@ -1,4 +1,6 @@
/* global $ */
/* global $, APP */
import { shouldDisplayTileView } from '../../../react/features/video-layout';
import SmallVideo from '../videolayout/SmallVideo';
const logger = require('jitsi-meet-logger').getLogger(__filename);
@@ -64,7 +66,9 @@ SharedVideoThumb.prototype.createContainer = function(spanId) {
* The thumb click handler.
*/
SharedVideoThumb.prototype.videoClick = function() {
this._togglePin();
if (!shouldDisplayTileView(APP.store.getState())) {
this._togglePin();
}
};
/**

View File

@@ -1,6 +1,13 @@
/* global $, APP, interfaceConfig */
import { setFilmstripVisible } from '../../../react/features/filmstrip';
import {
LAYOUTS,
getCurrentLayout,
getMaxColumnCount,
getTileViewGridDimensions,
shouldDisplayTileView
} from '../../../react/features/video-layout';
import UIEvents from '../../../service/UI/UIEvents';
import UIUtil from '../util/UIUtil';
@@ -233,6 +240,10 @@ const Filmstrip = {
* @returns {*|{localVideo, remoteVideo}}
*/
calculateThumbnailSize() {
if (shouldDisplayTileView(APP.store.getState())) {
return this._calculateThumbnailSizeForTileView();
}
const availableSizes = this.calculateAvailableSize();
const width = availableSizes.availableWidth;
const height = availableSizes.availableHeight;
@@ -247,11 +258,10 @@ const Filmstrip = {
* @returns {{availableWidth: number, availableHeight: number}}
*/
calculateAvailableSize() {
let availableHeight = interfaceConfig.FILM_STRIP_MAX_HEIGHT;
const thumbs = this.getThumbs(true);
const numvids = thumbs.remoteThumbs.length;
const localVideoContainer = $('#localVideoContainer');
const state = APP.store.getState();
const currentLayout = getCurrentLayout(state);
const isHorizontalFilmstripView
= currentLayout === LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW;
/**
* If the videoAreaAvailableWidth is set we use this one to calculate
@@ -268,10 +278,15 @@ const Filmstrip = {
- UIUtil.parseCssInt(this.filmstrip.css('borderRightWidth'), 10)
- 5;
let availableHeight = interfaceConfig.FILM_STRIP_MAX_HEIGHT;
let availableWidth = videoAreaAvailableWidth;
const thumbs = this.getThumbs(true);
// If local thumb is not hidden
if (thumbs.localThumb) {
const localVideoContainer = $('#localVideoContainer');
availableWidth = Math.floor(
videoAreaAvailableWidth - (
UIUtil.parseCssInt(
@@ -289,10 +304,12 @@ const Filmstrip = {
);
}
// If the number of videos is 0 or undefined or we're in vertical
// If the number of videos is 0 or undefined or we're not in horizontal
// filmstrip mode we don't need to calculate further any adjustments
// to width based on the number of videos present.
if (numvids && !interfaceConfig.VERTICAL_FILMSTRIP) {
const numvids = thumbs.remoteThumbs.length;
if (numvids && isHorizontalFilmstripView) {
const remoteVideoContainer = thumbs.remoteThumbs.eq(0);
availableWidth = Math.floor(
@@ -322,8 +339,10 @@ const Filmstrip = {
availableHeight
= Math.min(maxHeight, window.innerHeight - 18);
return { availableWidth,
availableHeight };
return {
availableHeight,
availableWidth
};
},
/**
@@ -434,6 +453,51 @@ const Filmstrip = {
};
},
/**
* Calculates the size for thumbnails when in tile view layout.
*
* @returns {{localVideo, remoteVideo}}
*/
_calculateThumbnailSizeForTileView() {
const tileAspectRatio = 16 / 9;
// The distance from the top and bottom of the screen, as set by CSS, to
// avoid overlapping UI elements.
const topBottomPadding = 200;
// Minimum space to keep between the sides of the tiles and the sides
// of the window.
const sideMargins = 30 * 2;
const state = APP.store.getState();
const viewWidth = document.body.clientWidth - sideMargins;
const viewHeight = document.body.clientHeight - topBottomPadding;
const {
columns,
visibleRows
} = getTileViewGridDimensions(state, getMaxColumnCount());
const initialWidth = viewWidth / columns;
const aspectRatioHeight = initialWidth / tileAspectRatio;
const heightOfEach = Math.min(
aspectRatioHeight,
viewHeight / visibleRows);
const widthOfEach = tileAspectRatio * heightOfEach;
return {
localVideo: {
thumbWidth: widthOfEach,
thumbHeight: heightOfEach
},
remoteVideo: {
thumbWidth: widthOfEach,
thumbHeight: heightOfEach
}
};
},
/**
* Resizes thumbnails
* @param local
@@ -443,6 +507,28 @@ const Filmstrip = {
*/
// eslint-disable-next-line max-params
resizeThumbnails(local, remote, forceUpdate = false) {
const state = APP.store.getState();
if (shouldDisplayTileView(state)) {
// The size of the side margins for each tile as set in CSS.
const sideMargins = 10 * 2;
const {
columns,
rows
} = getTileViewGridDimensions(state, getMaxColumnCount());
const hasOverflow = rows > columns;
// Width is set so that the flex layout can automatically wrap
// tiles onto new rows.
this.filmstripRemoteVideos.css({
width: (local.thumbWidth * columns) + (columns * sideMargins)
});
this.filmstripRemoteVideos.toggleClass('has-overflow', hasOverflow);
} else {
this.filmstripRemoteVideos.css('width', '');
}
const thumbs = this.getThumbs(!forceUpdate);
if (thumbs.localThumb) {
@@ -466,13 +552,15 @@ const Filmstrip = {
});
}
const currentLayout = getCurrentLayout(APP.store.getState());
// Let CSS take care of height in vertical filmstrip mode.
if (interfaceConfig.VERTICAL_FILMSTRIP) {
if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) {
$('#filmstripLocalVideo').css({
// adds 4 px because of small video 2px border
width: `${local.thumbWidth + 4}px`
});
} else {
} else if (currentLayout === LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW) {
this.filmstrip.css({
// adds 4 px because of small video 2px border
height: `${remote.thumbHeight + 4}px`

View File

@@ -11,6 +11,7 @@ import {
getAvatarURLByParticipantId
} from '../../../react/features/base/participants';
import { updateSettings } from '../../../react/features/base/settings';
import { shouldDisplayTileView } from '../../../react/features/video-layout';
/* eslint-enable no-unused-vars */
const logger = require('jitsi-meet-logger').getLogger(__filename);
@@ -26,7 +27,7 @@ function LocalVideo(VideoLayout, emitter, streamEndedCallback) {
this.streamEndedCallback = streamEndedCallback;
this.container = this.createContainer();
this.$container = $(this.container);
$('#filmstripLocalVideoThumbnail').append(this.container);
this.updateDOMLocation();
this.localVideoId = null;
this.bindHoverHandler();
@@ -109,16 +110,7 @@ LocalVideo.prototype.changeVideo = function(stream) {
this.localVideoId = `localVideo_${stream.getId()}`;
const localVideoContainer = document.getElementById('localVideoWrapper');
ReactDOM.render(
<Provider store = { APP.store }>
<VideoTrack
id = { this.localVideoId }
videoTrack = {{ jitsiTrack: stream }} />
</Provider>,
localVideoContainer
);
this._updateVideoElement();
// eslint-disable-next-line eqeqeq
const isVideo = stream.videoType != 'desktop';
@@ -128,12 +120,14 @@ LocalVideo.prototype.changeVideo = function(stream) {
this.setFlipX(isVideo ? settings.localFlipX : false);
const endedHandler = () => {
const localVideoContainer
= document.getElementById('localVideoWrapper');
// Only remove if there is no video and not a transition state.
// Previous non-react logic created a new video element with each track
// removal whereas react reuses the video component so it could be the
// stream ended but a new one is being used.
if (this.videoStream.isEnded()) {
if (localVideoContainer && this.videoStream.isEnded()) {
ReactDOM.unmountComponentAtNode(localVideoContainer);
}
@@ -235,6 +229,29 @@ LocalVideo.prototype._enableDisableContextMenu = function(enable) {
}
};
/**
* Places the {@code LocalVideo} in the DOM based on the current video layout.
*
* @returns {void}
*/
LocalVideo.prototype.updateDOMLocation = function() {
if (!this.container) {
return;
}
if (this.container.parentElement) {
this.container.parentElement.removeChild(this.container);
}
const appendTarget = shouldDisplayTileView(APP.store.getState())
? document.getElementById('localVideoTileViewContainer')
: document.getElementById('filmstripLocalVideoThumbnail');
appendTarget && appendTarget.appendChild(this.container);
this._updateVideoElement();
};
/**
* Callback invoked when the thumbnail is clicked. Will directly call
* VideoLayout to handle thumbnail click if certain elements have not been
@@ -258,7 +275,9 @@ LocalVideo.prototype._onContainerClick = function(event) {
= $source.parents('.displayNameContainer').length > 0;
const clickedOnPopover = $source.parents('.popover').length > 0
|| classList.contains('popover');
const ignoreClick = clickedOnDisplayName || clickedOnPopover;
const ignoreClick = clickedOnDisplayName
|| clickedOnPopover
|| shouldDisplayTileView(APP.store.getState());
if (event.stopPropagation && !ignoreClick) {
event.stopPropagation();
@@ -269,4 +288,28 @@ LocalVideo.prototype._onContainerClick = function(event) {
}
};
/**
* Renders the React Element for displaying video in {@code LocalVideo}.
*
*/
LocalVideo.prototype._updateVideoElement = function() {
const localVideoContainer = document.getElementById('localVideoWrapper');
ReactDOM.render(
<Provider store = { APP.store }>
<VideoTrack
id = 'localVideo_container'
videoTrack = {{ jitsiTrack: this.videoStream }} />
</Provider>,
localVideoContainer
);
// Ensure the video gets play() called on it. This may be necessary in the
// case where the local video container was moved and re-attached, in which
// case video does not autoplay.
const video = this.container.querySelector('video');
video && video.play();
};
export default LocalVideo;

View File

@@ -20,6 +20,11 @@ import {
REMOTE_CONTROL_MENU_STATES,
RemoteVideoMenuTriggerButton
} from '../../../react/features/remote-video-menu';
import {
LAYOUTS,
getCurrentLayout,
shouldDisplayTileView
} from '../../../react/features/video-layout';
/* eslint-enable no-unused-vars */
const logger = require('jitsi-meet-logger').getLogger(__filename);
@@ -163,8 +168,17 @@ RemoteVideo.prototype._generatePopupContent = function() {
const onVolumeChange = this._setAudioVolume;
const { isModerator } = APP.conference;
const participantID = this.id;
const menuPosition = interfaceConfig.VERTICAL_FILMSTRIP
? 'left bottom' : 'top center';
const currentLayout = getCurrentLayout(APP.store.getState());
let remoteMenuPosition;
if (currentLayout === LAYOUTS.TILE_VIEW) {
remoteMenuPosition = 'left top';
} else if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) {
remoteMenuPosition = 'left bottom';
} else {
remoteMenuPosition = 'top center';
}
ReactDOM.render(
<Provider store = { APP.store }>
@@ -174,7 +188,7 @@ RemoteVideo.prototype._generatePopupContent = function() {
initialVolumeValue = { initialVolumeValue }
isAudioMuted = { this.isAudioMuted }
isModerator = { isModerator }
menuPosition = { menuPosition }
menuPosition = { remoteMenuPosition }
onMenuDisplay
= {this._onRemoteVideoMenuDisplay.bind(this)}
onRemoteControlToggle = { onRemoteControlToggle }
@@ -613,7 +627,8 @@ RemoteVideo.prototype._onContainerClick = function(event) {
const { classList } = event.target;
const ignoreClick = $source.parents('.popover').length > 0
|| classList.contains('popover');
|| classList.contains('popover')
|| shouldDisplayTileView(APP.store.getState());
if (!ignoreClick) {
this._togglePin();

View File

@@ -27,6 +27,11 @@ import {
RaisedHandIndicator,
VideoMutedIndicator
} from '../../../react/features/filmstrip';
import {
LAYOUTS,
getCurrentLayout,
shouldDisplayTileView
} from '../../../react/features/video-layout';
/* eslint-enable no-unused-vars */
const logger = require('jitsi-meet-logger').getLogger(__filename);
@@ -328,7 +333,21 @@ SmallVideo.prototype.setVideoMutedView = function(isMuted) {
SmallVideo.prototype.updateStatusBar = function() {
const statusBarContainer
= this.container.querySelector('.videocontainer__toolbar');
const tooltipPosition = interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top';
if (!statusBarContainer) {
return;
}
const currentLayout = getCurrentLayout(APP.store.getState());
let tooltipPosition;
if (currentLayout === LAYOUTS.TILE_VIEW) {
tooltipPosition = 'right';
} else if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) {
tooltipPosition = 'left';
} else {
tooltipPosition = 'top';
}
ReactDOM.render(
<I18nextProvider i18n = { i18next }>
@@ -547,7 +566,8 @@ SmallVideo.prototype.isVideoPlayable = function() {
*/
SmallVideo.prototype.selectDisplayMode = function() {
// Display name is always and only displayed when user is on the stage
if (this.isCurrentlyOnLargeVideo()) {
if (this.isCurrentlyOnLargeVideo()
&& !shouldDisplayTileView(APP.store.getState())) {
return this.isVideoPlayable() && !APP.conference.isAudioOnly()
? DISPLAY_BLACKNESS_WITH_NAME : DISPLAY_AVATAR_WITH_NAME;
} else if (this.isVideoPlayable()
@@ -685,7 +705,10 @@ SmallVideo.prototype.showDominantSpeakerIndicator = function(show) {
this._showDominantSpeaker = show;
this.$container.toggleClass('active-speaker', this._showDominantSpeaker);
this.updateIndicators();
this.updateView();
};
/**
@@ -765,6 +788,18 @@ SmallVideo.prototype.initBrowserSpecificProperties = function() {
}
};
/**
* Helper function for re-rendering multiple react components of the small
* video.
*
* @returns {void}
*/
SmallVideo.prototype.rerender = function() {
this.updateIndicators();
this.updateStatusBar();
this.updateView();
};
/**
* Updates the React element responsible for showing connection status, dominant
* speaker, and raised hand icons. Uses instance variables to get the necessary
@@ -784,7 +819,19 @@ SmallVideo.prototype.updateIndicators = function() {
const iconSize = UIUtil.getIndicatorFontSize();
const showConnectionIndicator = this.videoIsHovered
|| !interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_ENABLED;
const tooltipPosition = interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top';
const currentLayout = getCurrentLayout(APP.store.getState());
let statsPopoverPosition, tooltipPosition;
if (currentLayout === LAYOUTS.TILE_VIEW) {
statsPopoverPosition = 'right top';
tooltipPosition = 'right';
} else if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) {
statsPopoverPosition = this.statsPopoverLocation;
tooltipPosition = 'left';
} else {
statsPopoverPosition = this.statsPopoverLocation;
tooltipPosition = 'top';
}
ReactDOM.render(
<I18nextProvider i18n = { i18next }>
@@ -799,7 +846,7 @@ SmallVideo.prototype.updateIndicators = function() {
enableStatsDisplay
= { !interfaceConfig.filmStripOnly }
statsPopoverPosition
= { this.statsPopoverLocation }
= { statsPopoverPosition }
userID = { this.id } />
: null }
{ this._showRaisedHand

View File

@@ -1,6 +1,10 @@
/* global APP, $, interfaceConfig */
const logger = require('jitsi-meet-logger').getLogger(__filename);
import {
getNearestReceiverVideoQualityLevel,
setMaxReceiverVideoQuality
} from '../../../react/features/base/conference';
import {
JitsiParticipantConnectionStatus
} from '../../../react/features/base/lib-jitsi-meet';
@@ -9,6 +13,9 @@ import {
getPinnedParticipant,
pinParticipant
} from '../../../react/features/base/participants';
import {
shouldDisplayTileView
} from '../../../react/features/video-layout';
import { SHARED_VIDEO_CONTAINER_TYPE } from '../shared_video/SharedVideo';
import SharedVideoThumb from '../shared_video/SharedVideoThumb';
@@ -594,12 +601,19 @@ const VideoLayout = {
Filmstrip.resizeThumbnails(localVideo, remoteVideo, forceUpdate);
if (shouldDisplayTileView(APP.store.getState())) {
const height
= (localVideo && localVideo.thumbHeight)
|| (remoteVideo && remoteVideo.thumbnHeight)
|| 0;
const qualityLevel = getNearestReceiverVideoQualityLevel(height);
APP.store.dispatch(setMaxReceiverVideoQuality(qualityLevel));
}
if (onComplete && typeof onComplete === 'function') {
onComplete();
}
return { localVideo,
remoteVideo };
},
/**
@@ -1142,6 +1156,22 @@ const VideoLayout = {
);
},
/**
* Helper method to invoke when the video layout has changed and elements
* have to be re-arranged and resized.
*
* @returns {void}
*/
refreshLayout() {
localVideoThumbnail && localVideoThumbnail.updateDOMLocation();
VideoLayout.resizeVideoArea();
localVideoThumbnail && localVideoThumbnail.rerender();
Object.values(remoteVideos).forEach(
remoteVideo => remoteVideo.rerender()
);
},
/**
* Triggers an update of large video if the passed in participant is
* currently displayed on large video.

54
package-lock.json generated
View File

@@ -3003,6 +3003,15 @@
"sdp-transform": "2.3.0"
}
},
"@microsoft/microsoft-graph-client": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-1.1.0.tgz",
"integrity": "sha512-sDgchKZz1l3QJVNdkE1P1KpwTjupNt1mS9h1T0CiP+ayMN7IeFKfElB8IYtxFplNalZTmEq+iqoQFqUVpVMLfQ==",
"requires": {
"es6-promise": "^4.1.0",
"isomorphic-fetch": "^2.2.1"
}
},
"@webcomponents/url": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@webcomponents/url/-/url-0.7.1.tgz",
@@ -6024,6 +6033,15 @@
"domelementtype": "1"
}
},
"dropbox": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/dropbox/-/dropbox-4.0.9.tgz",
"integrity": "sha512-UeaKw7DY24ZGLRV8xboZvbZXhbTVrFjPjfpr0LfF/KVOzBUad9vJJwqz3udqTLNxD0FXbFlC9rlNLLNXaj9msg==",
"requires": {
"buffer": "^5.0.8",
"moment": "^2.19.3"
}
},
"duplexify": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.5.4.tgz",
@@ -6263,6 +6281,11 @@
"event-emitter": "~0.3.5"
}
},
"es6-promise": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.4.tgz",
"integrity": "sha512-/NdNZVJg+uZgtm9eS3O6lrOLYmQag2DjdEXuPaHlZ6RuVqgqaVZfgYCepEIKsLqwdQArOPtC3XzRLqGGfT8KQQ=="
},
"es6-set": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz",
@@ -6449,8 +6472,8 @@
}
},
"eslint-config-jitsi": {
"version": "github:jitsi/eslint-config-jitsi#3d193df6476a73f827582e137a67a8612130a455",
"from": "github:jitsi/eslint-config-jitsi#v0.1.0",
"version": "github:jitsi/eslint-config-jitsi#7474f6668515eb5852f1273dc5a50b940a550d3f",
"from": "github:jitsi/eslint-config-jitsi#7474f6668515eb5852f1273dc5a50b940a550d3f",
"dev": true
},
"eslint-import-resolver-node": {
@@ -9643,6 +9666,11 @@
"verror": "1.10.0"
}
},
"jsrsasign": {
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-8.0.12.tgz",
"integrity": "sha1-Iqu5ZW00owuVMENnIINeicLlwxY="
},
"jssha": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-2.3.1.tgz",
@@ -9719,8 +9747,8 @@
}
},
"lib-jitsi-meet": {
"version": "github:jitsi/lib-jitsi-meet#2be752fc88ff71e454c6b9178b21a33b59c53f41",
"from": "github:jitsi/lib-jitsi-meet#2be752fc88ff71e454c6b9178b21a33b59c53f41",
"version": "github:jitsi/lib-jitsi-meet#095a4485f2e6749f5b4fe91da7088aed359ee728",
"from": "github:jitsi/lib-jitsi-meet#095a4485f2e6749f5b4fe91da7088aed359ee728",
"requires": {
"@jitsi/sdp-interop": "0.1.13",
"@jitsi/sdp-simulcast": "0.2.1",
@@ -9736,6 +9764,10 @@
"yaeti": "1.0.1"
}
},
"libflacjs": {
"version": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d",
"from": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d"
},
"load-json-file": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
@@ -12712,8 +12744,8 @@
"integrity": "sha512-vLNJIedXQZN4p3ChFsAgVHacnJqQMnLl+wBsnZuliRkmsjEHo8kQOA9fnLih/OoiDi1O3eHQvXC5L8f+RYiKgw=="
},
"react-native-calendar-events": {
"version": "github:jitsi/react-native-calendar-events#cad37355f36d17587d84af72b0095e8cc5fd3df9",
"from": "github:jitsi/react-native-calendar-events#cad37355f36d17587d84af72b0095e8cc5fd3df9"
"version": "github:wmcmahan/react-native-calendar-events#cb2731db6684a49b4343e09de7f9c2fcc68bcd9b",
"from": "github:wmcmahan/react-native-calendar-events#cb2731db6684a49b4343e09de7f9c2fcc68bcd9b"
},
"react-native-callstats": {
"version": "3.52.0",
@@ -12802,8 +12834,8 @@
}
},
"react-native-webrtc": {
"version": "github:jitsi/react-native-webrtc#6b0ea124414f6f5b7f234a7d5cec75d30f5f6312",
"from": "github:jitsi/react-native-webrtc#6b0ea124414f6f5b7f234a7d5cec75d30f5f6312",
"version": "github:jitsi/react-native-webrtc#bed49210a51cf53081954028589d720381e7cf40",
"from": "github:jitsi/react-native-webrtc#bed49210a51cf53081954028589d720381e7cf40",
"requires": {
"base64-js": "^1.1.2",
"event-target-shim": "^1.0.5",
@@ -14298,9 +14330,9 @@
}
},
"sdp": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-2.7.4.tgz",
"integrity": "sha512-0+wTfgvUUEGcvvFoHIC0aiGbx6gzwAUm8FkKt5Oqqkjf9mEEDLgwnoDKX7MYTGXrNNwzikVbutJ+OVNAGmJBQw=="
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-2.8.0.tgz",
"integrity": "sha512-wRSES07rAwKWAR7aev9UuClT7kdf9ZTdeUK5gTgHue9vlhs19Fbm3ccNEGJO4y2IitH4/JzS4sdzyPl6H2KQLw=="
},
"sdp-transform": {
"version": "2.3.0",

View File

@@ -34,8 +34,10 @@
"@atlaskit/tabs": "4.0.1",
"@atlaskit/theme": "2.4.0",
"@atlaskit/tooltip": "9.1.1",
"@microsoft/microsoft-graph-client": "1.1.0",
"@webcomponents/url": "0.7.1",
"autosize": "1.18.13",
"dropbox": "4.0.9",
"i18next": "8.4.3",
"i18next-browser-languagedetector": "2.0.0",
"i18next-xhr-backend": "1.4.2",
@@ -46,8 +48,10 @@
"jquery-i18next": "1.2.0",
"js-md5": "0.6.1",
"jsc-android": "224109.1.0",
"jsrsasign": "8.0.12",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#2be752fc88ff71e454c6b9178b21a33b59c53f41",
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#095a4485f2e6749f5b4fe91da7088aed359ee728",
"libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d",
"lodash": "4.17.4",
"moment": "2.19.4",
"moment-duration-format": "2.2.2",
@@ -58,7 +62,7 @@
"react-i18next": "4.8.0",
"react-native": "0.55.4",
"react-native-background-timer": "2.0.0",
"react-native-calendar-events": "github:jitsi/react-native-calendar-events#cad37355f36d17587d84af72b0095e8cc5fd3df9",
"react-native-calendar-events": "github:wmcmahan/react-native-calendar-events#cb2731db6684a49b4343e09de7f9c2fcc68bcd9b",
"react-native-callstats": "3.52.0",
"react-native-fast-image": "4.0.14",
"react-native-immersive": "1.1.0",
@@ -69,7 +73,7 @@
"react-native-prompt": "1.0.0",
"react-native-sound": "0.10.9",
"react-native-vector-icons": "4.4.2",
"react-native-webrtc": "github:jitsi/react-native-webrtc#6b0ea124414f6f5b7f234a7d5cec75d30f5f6312",
"react-native-webrtc": "github:jitsi/react-native-webrtc#bed49210a51cf53081954028589d720381e7cf40",
"react-redux": "5.0.7",
"redux": "4.0.0",
"redux-thunk": "2.2.0",
@@ -87,7 +91,7 @@
"clean-css": "3.4.25",
"css-loader": "0.28.7",
"eslint": "4.12.1",
"eslint-config-jitsi": "github:jitsi/eslint-config-jitsi#v0.1.0",
"eslint-config-jitsi": "github:jitsi/eslint-config-jitsi#7474f6668515eb5852f1273dc5a50b940a550d3f",
"eslint-plugin-flowtype": "2.39.1",
"eslint-plugin-import": "2.8.0",
"eslint-plugin-jsdoc": "3.2.0",

View File

@@ -124,6 +124,8 @@ function _appNavigateToOptionalLocation(
// FIXME Turn location's host, hostname, and port properties into
// setters in order to reduce the risks of inconsistent state.
location.hostname = defaultLocation.hostname;
location.pathname
= defaultLocation.pathname + location.pathname.substr(1);
location.port = defaultLocation.port;
location.protocol = defaultLocation.protocol;
} else {

View File

@@ -8,7 +8,8 @@ import {
AVATAR_ID_COMMAND,
AVATAR_URL_COMMAND,
EMAIL_COMMAND,
JITSI_CONFERENCE_URL_KEY
JITSI_CONFERENCE_URL_KEY,
VIDEO_QUALITY_LEVELS
} from './constants';
const logger = require('jitsi-meet-logger').getLogger(__filename);
@@ -102,6 +103,38 @@ export function getCurrentConference(stateful: Function | Object) {
: joining);
}
/**
* Finds the nearest match for the passed in {@link availableHeight} to am
* enumerated value in {@code VIDEO_QUALITY_LEVELS}.
*
* @param {number} availableHeight - The height to which a matching video
* quality level should be found.
* @returns {number} The closest matching value from
* {@code VIDEO_QUALITY_LEVELS}.
*/
export function getNearestReceiverVideoQualityLevel(availableHeight: number) {
const qualityLevels = [
VIDEO_QUALITY_LEVELS.HIGH,
VIDEO_QUALITY_LEVELS.STANDARD,
VIDEO_QUALITY_LEVELS.LOW
];
let selectedLevel = qualityLevels[0];
for (let i = 1; i < qualityLevels.length; i++) {
const previousValue = qualityLevels[i - 1];
const currentValue = qualityLevels[i];
const diffWithCurrent = Math.abs(availableHeight - currentValue);
const diffWithPrevious = Math.abs(availableHeight - previousValue);
if (diffWithCurrent < diffWithPrevious) {
selectedLevel = currentValue;
}
}
return selectedLevel;
}
/**
* Handle an error thrown by the backend (i.e. lib-jitsi-meet) while
* manipulating a conference participant (e.g. pin or select participant).

View File

@@ -96,6 +96,7 @@ const WHITELISTED_KEYS = [
'disableRtx',
'disableSuspendVideo',
'displayJids',
'e2eping',
'enableDisplayNameInStats',
'enableLayerSuspension',
'enableLipSync',

View File

@@ -37,9 +37,6 @@ const INITIAL_RN_STATE = {
// fastest to merely disable them.
disableAudioLevels: true,
// FIXME flow complains about missing 'locationURL' missing in _setConfig
locationURL: undefined,
p2p: {
disableH264: false,
preferH264: true
@@ -129,10 +126,8 @@ function _setConfig(state, { config }) {
const newState = _.merge(
{},
config, {
error: undefined,
locationURL: state.locationURL
},
config,
{ error: undefined },
// The config of _getInitialState() is meant to override the config
// downloaded from the Jitsi Meet deployment because the former contains

View File

@@ -9,7 +9,7 @@ import {
getCurrentConference
} from '../conference';
import JitsiMeetJS, { JitsiConnectionEvents } from '../lib-jitsi-meet';
import { parseStandardURIString } from '../util';
import { parseURIString } from '../util';
import {
CONNECTION_DISCONNECTED,
@@ -276,18 +276,32 @@ function _connectionWillConnect(connection) {
* {@code JitsiConnection}.
*/
function _constructOptions(state) {
const defaultOptions = state['features/base/connection'].options;
const options = _.merge(
{},
defaultOptions,
// Deep clone the options to make sure we don't modify the object in the
// redux store.
const options = _.cloneDeep(state['features/base/config']);
// Lib-jitsi-meet wants the config passed in multiple places and here is
// the latest one I have discovered.
state['features/base/config'],
);
// Normalize the BOSH URL.
let { bosh } = options;
if (bosh) {
if (bosh.startsWith('//')) {
// By default our config.js doesn't include the protocol.
const { locationURL } = state['features/base/connection'];
bosh = `${locationURL.protocol}${bosh}`;
} else if (bosh.startsWith('/')) {
// Handle relative URLs, which won't work on mobile.
const { locationURL } = state['features/base/connection'];
const {
protocol,
hostname,
contextRoot
} = parseURIString(locationURL.href);
// eslint-disable-next-line max-len
bosh = `${protocol}//${hostname}${contextRoot || '/'}${bosh.substr(1)}`;
}
// Append room to the URL's search.
const { room } = state['features/base/conference'];
@@ -296,16 +310,6 @@ function _constructOptions(state) {
// not ignore case themselves.
room && (bosh += `?room=${room.toLowerCase()}`);
// XXX By default, config.js does not add a protocol to the BOSH URL.
// Which trips React Native. Make sure there is a protocol in order to
// satisfy React Native.
if (bosh !== defaultOptions.bosh
&& !parseStandardURIString(bosh).protocol) {
const { protocol } = parseStandardURIString(defaultOptions.bosh);
protocol && (bosh = protocol + bosh);
}
options.bosh = bosh;
}

View File

@@ -2,8 +2,7 @@
import { SET_ROOM } from '../conference';
import { JitsiConnectionErrors } from '../lib-jitsi-meet';
import { assign, ReducerRegistry } from '../redux';
import { parseURIString } from '../util';
import { assign, set, ReducerRegistry } from '../redux';
import {
CONNECTION_DISCONNECTED,
@@ -153,50 +152,6 @@ function _connectionWillConnect(
});
}
/**
* Constructs options to be passed to the constructor of {@code JitsiConnection}
* based on a specific location URL.
*
* @param {string} locationURL - The location URL with which the returned
* options are to be constructed.
* @private
* @returns {Object} The options to be passed to the constructor of
* {@code JitsiConnection} based on the location URL.
*/
function _constructOptions(locationURL: URL) {
const locationURI = parseURIString(locationURL.href);
// FIXME The HTTPS scheme for the BOSH URL works with meet.jit.si on both
// mobile & Web. It also works with beta.meet.jit.si on Web. Unfortunately,
// it doesn't work with beta.meet.jit.si on mobile. Temporarily, use the
// HTTP scheme for the BOSH URL with beta.meet.jit.si on mobile.
let { protocol } = locationURI;
const domain = locationURI.hostname;
if (!protocol && domain === 'beta.meet.jit.si') {
const windowLocation = window.location;
windowLocation && (protocol = windowLocation.protocol);
protocol || (protocol = 'http:');
}
// Default to the HTTPS scheme for the BOSH URL.
protocol || (protocol = 'https:');
return {
bosh:
`${String(protocol)}//${domain}${
locationURI.contextRoot || '/'}http-bind`,
hosts: {
domain,
// Required by:
// - lib-jitsi-meet/modules/xmpp/xmpp.js
muc: `conference.${domain}`
}
};
}
/**
* The current (similar to getCurrentConference in base/conference/functions.js)
* connection which is {@code connection} or {@code connecting}.
@@ -223,10 +178,7 @@ function _getCurrentConnection(baseConnectionState: Object): ?Object {
function _setLocationURL(
state: Object,
{ locationURL }: { locationURL: ?URL }) {
return assign(state, {
locationURL,
options: locationURL ? _constructOptions(locationURL) : undefined
});
return set(state, 'locationURL', locationURL);
}
/**

View File

@@ -113,7 +113,7 @@ class Dialog extends AbstractDialog<Props, State> {
[_TAG_KEY]: _SUBMIT_TEXT_TAG_VALUE
};
let el: ?React$Element<*> = ( // eslint-disable-line no-extra-parens
let el: ?React$Element<*> = (
<Prompt
cancelButtonTextStyle = { cancelButtonTextStyle }
cancelText = { t(cancelTitleKey) }

View File

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

View File

@@ -14,6 +14,7 @@ export const JitsiConnectionErrors = JitsiMeetJS.errors.connection;
export const JitsiConnectionEvents = JitsiMeetJS.events.connection;
export const JitsiConnectionQualityEvents
= JitsiMeetJS.events.connectionQuality;
export const JitsiE2ePingEvents = JitsiMeetJS.events.e2eping;
export const JitsiMediaDevicesEvents = JitsiMeetJS.events.mediaDevices;
export const JitsiParticipantConnectionStatus
= JitsiMeetJS.constants.participantConnectionStatus;

View File

@@ -126,7 +126,6 @@ function _visitNode(node, callback) {
//
// Required by:
// - jQuery
// - lib-jitsi-meet/modules/RTC/adapter.screenshare.js
// - Strophe
if (typeof global.document === 'undefined') {
const document
@@ -151,14 +150,6 @@ function _visitNode(node, callback) {
document.cookie = '';
}
// document.implementation
//
// Required by:
// - jQuery
if (typeof document.implementation === 'undefined') {
document.implementation = {};
}
// document.implementation.createHTMLDocument
//
// Required by:
@@ -362,26 +353,9 @@ function _visitNode(node, callback) {
const { navigator } = global;
if (navigator) {
// platform
//
// Required by:
// - lib-jitsi-meet/modules/RTC/adapter.screenshare.js
if (typeof navigator.platform === 'undefined') {
navigator.platform = '';
}
// plugins
//
// Required by:
// - lib-jitsi-meet/modules/RTC/adapter.screenshare.js
if (typeof navigator.plugins === 'undefined') {
navigator.plugins = [];
}
// userAgent
//
// Required by:
// - lib-jitsi-meet/modules/RTC/adapter.screenshare.js
// - lib-jitsi-meet/modules/browser/BrowserDetection.js
let userAgent = navigator.userAgent || '';

View File

@@ -93,7 +93,7 @@ export default class Video extends Component<*> {
? 'contain'
: (style && style.objectFit) || 'cover';
const rtcView
= ( // eslint-disable-line no-extra-parens
= (
<RTCView
mirror = { this.props.mirror }
objectFit = { objectFit }

View File

@@ -12,6 +12,11 @@ export type Item = {
*/
colorBase: string,
/**
* An optional react element to append to the end of the Item.
*/
elementAfter?: ?ComponentType<any>,
/**
* Item title
*/

View File

@@ -87,7 +87,7 @@ class NavigateSectionList extends Component<Props> {
*/
render() {
const {
renderListEmptyComponent = this._renderListEmptyComponent,
renderListEmptyComponent = this._renderListEmptyComponent(),
sections
} = this.props;
@@ -128,11 +128,13 @@ class NavigateSectionList extends Component<Props> {
* @returns {Function}
*/
_onPress(url) {
return () => {
const { disabled, onPress } = this.props;
const { disabled, onPress } = this.props;
!disabled && url && typeof onPress === 'function' && onPress(url);
};
if (!disabled && url && typeof onPress === 'function') {
return () => onPress(url);
}
return null;
}
_onRefresh: () => void;

View File

@@ -41,7 +41,7 @@ class InlineDialogFailure extends Component<*> {
const supportString = t('inlineDialogFailure.supportMsg');
const supportLinkElem
= supportLink
? ( // eslint-disable-line no-extra-parens
? (
<div className = 'inline-dialog-error-text'>
<span>{ supportString.padEnd(supportString.length + 1) }
</span>

View File

@@ -244,7 +244,7 @@ class MultiSelectAutocomplete extends Component {
if (!this.state.error) {
return null;
}
const content = ( // eslint-disable-line no-extra-parens
const content = (
<div className = 'autocomplete-error'>
<InlineDialogFailure
onRetry = { this._onRetry } />

View File

@@ -6,18 +6,22 @@ import Container from './Container';
import Text from './Text';
import type { Item } from '../../Types';
/**
* The type of the React {@code Component} props of
* {@link NavigateSectionListItem}.
*/
type Props = {
/**
* Function to be invoked when an item is pressed. The item's URL is passed.
*/
onPress: Function,
onPress: ?Function,
/**
* A item containing data to be rendered
*/
item: Item
}
};
/**
* Implements a React/Web {@link Component} for displaying an item in a
@@ -25,14 +29,16 @@ type Props = {
*
* @extends Component
*/
export default class NavigateSectionListItem extends Component<Props> {
export default class NavigateSectionListItem<P: Props>
extends Component<P> {
/**
* Renders the content of this component.
*
* @returns {ReactElement}
*/
render() {
const { lines, title } = this.props.item;
const { elementAfter, lines, title } = this.props.item;
const { onPress } = this.props;
/**
@@ -52,22 +58,28 @@ export default class NavigateSectionListItem extends Component<Props> {
duration = lines[1];
}
const rootClassName = `navigate-section-list-tile ${
onPress ? 'with-click-handler' : 'without-click-handler'}`;
return (
<Container
className = 'navigate-section-list-tile'
className = { rootClassName }
onClick = { onPress }>
<Text
className = 'navigate-section-tile-title'>
{ title }
</Text>
<Text
className = 'navigate-section-tile-body'>
{ date }
</Text>
<Text
className = 'navigate-section-tile-body'>
{ duration }
</Text>
<Container className = 'navigate-section-list-tile-info'>
<Text
className = 'navigate-section-tile-title'>
{ title }
</Text>
<Text
className = 'navigate-section-tile-body'>
{ date }
</Text>
<Text
className = 'navigate-section-tile-body'>
{ duration }
</Text>
</Container>
{ elementAfter || null }
</Container>
);
}

View File

@@ -7,6 +7,11 @@ import type { Section } from '../../Types';
type Props = {
/**
* Rendered when the list is empty. Should be a rendered element.
*/
ListEmptyComponent: Object,
/**
* Used to extract a unique key for a given item at the specified index.
* Key is used for caching and as the react key to track item re-ordering.
@@ -49,6 +54,7 @@ export default class SectionList extends Component<Props> {
*/
render() {
const {
ListEmptyComponent,
renderSectionHeader,
renderItem,
sections,
@@ -56,34 +62,34 @@ export default class SectionList extends Component<Props> {
} = this.props;
/**
* If there are no recent items we dont want to display anything
* If there are no recent items we don't want to display anything
*/
if (sections) {
return (
/* eslint-disable no-extra-parens */
<Container
className = 'navigate-section-list'>
{
sections.map((section, sectionIndex) => (
<Container
key = { sectionIndex }>
{ renderSectionHeader(section) }
{ section.data
.map((item, listIndex) => {
const listItem = {
item
};
sections.length === 0
? ListEmptyComponent
: sections.map((section, sectionIndex) => (
<Container
key = { sectionIndex }>
{ renderSectionHeader(section) }
{ section.data
.map((item, listIndex) => {
const listItem = {
item
};
return renderItem(listItem,
keyExtractor(section,
listIndex));
}) }
</Container>
)
)
return renderItem(listItem,
keyExtractor(section,
listIndex));
}) }
</Container>
)
)
}
</Container>
/* eslint-enable no-extra-parens */
);
}

View File

@@ -105,7 +105,7 @@ class Watermarks extends Component<*, *> {
let reactElement = null;
if (this.state.showBrandWatermark) {
reactElement = ( // eslint-disable-line no-extra-parens
reactElement = (
<div
className = 'watermark rightwatermark'
style = { _RIGHT_WATERMARK_STYLE } />
@@ -114,7 +114,7 @@ class Watermarks extends Component<*, *> {
const { brandWatermarkLink } = this.state;
if (brandWatermarkLink) {
reactElement = ( // eslint-disable-line no-extra-parens
reactElement = (
<a
href = { brandWatermarkLink }
target = '_new'>
@@ -144,7 +144,7 @@ class Watermarks extends Component<*, *> {
const { jitsiWatermarkLink } = this.state;
if (jitsiWatermarkLink) {
reactElement = ( // eslint-disable-line no-extra-parens
reactElement = (
<a
href = { jitsiWatermarkLink }
target = '_new'>

View File

@@ -1,14 +0,0 @@
/**
* FIXME.
*
* {
* type: SET_SESSION,
* session: {
* url: {string},
* state: {string},
* ...data
* }
* }
* @public
*/
export const SET_SESSION = Symbol('SET_SESSION');

View File

@@ -1,16 +0,0 @@
import { SET_SESSION } from './actionTypes';
/**
* FIXME.
*
* @param {string} session - FIXME.
* @returns {{
* type: SET_SESSION
* }}
*/
export function setSession(session) {
return {
type: SET_SESSION,
session
};
}

View File

@@ -1,12 +0,0 @@
export const SESSION_CONFIGURED = Symbol('SESSION_CONFIGURED');
export const SESSION_ENDED = Symbol('SESSION_ENDED');
export const SESSION_FAILED = Symbol('SESSION_FAILED');
export const SESSION_STARTED = Symbol('SESSION_STARTED');
export const SESSION_WILL_END = Symbol('SESSION_WILL_END');
export const SESSION_WILL_START = Symbol('SESSION_WILL_START');

View File

@@ -1,36 +0,0 @@
// @flow
import { toState } from '../redux';
import { toURLString } from '../util';
/**
* FIXME.
*
* @param {Function|Object} stateful - FIXME.
* @param {string} url - FIXME.
* @returns {*}
*/
export function getSession(stateful: Function | Object, url: string): ?Object {
const state = toState(stateful);
const session = state['features/base/session'].get(url);
if (!session) {
console.info(`SESSION NOT FOUND FOR URL: ${url}`);
}
return session;
}
/**
* FIXME.
*
* @param {Function | Object} stateful - FIXME.
* @returns {Object}
*/
export function getCurrentSession(stateful: Function | Object): ?Object {
const state = toState(stateful);
const { locationURL } = state['features/base/config'];
return getSession(state, toURLString(locationURL));
}

View File

@@ -1,349 +0,0 @@
// @flow
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_LEFT,
CONFERENCE_WILL_JOIN,
CONFERENCE_WILL_LEAVE,
JITSI_CONFERENCE_URL_KEY,
isRoomValid
} from '../../base/conference';
import {
CONNECTION_DISCONNECTED,
CONNECTION_FAILED,
CONNECTION_WILL_CONNECT
} from '../../base/connection';
import {
MiddlewareRegistry,
toState
} from '../../base/redux';
import { parseURIString, toURLString } from '../../base/util';
import {
SESSION_CONFIGURED,
SESSION_ENDED,
SESSION_FAILED,
SESSION_STARTED,
SESSION_WILL_END,
SESSION_WILL_START
} from './constants';
import { setSession } from './actions';
import { CONFIG_WILL_LOAD, LOAD_CONFIG_ERROR, SET_CONFIG } from '../config';
import { getCurrentSession, getSession } from './functions';
/**
* Middleware that maintains conference sessions. The features spans across
* three features strictly related to the conference lifecycle.
* The first one is the base/config which configures the session. It's
* 'locationURL' state is used to tell what's the current conference URL the app
* is working with. The session starts as soon as {@link CONFIG_WILL_LOAD} event
* arrives. The {@code locationURL} instance is stored in the session to
* associate the load config request with the session and be able to distinguish
* between the current and outdated load config request failures. After the
* config is loaded the lifecycle moves to the base/connection feature which
* creates a {@code JitsiConnection} and tries to connect to the server. On
* {@code CONNECTION_WILL_CONNECT} the connection instance is stored in the
* session and used later to filter the events similar to what's done for
* the load config requests. The base/conference feature adds the last part to
* the session's lifecycle. A {@code JitsiConference} instance is stored in the
* session on the {@code CONFERENCE_WILL_JOIN} action. A session is considered
* alive as long as either connection or conference is available and
* operational.
*
* @param {Store} store - Redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const result = next(action);
const { type } = action;
switch (type) {
case CONFERENCE_WILL_JOIN: {
const { conference } = action;
const { locationURL } = store.getState()['features/base/connection'];
const url = toURLString(locationURL);
const session = getSession(store, url);
if (session) {
store.dispatch(setSession({
url: session.url,
conference
}));
} else {
console.info(`IGNORED WILL_JOIN FOR: ${url}`);
}
break;
}
case CONFERENCE_JOINED: {
const { conference } = action;
const session = findSessionForConference(store, conference);
const state = session && session.state;
if (state === SESSION_CONFIGURED) {
store.dispatch(
setSession({
// Flow complains that the session can be undefined, but it
// can't if the state is defined.
// $FlowExpectedError
url: session.url,
state: SESSION_STARTED
}));
} else {
// eslint-disable-next-line max-len
console.info(`IGNORED CONF JOINED FOR: ${toURLString(conference[JITSI_CONFERENCE_URL_KEY])}`);
}
break;
}
case CONFERENCE_LEFT:
case CONFERENCE_FAILED: {
const { conference, error } = action;
const session = findSessionForConference(store, conference);
// FIXME update comments
// XXX Certain CONFERENCE_FAILED errors are recoverable i.e. they have
// prevented the user from joining a specific conference but the app may
// be able to eventually join the conference. For example, the app will
// ask the user for a password upon
// JitsiConferenceErrors.PASSWORD_REQUIRED and will retry joining the
// conference afterwards. Such errors are to not reach the native
// counterpart of the External API (or at least not in the
// fatality/finality semantics attributed to
// conferenceFailed:/onConferenceFailed).
if (session) {
if (!error || isGameOver(store, session, error)) {
if (session.connection) {
store.dispatch(
setSession({
url: session.url,
conference: undefined
}));
} else {
store.dispatch(
setSession({
url: session.url,
state: error ? SESSION_FAILED : SESSION_ENDED,
error
}));
}
}
} else {
// eslint-disable-next-line max-len
console.info(`IGNORED FAILED/LEFT for ${toURLString(conference[JITSI_CONFERENCE_URL_KEY])}`, error);
}
break;
}
// NOTE WILL_JOIN is fired on SET_ROOM
// case CONFERENCE_WILL_JOIN:
case CONFERENCE_WILL_LEAVE: {
const { conference } = action;
const url = toURLString(conference[JITSI_CONFERENCE_URL_KEY]);
const session = findSessionForConference(store, conference);
const state = session && session.state;
if (state && state !== SESSION_WILL_END) {
store.dispatch(
setSession({
// Flow complains that the session can be undefined, but it
// can't if the state is defined.
// $FlowExpectedError
url: session.url,
state: SESSION_WILL_END
}));
} else {
console.info(`IGNORED WILL LEAVE FOR ${url}`);
}
break;
}
case CONNECTION_WILL_CONNECT: {
const { connection } = action;
const { locationURL } = store.getState()['features/base/connection'];
const url = toURLString(locationURL);
const session = getSession(store, url);
if (session) {
store.dispatch(
setSession({
url: session.url,
connection,
conference: undefined // Detach from the old conference
}));
} else {
console.info(`IGNORED CONNECTION_WILL_CONNECT FOR: ${url}`);
}
break;
}
case CONNECTION_DISCONNECTED:
case CONNECTION_FAILED: {
const { connection, error } = action;
const session = findSessionForConnection(store, connection);
if (session) {
// Remove connection from the session, but wait for
// the conference to be removed as well.
if (!error || isGameOver(store, session, error)) {
if (session.conference) {
store.dispatch(
setSession({
url: session.url,
connection: undefined
}));
} else {
store.dispatch(
setSession({
url: session.url,
state: error ? SESSION_FAILED : SESSION_ENDED,
error
}));
}
}
} else {
console.info('Ignored DISCONNECTED/FAILED for connection');
}
break;
}
case SET_CONFIG: {
// XXX SET_CONFIG IS ALWAYS RELEVANT
const { locationURL } = store.getState()['features/base/config'];
const url = toURLString(locationURL);
const session = getSession(store, url);
const state = session && session.state;
if (state === SESSION_WILL_START) {
store.dispatch(
setSession({
url,
state: SESSION_CONFIGURED
}));
}
break;
}
case CONFIG_WILL_LOAD: {
const { locationURL } = action;
const url = toURLString(locationURL);
const session = getSession(store, url);
// The back and forth to string conversion is here, because there's no
// guarantee that the locationURL will be the exact custom structure
// which contains the room property.
let { room } = parseURIString(url);
// Validate the room
room = isRoomValid(room) ? room : undefined;
if (room && !session) {
store.dispatch(
setSession({
url,
state: SESSION_WILL_START,
locationURL,
room
}));
} else if (room && session) {
// Update to the new locationURL instance
store.dispatch(
setSession({
url,
locationURL
}));
} else {
console.info(`IGNORED CFG WILL LOAD FOR ${url}`);
}
break;
}
case LOAD_CONFIG_ERROR: {
const { error, locationURL } = action;
const url = toURLString(locationURL);
const session = getSession(store, url);
if (session && session.locationURL === locationURL) {
if (isGameOver(store, session, error)) {
store.dispatch(
setSession({
url,
state: SESSION_FAILED,
error
}));
}
} else {
console.info(`IGNORED LOAD_CONFIG_ERROR FOR: ${url}`);
}
break;
}
}
return result;
});
/**
* FIXME A session is to be terminated either when the recoverable flag is
* explicitly set to {@code false} or if the error arrives for a session which
* is no longer current (the app has started working with another session).
* This can happen when a conference which is being disconnected fails in which
* case the session needs to be ended even if the flag is not {@code false}
* because we know that there's no fatal error handling. This is kind of
* a contract between the fatal error feature and the session which probably
* indicates that the fatal error detection and handling should be incorporated
* into the session feature.
*
* @param {Object | Function} stateful - FIXME.
* @param {Object} session - FIXME.
* @param {Object} error - FIXME.
* @returns {boolean}
*/
function isGameOver(stateful, session, error) {
return getCurrentSession(stateful) !== session
|| error.recoverable === false;
}
/**
* FIXME.
*
* @param {Object | Function} stateful - FIXME.
* @param {JitsiConnection} connection - FIXME.
* @returns {Object|undefined}
*/
function findSessionForConnection(stateful, connection) {
const state = toState(stateful);
for (const session of state['features/base/session'].values()) {
if (session.connection === connection) {
return session;
}
}
console.info('Session not found for a connection');
return undefined;
}
/**
* FIXME.
*
* @param {Object | Function} stateful - FIXME.
* @param {JitsiConference} conference - FIXME.
* @returns {Object|undefined}
*/
function findSessionForConference(stateful, conference) {
const state = toState(stateful);
for (const session of state['features/base/session'].values()) {
if (session.conference === conference) {
return session;
}
}
console.info('Session not found for a conference');
return undefined;
}

View File

@@ -1,71 +0,0 @@
// @flow
import { assign, ReducerRegistry } from '../../base/redux';
import { getSymbolDescription } from '../util';
import { SET_SESSION } from './actionTypes';
import {
SESSION_FAILED,
SESSION_ENDED,
SESSION_WILL_START
} from './constants';
ReducerRegistry.register('features/base/session',
(state = new Map(), action) => {
switch (action.type) {
case SET_SESSION:
return _setSession(state, action);
}
return state;
});
/**
* FIXME.
*
* @param {Object} featureState - FIXME.
* @param {Object} action - FIXME.
* @returns {Map<any, any>} - FIXME.
* @private
*/
function _setSession(featureState, action) {
const { url, state, ...data } = action.session;
const session = featureState.get(url);
const nextState = new Map(featureState);
// Drop the whole action if the url is not defined
if (!url) {
console.error('SET SESSION - NO URL');
return nextState;
}
if (session) {
if (state === SESSION_ENDED || state === SESSION_FAILED) {
nextState.delete(url);
} else {
nextState.set(
url,
assign(session, {
url,
state: state ? state : session.state,
...data
}));
}
} else if (state === SESSION_WILL_START) {
nextState.set(
url, {
url,
state,
...data
});
}
console.info(
'SESSION STATE REDUCED: ',
new Map(nextState),
url,
state && getSymbolDescription(state),
action.session.error);
return nextState;
}

View File

@@ -67,7 +67,7 @@ export default class ToolboxItem extends AbstractToolboxItem<Props> {
// XXX TouchableHighlight requires 1 child. If there's a need to
// show both the icon and the label, then these two need to be
// wrapped in a View.
children = ( // eslint-disable-line no-extra-parens
children = (
<View style = { style }>
{ children }
<Text style = { styles && styles.labelStyle }>

View File

@@ -35,7 +35,6 @@ export default class ToolboxItem extends AbstractToolboxItem<Props> {
};
const elementType = showLabel ? 'li' : 'div';
const useTooltip = this.tooltip && this.tooltip.length > 0;
// eslint-disable-next-line no-extra-parens
let children = (
<Fragment>
{ this._renderIcon() }
@@ -47,7 +46,6 @@ export default class ToolboxItem extends AbstractToolboxItem<Props> {
);
if (useTooltip) {
// eslint-disable-next-line no-extra-parens
children = (
<Tooltip
content = { this.tooltip }

View File

@@ -300,7 +300,15 @@ export function parseStandardURIString(str: string) {
* references a Jitsi Meet resource (location).
* @public
* @returns {{
* room: (string|undefined)
* contextRoot: string,
* hash: string,
* host: string,
* hostname: string,
* pathname: string,
* port: string,
* protocol: string,
* room: (string|undefined),
* search: string
* }}
*/
export function parseURIString(uri: ?string) {

View File

@@ -1,5 +1,15 @@
// @flow
/**
* Resets the state of calendar integration so stored events and selected
* calendar type are cleared.
*
* {
* type: CLEAR_CALENDAR_INTEGRATION
* }
*/
export const CLEAR_CALENDAR_INTEGRATION = Symbol('CLEAR_CALENDAR_INTEGRATION');
/**
* Action to refresh (re-fetch) the entry list.
*
@@ -32,3 +42,48 @@ export const SET_CALENDAR_AUTHORIZATION = Symbol('SET_CALENDAR_AUTHORIZATION');
* }
*/
export const SET_CALENDAR_EVENTS = Symbol('SET_CALENDAR_EVENTS');
/**
* Action to update calendar type to be used for web.
*
* {
* type: SET_CALENDAR_INTEGRATION,
* integrationReady: boolean,
* integrationType: string
* }
*/
export const SET_CALENDAR_INTEGRATION = Symbol('SET_CALENDAR_INTEGRATION');
/**
* The type of Redux action which changes Calendar API auth state.
*
* {
* type: SET_CALENDAR_AUTH_STATE
* }
* @public
*/
export const SET_CALENDAR_AUTH_STATE = Symbol('SET_CALENDAR_AUTH_STATE');
/**
* The type of Redux action which changes Calendar Profile email state.
*
* {
* type: SET_CALENDAR_PROFILE_EMAIL,
* email: string
* }
* @public
*/
export const SET_CALENDAR_PROFILE_EMAIL = Symbol('SET_CALENDAR_PROFILE_EMAIL');
/**
* The type of Redux action which denotes whether a request is in flight to get
* updated calendar events.
*
* {
* type: SET_LOADING_CALENDAR_EVENTS,
* isLoadingEvents: string
* }
* @public
*/
export const SET_LOADING_CALENDAR_EVENTS
= Symbol('SET_LOADING_CALENDAR_EVENTS');

View File

@@ -1,10 +1,89 @@
// @flow
import { loadGoogleAPI } from '../google-api';
import {
CLEAR_CALENDAR_INTEGRATION,
REFRESH_CALENDAR,
SET_CALENDAR_AUTH_STATE,
SET_CALENDAR_AUTHORIZATION,
SET_CALENDAR_EVENTS
SET_CALENDAR_EVENTS,
SET_CALENDAR_INTEGRATION,
SET_CALENDAR_PROFILE_EMAIL,
SET_LOADING_CALENDAR_EVENTS
} from './actionTypes';
import { _getCalendarIntegration, isCalendarEnabled } from './functions';
import { generateRoomWithoutSeparator } from '../welcome';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Sets the initial state of calendar integration by loading third party APIs
* and filling out any data that needs to be fetched.
*
* @returns {Function}
*/
export function bootstrapCalendarIntegration(): Function {
return (dispatch, getState) => {
const {
googleApiApplicationClientID
} = getState()['features/base/config'];
const {
integrationReady,
integrationType
} = getState()['features/calendar-sync'];
if (!isCalendarEnabled()) {
return Promise.reject();
}
return Promise.resolve()
.then(() => {
if (googleApiApplicationClientID) {
return dispatch(
loadGoogleAPI(googleApiApplicationClientID));
}
})
.then(() => {
if (!integrationType || integrationReady) {
return;
}
const integrationToLoad
= _getCalendarIntegration(integrationType);
if (!integrationToLoad) {
dispatch(clearCalendarIntegration());
return;
}
return dispatch(integrationToLoad._isSignedIn())
.then(signedIn => {
if (signedIn) {
dispatch(setIntegrationReady(integrationType));
dispatch(updateProfile(integrationType));
} else {
dispatch(clearCalendarIntegration());
}
});
});
};
}
/**
* Resets the state of calendar integration so stored events and selected
* calendar type are cleared.
*
* @returns {{
* type: CLEAR_CALENDAR_INTEGRATION
* }}
*/
export function clearCalendarIntegration() {
return {
type: CLEAR_CALENDAR_INTEGRATION
};
}
/**
* Sends an action to refresh the entry list (fetches new data).
@@ -28,6 +107,23 @@ export function refreshCalendar(
};
}
/**
* Sends an action to update the current calendar api auth state in redux.
* This is used only for microsoft implementation to store it auth state.
*
* @param {number} newState - The new state.
* @returns {{
* type: SET_CALENDAR_AUTH_STATE,
* msAuthState: Object
* }}
*/
export function setCalendarAPIAuthState(newState: ?Object) {
return {
type: SET_CALENDAR_AUTH_STATE,
msAuthState: newState
};
}
/**
* Sends an action to signal that a calendar access has been requested. For more
* info, see {@link SET_CALENDAR_AUTHORIZATION}.
@@ -61,3 +157,155 @@ export function setCalendarEvents(events: Array<Object>) {
events
};
}
/**
* Sends an action to update the current calendar profile email state in redux.
*
* @param {number} newEmail - The new email.
* @returns {{
* type: SET_CALENDAR_PROFILE_EMAIL,
* email: string
* }}
*/
export function setCalendarProfileEmail(newEmail: ?string) {
return {
type: SET_CALENDAR_PROFILE_EMAIL,
email: newEmail
};
}
/**
* Sends an to denote a request in is flight to get calendar events.
*
* @param {boolean} isLoadingEvents - Whether or not calendar events are being
* fetched.
* @returns {{
* type: SET_LOADING_CALENDAR_EVENTS,
* isLoadingEvents: boolean
* }}
*/
export function setLoadingCalendarEvents(isLoadingEvents: boolean) {
return {
type: SET_LOADING_CALENDAR_EVENTS,
isLoadingEvents
};
}
/**
* Sets the calendar integration type to be used by web and signals that the
* integration is ready to be used.
*
* @param {string|undefined} integrationType - The calendar type.
* @returns {{
* type: SET_CALENDAR_INTEGRATION,
* integrationReady: boolean,
* integrationType: string
* }}
*/
export function setIntegrationReady(integrationType: string) {
return {
type: SET_CALENDAR_INTEGRATION,
integrationReady: true,
integrationType
};
}
/**
* Signals signing in to the specified calendar integration.
*
* @param {string} calendarType - The calendar integration which should be
* signed into.
* @returns {Function}
*/
export function signIn(calendarType: string): Function {
return (dispatch: Dispatch<*>) => {
const integration = _getCalendarIntegration(calendarType);
if (!integration) {
return Promise.reject('No supported integration found');
}
return dispatch(integration.load())
.then(() => dispatch(integration.signIn()))
.then(() => dispatch(setIntegrationReady(calendarType)))
.then(() => dispatch(updateProfile(calendarType)))
.then(() => dispatch(refreshCalendar()))
.catch(error => {
logger.error(
'Error occurred while signing into calendar integration',
error);
return Promise.reject(error);
});
};
}
/**
* Signals to get current profile data linked to the current calendar
* integration that is in use.
*
* @param {string} calendarType - The calendar integration to which the profile
* should be updated.
* @returns {Function}
*/
export function updateProfile(calendarType: string): Function {
return (dispatch: Dispatch<*>) => {
const integration = _getCalendarIntegration(calendarType);
if (!integration) {
return Promise.reject('No integration found');
}
return dispatch(integration.getCurrentEmail())
.then(email => {
dispatch(setCalendarProfileEmail(email));
});
};
}
/**
* Updates calendar event by generating new invite URL and editing the event
* adding some descriptive text and location.
*
* @param {string} id - The event id.
* @param {string} calendarId - The id of the calendar to use.
* @returns {Function}
*/
export function updateCalendarEvent(id: string, calendarId: string): Function {
return (dispatch: Dispatch<*>, getState: Function) => {
const { integrationType } = getState()['features/calendar-sync'];
const integration = _getCalendarIntegration(integrationType);
if (!integration) {
return Promise.reject('No integration found');
}
const { locationURL } = getState()['features/base/connection'];
const newRoomName = generateRoomWithoutSeparator();
let href = locationURL.href;
href.endsWith('/') || (href += '/');
const roomURL = `${href}${newRoomName}`;
return dispatch(integration.updateCalendarEvent(
id, calendarId, roomURL))
.then(() => {
// make a copy of the array
const events
= getState()['features/calendar-sync'].events.slice(0);
const eventIx = events.findIndex(
e => e.id === id && e.calendarId === calendarId);
// clone the event we will modify
const newEvent = Object.assign({}, events[eventIx]);
newEvent.url = roomURL;
events[eventIx] = newEvent;
return dispatch(setCalendarEvents(events));
});
};
}

View File

@@ -0,0 +1,23 @@
// @flow
import { Component } from 'react';
/**
* A React Component for adding a meeting URL to an existing calendar meeting.
*
* @extends Component
*/
class AddMeetingUrlButton extends Component<*> {
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
render() {
// Not yet implemented.
return null;
}
}
export default AddMeetingUrlButton;

View File

@@ -0,0 +1,89 @@
// @flow
import Button from '@atlaskit/button';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import Tooltip from '@atlaskit/tooltip';
import { translate } from '../../base/i18n';
import { updateCalendarEvent } from '../actions';
/**
* The type of the React {@code Component} props of {@link AddMeetingUrlButton}.
*/
type Props = {
/**
* The calendar ID associated with the calendar event.
*/
calendarId: string,
/**
* Invoked to add a meeting URL to a calendar event.
*/
dispatch: Dispatch<*>,
/**
* The ID of the calendar event that will have a meeting URL added on click.
*/
eventId: string,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* A React Component for adding a meeting URL to an existing calendar event.
*
* @extends Component
*/
class AddMeetingUrlButton extends Component<Props> {
/**
* Initializes a new {@code AddMeetingUrlButton} instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
render() {
return (
<Tooltip content = { this.props.t('calendarSync.addMeetingURL') }>
<Button
appearance = 'primary'
onClick = { this._onClick }
type = 'button'>
<i className = { 'icon-add' } />
</Button>
</Tooltip>
);
}
_onClick: () => void;
/**
* Dispatches an action to adding a meeting URL to a calendar event.
*
* @returns {void}
*/
_onClick() {
const { calendarId, dispatch, eventId } = this.props;
dispatch(updateCalendarEvent(eventId, calendarId));
}
}
export default translate(connect()(AddMeetingUrlButton));

View File

@@ -1,28 +1,24 @@
// @flow
import React, { Component } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { connect } from 'react-redux';
import { appNavigate } from '../../app';
import { getLocalizedDateFormatter, translate } from '../../base/i18n';
import { NavigateSectionList } from '../../base/react';
import { openSettings } from '../../mobile/permissions';
import { refreshCalendar } from '../actions';
import { CALENDAR_ENABLED } from '../constants';
import styles from './styles';
import { isCalendarEnabled } from '../functions';
import AddMeetingUrlButton from './AddMeetingUrlButton';
/**
* The tyoe of the React {@code Component} props of {@link MeetingList}.
* The type of the React {@code Component} props of
* {@link BaseCalendarList}.
*/
type Props = {
/**
* The current state of the calendar access permission.
*/
_authorization: ?string,
/**
* The calendar event list.
*/
@@ -38,6 +34,11 @@ type Props = {
*/
dispatch: Function,
/**
*
*/
renderListEmptyComponent: Function,
/**
* The translate function.
*/
@@ -45,9 +46,9 @@ type Props = {
};
/**
* Component to display a list of events from the (mobile) user's calendar.
* Component to display a list of events from a connected calendar.
*/
class MeetingList extends Component<Props> {
class BaseCalendarList extends Component<Props> {
/**
* Default values for the component's props.
*/
@@ -75,7 +76,7 @@ class MeetingList extends Component<Props> {
}
/**
* Initializes a new {@code MeetingList} instance.
* Initializes a new {@code BaseCalendarList} instance.
*
* @inheritdoc
*/
@@ -83,13 +84,12 @@ class MeetingList extends Component<Props> {
super(props);
// Bind event handlers so they are only bound once per instance.
this._getRenderListEmptyComponent
= this._getRenderListEmptyComponent.bind(this);
this._onPress = this._onPress.bind(this);
this._onRefresh = this._onRefresh.bind(this);
this._toDateString = this._toDateString.bind(this);
this._toDisplayableItem = this._toDisplayableItem.bind(this);
this._toDisplayableList = this._toDisplayableList.bind(this);
this._toTimeString = this._toTimeString.bind(this);
}
/**
@@ -98,7 +98,7 @@ class MeetingList extends Component<Props> {
* @inheritdoc
*/
render() {
const { disabled } = this.props;
const { disabled, renderListEmptyComponent } = this.props;
return (
<NavigateSectionList
@@ -106,46 +106,11 @@ class MeetingList extends Component<Props> {
onPress = { this._onPress }
onRefresh = { this._onRefresh }
renderListEmptyComponent
= { this._getRenderListEmptyComponent() }
= { renderListEmptyComponent }
sections = { this._toDisplayableList() } />
);
}
_getRenderListEmptyComponent: () => Object;
/**
* Returns a list empty component if a custom one has to be rendered instead
* of the default one in the {@link NavigateSectionList}.
*
* @private
* @returns {?React$Component}
*/
_getRenderListEmptyComponent() {
const { _authorization, t } = this.props;
// If we don't provide a list specific renderListEmptyComponent, then
// the default empty component of the NavigateSectionList will be
// rendered, which (atm) is a simple "Pull to refresh" message.
if (_authorization !== 'denied') {
return undefined;
}
return (
<View style = { styles.noPermissionMessageView }>
<Text style = { styles.noPermissionMessageText }>
{ t('calendarSync.permissionMessage') }
</Text>
<TouchableOpacity
onPress = { openSettings }
style = { styles.noPermissionMessageButton } >
<Text style = { styles.noPermissionMessageButtonText }>
{ t('calendarSync.permissionButton') }
</Text>
</TouchableOpacity>
</View>
);
}
_onPress: string => Function;
/**
@@ -174,7 +139,7 @@ class MeetingList extends Component<Props> {
_toDateString: Object => string;
/**
* Generates a date (interval) string for a given event.
* Generates a date string for a given event.
*
* @param {Object} event - The event.
* @private
@@ -182,11 +147,9 @@ class MeetingList extends Component<Props> {
*/
_toDateString(event) {
const startDateTime
= getLocalizedDateFormatter(event.startDate).format('lll');
const endTime
= getLocalizedDateFormatter(event.endDate).format('LT');
= getLocalizedDateFormatter(event.startDate).format('MMM Do, YYYY');
return `${startDateTime} - ${endTime}`;
return `${startDateTime}`;
}
_toDisplayableItem: Object => Object;
@@ -200,10 +163,15 @@ class MeetingList extends Component<Props> {
*/
_toDisplayableItem(event) {
return {
elementAfter: event.url ? undefined : (
<AddMeetingUrlButton
calendarId = { event.calendarId }
eventId = { event.id } />
),
key: `${event.id}-${event.startDate}`,
lines: [
event.url,
this._toDateString(event)
this._toTimeString(event)
],
title: event.title,
url: event.url
@@ -221,39 +189,60 @@ class MeetingList extends Component<Props> {
_toDisplayableList() {
const { _eventList, t } = this.props;
const now = Date.now();
const now = new Date();
const { createSection } = NavigateSectionList;
const nowSection = createSection(t('calendarSync.now'), 'now');
const nextSection = createSection(t('calendarSync.next'), 'next');
const laterSection = createSection(t('calendarSync.later'), 'later');
const TODAY_SECTION = 'today';
const sectionMap = new Map();
for (const event of _eventList) {
const displayableEvent = this._toDisplayableItem(event);
const startDate = new Date(event.startDate).getDate();
if (event.startDate < now && event.endDate > now) {
nowSection.data.push(displayableEvent);
} else if (event.startDate > now) {
if (nextSection.data.length
&& nextSection.data[0].startDate !== event.startDate) {
laterSection.data.push(displayableEvent);
} else {
nextSection.data.push(displayableEvent);
if (startDate === now.getDate()) {
let todaySection = sectionMap.get(TODAY_SECTION);
if (!todaySection) {
todaySection
= createSection(t('calendarSync.today'), TODAY_SECTION);
sectionMap.set(TODAY_SECTION, todaySection);
}
todaySection.data.push(displayableEvent);
} else if (sectionMap.has(startDate)) {
const section = sectionMap.get(startDate);
if (section) {
section.data.push(displayableEvent);
}
} else {
const newSection
= createSection(this._toDateString(event), startDate);
sectionMap.set(startDate, newSection);
newSection.data.push(displayableEvent);
}
}
const sectionList = [];
return Array.from(sectionMap.values());
}
for (const section of [
nowSection,
nextSection,
laterSection
]) {
section.data.length && sectionList.push(section);
}
_toTimeString: Object => string;
return sectionList;
/**
* Generates a time (interval) string for a given event.
*
* @param {Object} event - The event.
* @private
* @returns {string}
*/
_toTimeString(event) {
const startDateTime
= getLocalizedDateFormatter(event.startDate).format('lll');
const endTime
= getLocalizedDateFormatter(event.endDate).format('LT');
return `${startDateTime} - ${endTime}`;
}
}
@@ -262,19 +251,15 @@ class MeetingList extends Component<Props> {
*
* @param {Object} state - The redux state.
* @returns {{
* _authorization: ?string,
* _eventList: Array<Object>
* }}
*/
function _mapStateToProps(state: Object) {
const { authorization, events } = state['features/calendar-sync'];
return {
_authorization: authorization,
_eventList: events
_eventList: state['features/calendar-sync'].events
};
}
export default CALENDAR_ENABLED
? translate(connect(_mapStateToProps)(MeetingList))
export default isCalendarEnabled()
? translate(connect(_mapStateToProps)(BaseCalendarList))
: undefined;

View File

@@ -0,0 +1,126 @@
// @flow
import React, { Component } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { connect } from 'react-redux';
import { openSettings } from '../../mobile/permissions';
import { translate } from '../../base/i18n';
import { isCalendarEnabled } from '../functions';
import styles from './styles';
import BaseCalendarList from './BaseCalendarList';
/**
* The tyoe of the React {@code Component} props of {@link CalendarList}.
*/
type Props = {
/**
* The current state of the calendar access permission.
*/
_authorization: ?string,
/**
* Indicates if the list is disabled or not.
*/
disabled: boolean,
/**
* The translate function.
*/
t: Function
};
/**
* Component to display a list of events from the (mobile) user's calendar.
*/
class CalendarList extends Component<Props> {
/**
* Initializes a new {@code CalendarList} instance.
*
* @inheritdoc
*/
constructor(props) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._getRenderListEmptyComponent
= this._getRenderListEmptyComponent.bind(this);
}
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
render() {
const { disabled } = this.props;
return (
BaseCalendarList
? <BaseCalendarList
disabled = { disabled }
renderListEmptyComponent
= { this._getRenderListEmptyComponent() } />
: null
);
}
_getRenderListEmptyComponent: () => Object;
/**
* Returns a list empty component if a custom one has to be rendered instead
* of the default one in the {@link NavigateSectionList}.
*
* @private
* @returns {?React$Component}
*/
_getRenderListEmptyComponent() {
const { _authorization, t } = this.props;
// If we don't provide a list specific renderListEmptyComponent, then
// the default empty component of the NavigateSectionList will be
// rendered, which (atm) is a simple "Pull to refresh" message.
if (_authorization !== 'denied') {
return undefined;
}
return (
<View style = { styles.noPermissionMessageView }>
<Text style = { styles.noPermissionMessageText }>
{ t('calendarSync.permissionMessage') }
</Text>
<TouchableOpacity
onPress = { openSettings }
style = { styles.noPermissionMessageButton } >
<Text style = { styles.noPermissionMessageButtonText }>
{ t('calendarSync.permissionButton') }
</Text>
</TouchableOpacity>
</View>
);
}
}
/**
* Maps redux state to component props.
*
* @param {Object} state - The redux state.
* @returns {{
* _authorization: ?string,
* _eventList: Array<Object>
* }}
*/
function _mapStateToProps(state: Object) {
const { authorization } = state['features/calendar-sync'];
return {
_authorization: authorization
};
}
export default isCalendarEnabled()
? translate(connect(_mapStateToProps)(CalendarList))
: undefined;

View File

@@ -0,0 +1,194 @@
// @flow
import Button from '@atlaskit/button';
import Spinner from '@atlaskit/spinner';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { translate } from '../../base/i18n';
import { openSettingsDialog, SETTINGS_TABS } from '../../settings';
import { refreshCalendar } from '../actions';
import { isCalendarEnabled } from '../functions';
import BaseCalendarList from './BaseCalendarList';
declare var interfaceConfig: Object;
/**
* The type of the React {@code Component} props of {@link CalendarList}.
*/
type Props = {
/**
* Whether or not a calendar may be connected for fetching calendar events.
*/
_hasIntegrationSelected: boolean,
/**
* Whether or not events have been fetched from a calendar.
*/
_hasLoadedEvents: boolean,
/**
* Indicates if the list is disabled or not.
*/
disabled: boolean,
/**
* The Redux dispatch function.
*/
dispatch: Function,
/**
* The translate function.
*/
t: Function
};
/**
* Component to display a list of events from the user's calendar.
*/
class CalendarList extends Component<Props> {
/**
* Initializes a new {@code CalendarList} instance.
*
* @inheritdoc
*/
constructor(props) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._getRenderListEmptyComponent
= this._getRenderListEmptyComponent.bind(this);
this._onOpenSettings = this._onOpenSettings.bind(this);
this._onRefreshEvents = this._onRefreshEvents.bind(this);
}
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
render() {
const { disabled } = this.props;
return (
BaseCalendarList
? <BaseCalendarList
disabled = { disabled }
renderListEmptyComponent
= { this._getRenderListEmptyComponent() } />
: null
);
}
_getRenderListEmptyComponent: () => Object;
/**
* Returns a list empty component if a custom one has to be rendered instead
* of the default one in the {@link NavigateSectionList}.
*
* @private
* @returns {React$Component}
*/
_getRenderListEmptyComponent() {
const { _hasIntegrationSelected, _hasLoadedEvents, t } = this.props;
if (_hasIntegrationSelected && _hasLoadedEvents) {
return (
<div className = 'navigate-section-list-empty'>
<div>{ t('calendarSync.noEvents') }</div>
<Button
appearance = 'primary'
className = 'calendar-button'
id = 'connect_calendar_button'
onClick = { this._onRefreshEvents }
type = 'button'>
{ t('calendarSync.refresh') }
</Button>
</div>
);
} else if (_hasIntegrationSelected && !_hasLoadedEvents) {
return (
<div className = 'navigate-section-list-empty'>
<Spinner
invertColor = { true }
isCompleting = { false }
size = 'medium' />
</div>
);
}
return (
<div className = 'navigate-section-list-empty'>
<p className = 'header-text-description'>
{ t('welcomepage.connectCalendarText', {
app: interfaceConfig.APP_NAME
}) }
</p>
<Button
appearance = 'primary'
className = 'calendar-button'
id = 'connect_calendar_button'
onClick = { this._onOpenSettings }
type = 'button'>
{ t('welcomepage.connectCalendarButton') }
</Button>
</div>
);
}
_onOpenSettings: () => void;
/**
* Opens {@code SettingsDialog}.
*
* @private
* @returns {void}
*/
_onOpenSettings() {
this.props.dispatch(openSettingsDialog(SETTINGS_TABS.CALENDAR));
}
_onRefreshEvents: () => void;
/**
* Gets an updated list of calendar events.
*
* @private
* @returns {void}
*/
_onRefreshEvents() {
this.props.dispatch(refreshCalendar(true));
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code CalendarList} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _hasIntegrationSelected: boolean,
* _hasLoadedEvents: boolean
* }}
*/
function _mapStateToProps(state) {
const {
events,
integrationType,
isLoadingEvents
} = state['features/calendar-sync'];
return {
_hasIntegrationSelected: Boolean(integrationType),
_hasLoadedEvents: Boolean(events) || !isLoadingEvents
};
}
export default isCalendarEnabled()
? translate(connect(_mapStateToProps)(CalendarList))
: undefined;

View File

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

View File

@@ -0,0 +1,44 @@
// @flow
import React, { Component } from 'react';
/**
* The type of the React {@code Component} props of
* {@link MicrosoftSignInButton}.
*/
type Props = {
// The callback to invoke when {@code MicrosoftSignInButton} is clicked.
onClick: Function,
// The text to display within {@code MicrosoftSignInButton}.
text: string
};
/**
* A React Component showing a button to sign in with Microsoft.
*
* @extends Component
*/
export default class MicrosoftSignInButton extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<div
className = 'microsoft-sign-in'
onClick = { this.props.onClick }>
<img
className = 'microsoft-logo'
src = 'images/microsoftLogo.svg' />
<div className = 'microsoft-cta'>
{ this.props.text }
</div>
</div>
);
}
}

View File

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

View File

@@ -4,7 +4,7 @@ const NOTIFICATION_SIZE = 55;
/**
* The styles of the React {@code Component}s of the feature meeting-list i.e.
* {@code MeetingList}.
* {@code CalendarList}.
*/
export default createStyleSheet({

View File

@@ -1,37 +1,26 @@
// @flow
import { NativeModules } from 'react-native';
/**
* The indicator which determines whether the calendar feature is enabled by the
* app.
* An enumeration of support calendar integration types.
*
* @type {boolean}
* @enum {string}
*/
export const CALENDAR_ENABLED = _isCalendarEnabled();
/**
* The default state of the calendar.
*
* NOTE: This is defined here, to be reusable by functions.js as well (see file
* for details).
*/
export const DEFAULT_STATE = {
authorization: undefined,
events: []
export const CALENDAR_TYPE = {
GOOGLE: 'google',
MICROSOFT: 'microsoft'
};
/**
* Determines whether the calendar feature is enabled by the app. For
* example, Apple through its App Store requires
* {@code NSCalendarsUsageDescription} in the app's Info.plist or App Store
* rejects the app.
*
* @returns {boolean} If the app has enabled the calendar feature, {@code true};
* otherwise, {@code false}.
* The number of days to fetch.
*/
function _isCalendarEnabled() {
const { calendarEnabled } = NativeModules.AppInfo;
export const FETCH_END_DAYS = 10;
return typeof calendarEnabled === 'undefined' ? true : calendarEnabled;
}
/**
* The number of days to go back when fetching.
*/
export const FETCH_START_DAYS = -1;
/**
* The max number of events to fetch from the calendar.
*/
export const MAX_LIST_LENGTH = 10;

View File

@@ -0,0 +1,171 @@
// @flow
import md5 from 'js-md5';
import { setCalendarEvents } from './actions';
import { APP_LINK_SCHEME, parseURIString } from '../base/util';
import { MAX_LIST_LENGTH } from './constants';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Updates the calendar entries in redux when new list is received. The feature
* calendar-sync doesn't display all calendar events, it displays unique
* title, URL, and start time tuples i.e. it doesn't display subsequent
* occurrences of recurring events, and the repetitions of events coming from
* multiple calendars.
*
* XXX The function's {@code this} is the redux store.
*
* @param {Array<CalendarEntry>} events - The new event list.
* @private
* @returns {void}
*/
export function _updateCalendarEntries(events: Array<Object>) {
if (!events || !events.length) {
return;
}
// eslint-disable-next-line no-invalid-this
const { dispatch, getState } = this;
const knownDomains = getState()['features/base/known-domains'];
const now = Date.now();
const entryMap = new Map();
for (const event of events) {
const entry = _parseCalendarEntry(event, knownDomains);
if (entry && entry.endDate > now) {
// As was stated above, we don't display subsequent occurrences of
// recurring events, and the repetitions of events coming from
// multiple calendars.
const key = md5.hex(JSON.stringify([
// Obviously, we want to display different conference/meetings
// URLs. URLs are the very reason why we implemented the feature
// calendar-sync in the first place.
entry.url,
// We probably want to display one and the same URL to people if
// they have it under different titles in their Calendar.
// Because maybe they remember the title of the meeting, not the
// URL so they expect to see the title without realizing that
// they have the same URL already under a different title.
entry.title,
// XXX Eventually, given that the URL and the title are the
// same, what sets one event apart from another is the start
// time of the day (note the use of toTimeString() bellow)! The
// day itself is not important because we don't want multiple
// occurrences of a recurring event or repetitions of an even
// from multiple calendars.
new Date(entry.startDate).toTimeString()
]));
const existingEntry = entryMap.get(key);
// We want only the earliest occurrence (which hasn't ended in the
// past, that is) of a recurring event.
if (!existingEntry || existingEntry.startDate > entry.startDate) {
entryMap.set(key, entry);
}
}
}
dispatch(
setCalendarEvents(
Array.from(entryMap.values())
.sort((a, b) => a.startDate - b.startDate)
.slice(0, MAX_LIST_LENGTH)));
}
/**
* Updates the calendar entries in Redux when new list is received.
*
* @param {Object} event - An event returned from the native calendar.
* @param {Array<string>} knownDomains - The known domain list.
* @private
* @returns {CalendarEntry}
*/
function _parseCalendarEntry(event, knownDomains) {
if (event) {
const url = _getURLFromEvent(event, knownDomains);
// we only filter events without url on mobile, this is temporary
// till we implement event edit on mobile
if (url || navigator.product !== 'ReactNative') {
const startDate = Date.parse(event.startDate);
const endDate = Date.parse(event.endDate);
// we want to hide all events that
// - has no start or end date
// - for web, if there is no url and we cannot edit the event (has
// no calendarId)
if (isNaN(startDate)
|| isNaN(endDate)
|| (navigator.product !== 'ReactNative'
&& !url
&& !event.calendarId)) {
logger.debug(
'Skipping invalid calendar event',
event.title,
event.startDate,
event.endDate,
url,
event.calendarId
);
} else {
return {
calendarId: event.calendarId,
endDate,
id: event.id,
startDate,
title: event.title,
url
};
}
}
}
return null;
}
/**
* Retrieves a Jitsi Meet URL from an event if present.
*
* @param {Object} event - The event to parse.
* @param {Array<string>} knownDomains - The known domain names.
* @private
* @returns {string}
*/
function _getURLFromEvent(event, knownDomains) {
const linkTerminatorPattern = '[^\\s<>$]';
const urlRegExp
= new RegExp(
`http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`,
'gi');
const schemeRegExp
= new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi');
const fieldsToSearch = [
event.title,
event.url,
event.location,
event.notes,
event.description
];
for (const field of fieldsToSearch) {
if (typeof field === 'string') {
const matches = urlRegExp.exec(field) || schemeRegExp.exec(field);
if (matches) {
const url = parseURIString(matches[0]);
if (url) {
return url.toString();
}
}
}
}
return null;
}

View File

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

View File

@@ -0,0 +1,99 @@
import { NativeModules } from 'react-native';
import RNCalendarEvents from 'react-native-calendar-events';
import { setCalendarAuthorization } from './actions';
import { FETCH_END_DAYS, FETCH_START_DAYS } from './constants';
import { _updateCalendarEntries } from './functions';
export * from './functions.any';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Determines whether the calendar feature is enabled by the app. For
* example, Apple through its App Store requires
* {@code NSCalendarsUsageDescription} in the app's Info.plist or App Store
* rejects the app.
*
* @returns {boolean} If the app has enabled the calendar feature, {@code true};
* otherwise, {@code false}.
*/
export function isCalendarEnabled() {
const { calendarEnabled } = NativeModules.AppInfo;
return typeof calendarEnabled === 'undefined' ? true : calendarEnabled;
}
/**
* Reads the user's calendar and updates the stored entries if need be.
*
* @param {Object} store - The redux store.
* @param {boolean} maybePromptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {boolean|undefined} forcePermission - Whether to force to re-ask for
* the permission or not.
* @private
* @returns {void}
*/
export function _fetchCalendarEntries(
store,
maybePromptForPermission,
forcePermission) {
const { dispatch, getState } = store;
const promptForPermission
= (maybePromptForPermission
&& !getState()['features/calendar-sync'].authorization)
|| forcePermission;
_ensureCalendarAccess(promptForPermission, dispatch)
.then(accessGranted => {
if (accessGranted) {
const startDate = new Date();
const endDate = new Date();
startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
RNCalendarEvents.fetchAllEvents(
startDate.getTime(),
endDate.getTime(),
[])
.then(_updateCalendarEntries.bind(store))
.catch(error =>
logger.error('Error fetching calendar.', error));
} else {
logger.warn('Calendar access not granted.');
}
})
.catch(reason => logger.error('Error accessing calendar.', reason));
}
/**
* Ensures calendar access if possible and resolves the promise if it's granted.
*
* @param {boolean} promptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {Function} dispatch - The Redux dispatch function.
* @private
* @returns {Promise}
*/
function _ensureCalendarAccess(promptForPermission, dispatch) {
return new Promise((resolve, reject) => {
RNCalendarEvents.authorizationStatus()
.then(status => {
if (status === 'authorized') {
resolve(true);
} else if (promptForPermission) {
RNCalendarEvents.authorizeEventStore()
.then(result => {
dispatch(setCalendarAuthorization(result));
resolve(result === 'authorized');
})
.catch(reject);
} else {
resolve(false);
}
})
.catch(reject);
});
}

View File

@@ -0,0 +1,97 @@
// @flow
import { setLoadingCalendarEvents } from './actions';
export * from './functions.any';
import {
CALENDAR_TYPE,
FETCH_END_DAYS,
FETCH_START_DAYS
} from './constants';
import { _updateCalendarEntries } from './functions';
import { googleCalendarApi } from './web/googleCalendar';
import { microsoftCalendarApi } from './web/microsoftCalendar';
const logger = require('jitsi-meet-logger').getLogger(__filename);
declare var config: Object;
/**
* Determines whether the calendar feature is enabled by the web.
*
* @returns {boolean} If the app has enabled the calendar feature, {@code true};
* otherwise, {@code false}.
*/
export function isCalendarEnabled() {
return Boolean(
config.enableCalendarIntegration
&& (config.googleApiApplicationClientID
|| config.microsoftApiApplicationClientID));
}
/* eslint-disable no-unused-vars */
/**
* Reads the user's calendar and updates the stored entries if need be.
*
* @param {Object} store - The redux store.
* @param {boolean} maybePromptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {boolean|undefined} forcePermission - Whether to force to re-ask for
* the permission or not.
* @private
* @returns {void}
*/
export function _fetchCalendarEntries(
store,
maybePromptForPermission,
forcePermission) {
/* eslint-enable no-unused-vars */
const { dispatch, getState } = store;
const { integrationType } = getState()['features/calendar-sync'];
const integration = _getCalendarIntegration(integrationType);
if (!integration) {
logger.debug('No calendar type available');
return;
}
dispatch(setLoadingCalendarEvents(true));
dispatch(integration.load())
.then(() => dispatch(integration._isSignedIn()))
.then(signedIn => {
if (signedIn) {
return Promise.resolve();
}
return Promise.reject('Not authorized, please sign in!');
})
.then(() => dispatch(integration.getCalendarEntries(
FETCH_START_DAYS, FETCH_END_DAYS)))
.then(events => _updateCalendarEntries.call({
dispatch,
getState
}, events))
.catch(error =>
logger.error('Error fetching calendar.', error))
.then(() => dispatch(setLoadingCalendarEvents(false)));
}
/**
* Returns the calendar API implementation by specified type.
*
* @param {string} calendarType - The calendar type API as defined in
* the constant {@link CALENDAR_TYPE}.
* @private
* @returns {Object|undefined}
*/
export function _getCalendarIntegration(calendarType: string) {
switch (calendarType) {
case CALENDAR_TYPE.GOOGLE:
return googleCalendarApi;
case CALENDAR_TYPE.MICROSOFT:
return microsoftCalendarApi;
}
}

View File

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

View File

@@ -1,36 +1,15 @@
// @flow
import md5 from 'js-md5';
import RNCalendarEvents from 'react-native-calendar-events';
import { APP_WILL_MOUNT } from '../base/app';
import { SET_CONFIG } from '../base/config';
import { ADD_KNOWN_DOMAINS, addKnownDomains } from '../base/known-domains';
import { MiddlewareRegistry } from '../base/redux';
import { APP_LINK_SCHEME, parseURIString } from '../base/util';
import { APP_STATE_CHANGED } from '../mobile/background';
import { equals, MiddlewareRegistry } from '../base/redux';
import { APP_STATE_CHANGED } from '../mobile/background/actionTypes';
import { setCalendarAuthorization, setCalendarEvents } from './actions';
import { setCalendarAuthorization } from './actions';
import { REFRESH_CALENDAR } from './actionTypes';
import { CALENDAR_ENABLED } from './constants';
import { _fetchCalendarEntries, isCalendarEnabled } from './functions';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* The number of days to fetch.
*/
const FETCH_END_DAYS = 10;
/**
* The number of days to go back when fetching.
*/
const FETCH_START_DAYS = -1;
/**
* The max number of events to fetch from the calendar.
*/
const MAX_LIST_LENGTH = 10;
CALENDAR_ENABLED
isCalendarEnabled()
&& MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case ADD_KNOWN_DOMAINS: {
@@ -41,7 +20,8 @@ CALENDAR_ENABLED
const result = next(action);
const newValue = getState()['features/base/known-domains'];
oldValue === newValue || _fetchCalendarEntries(store, false, false);
equals(oldValue, newValue)
|| _fetchCalendarEntries(store, false, false);
return result;
}
@@ -54,7 +34,9 @@ CALENDAR_ENABLED
return result;
}
case APP_WILL_MOUNT: {
case SET_CONFIG: {
const result = next(action);
// For legacy purposes, we've allowed the deserialization of
// knownDomains and now we're to translate it to base/known-domains.
const state = store.getState()['features/calendar-sync'];
@@ -69,7 +51,7 @@ CALENDAR_ENABLED
_fetchCalendarEntries(store, false, false);
return next(action);
return result;
}
case REFRESH_CALENDAR: {
@@ -85,121 +67,6 @@ CALENDAR_ENABLED
return next(action);
});
/**
* Ensures calendar access if possible and resolves the promise if it's granted.
*
* @param {boolean} promptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {Function} dispatch - The Redux dispatch function.
* @private
* @returns {Promise}
*/
function _ensureCalendarAccess(promptForPermission, dispatch) {
return new Promise((resolve, reject) => {
RNCalendarEvents.authorizationStatus()
.then(status => {
if (status === 'authorized') {
resolve(true);
} else if (promptForPermission) {
RNCalendarEvents.authorizeEventStore()
.then(result => {
dispatch(setCalendarAuthorization(result));
resolve(result === 'authorized');
})
.catch(reject);
} else {
resolve(false);
}
})
.catch(reject);
});
}
/**
* Reads the user's calendar and updates the stored entries if need be.
*
* @param {Object} store - The redux store.
* @param {boolean} maybePromptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {boolean|undefined} forcePermission - Whether to force to re-ask for
* the permission or not.
* @private
* @returns {void}
*/
function _fetchCalendarEntries(
store,
maybePromptForPermission,
forcePermission) {
const { dispatch, getState } = store;
const promptForPermission
= (maybePromptForPermission
&& !getState()['features/calendar-sync'].authorization)
|| forcePermission;
_ensureCalendarAccess(promptForPermission, dispatch)
.then(accessGranted => {
if (accessGranted) {
const startDate = new Date();
const endDate = new Date();
startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
RNCalendarEvents.fetchAllEvents(
startDate.getTime(),
endDate.getTime(),
[])
.then(_updateCalendarEntries.bind(store))
.catch(error =>
logger.error('Error fetching calendar.', error));
} else {
logger.warn('Calendar access not granted.');
}
})
.catch(reason => logger.error('Error accessing calendar.', reason));
}
/**
* Retrieves a Jitsi Meet URL from an event if present.
*
* @param {Object} event - The event to parse.
* @param {Array<string>} knownDomains - The known domain names.
* @private
* @returns {string}
*/
function _getURLFromEvent(event, knownDomains) {
const linkTerminatorPattern = '[^\\s<>$]';
const urlRegExp
= new RegExp(
`http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`,
'gi');
const schemeRegExp
= new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi');
const fieldsToSearch = [
event.title,
event.url,
event.location,
event.notes,
event.description
];
for (const field of fieldsToSearch) {
if (typeof field === 'string') {
const matches = urlRegExp.exec(field) || schemeRegExp.exec(field);
if (matches) {
const url = parseURIString(matches[0]);
if (url) {
return url.toString();
}
}
}
}
return null;
}
/**
* Clears the calendar access status when the app comes back from the
* background. This is needed as some users may never quit the app, but puts it
@@ -215,111 +82,3 @@ function _maybeClearAccessStatus(store, { appState }) {
appState === 'background'
&& store.dispatch(setCalendarAuthorization(undefined));
}
/**
* Updates the calendar entries in Redux when new list is received.
*
* @param {Object} event - An event returned from the native calendar.
* @param {Array<string>} knownDomains - The known domain list.
* @private
* @returns {CalendarEntry}
*/
function _parseCalendarEntry(event, knownDomains) {
if (event) {
const url = _getURLFromEvent(event, knownDomains);
if (url) {
const startDate = Date.parse(event.startDate);
const endDate = Date.parse(event.endDate);
if (isNaN(startDate) || isNaN(endDate)) {
logger.warn(
'Skipping invalid calendar event',
event.title,
event.startDate,
event.endDate
);
} else {
return {
endDate,
id: event.id,
startDate,
title: event.title,
url
};
}
}
}
return null;
}
/**
* Updates the calendar entries in redux when new list is received. The feature
* calendar-sync doesn't display all calendar events, it displays unique
* title, URL, and start time tuples i.e. it doesn't display subsequent
* occurrences of recurring events, and the repetitions of events coming from
* multiple calendars.
*
* XXX The function's {@code this} is the redux store.
*
* @param {Array<CalendarEntry>} events - The new event list.
* @private
* @returns {void}
*/
function _updateCalendarEntries(events) {
if (!events || !events.length) {
return;
}
// eslint-disable-next-line no-invalid-this
const { dispatch, getState } = this;
const knownDomains = getState()['features/base/known-domains'];
const now = Date.now();
const entryMap = new Map();
for (const event of events) {
const entry = _parseCalendarEntry(event, knownDomains);
if (entry && entry.endDate > now) {
// As was stated above, we don't display subsequent occurrences of
// recurring events, and the repetitions of events coming from
// multiple calendars.
const key = md5.hex(JSON.stringify([
// Obviously, we want to display different conference/meetings
// URLs. URLs are the very reason why we implemented the feature
// calendar-sync in the first place.
entry.url,
// We probably want to display one and the same URL to people if
// they have it under different titles in their Calendar.
// Because maybe they remember the title of the meeting, not the
// URL so they expect to see the title without realizing that
// they have the same URL already under a different title.
entry.title,
// XXX Eventually, given that the URL and the title are the
// same, what sets one event apart from another is the start
// time of the day (note the use of toTimeString() bellow)! The
// day itself is not important because we don't want multiple
// occurrences of a recurring event or repetitions of an even
// from multiple calendars.
new Date(entry.startDate).toTimeString()
]));
const existingEntry = entryMap.get(key);
// We want only the earliest occurrence (which hasn't ended in the
// past, that is) of a recurring event.
if (!existingEntry || existingEntry.startDate > entry.startDate) {
entryMap.set(key, entry);
}
}
}
dispatch(
setCalendarEvents(
Array.from(entryMap.values())
.sort((a, b) => a.startDate - b.startDate)
.slice(0, MAX_LIST_LENGTH)));
}

View File

@@ -5,21 +5,37 @@ import { ReducerRegistry, set } from '../base/redux';
import { PersistenceRegistry } from '../base/storage';
import {
CLEAR_CALENDAR_INTEGRATION,
SET_CALENDAR_AUTH_STATE,
SET_CALENDAR_AUTHORIZATION,
SET_CALENDAR_EVENTS
SET_CALENDAR_EVENTS,
SET_CALENDAR_INTEGRATION,
SET_CALENDAR_PROFILE_EMAIL,
SET_LOADING_CALENDAR_EVENTS
} from './actionTypes';
import { CALENDAR_ENABLED, DEFAULT_STATE } from './constants';
import { isCalendarEnabled } from './functions';
/**
* The default state of the calendar feature.
*
* @type {Object}
*/
const DEFAULT_STATE = {
authorization: undefined,
events: [],
integrationReady: false,
integrationType: undefined,
msAuthState: undefined
};
/**
* Constant for the Redux subtree of the calendar feature.
*
* NOTE: Please do not access this subtree directly outside of this feature.
* This feature can be disabled (see {@code constants.js} for details), and in
* that case, accessing this subtree directly will return undefined and will
* need a bunch of repetitive type checks in other features. Use the
* {@code getCalendarState} function instead, or make sure you take care of
* those checks, or consider using the {@code CALENDAR_ENABLED} const to gate
* features if needed.
* NOTE: This feature can be disabled and in that case, accessing this subtree
* directly will return undefined and will need a bunch of repetitive type
* checks in other features. Make sure you take care of those checks, or
* consider using the {@code isCalendarEnabled} value to gate features if
* needed.
*/
const STORE_NAME = 'features/calendar-sync';
@@ -31,12 +47,14 @@ const STORE_NAME = 'features/calendar-sync';
* runtime value to see if we need to re-request the calendar permission from
* the user.
*/
CALENDAR_ENABLED
isCalendarEnabled()
&& PersistenceRegistry.register(STORE_NAME, {
knownDomains: true
integrationType: true,
knownDomains: true,
msAuthState: true
});
CALENDAR_ENABLED
isCalendarEnabled()
&& ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STATE, action) => {
switch (action.type) {
case APP_WILL_MOUNT:
@@ -49,11 +67,39 @@ CALENDAR_ENABLED
}
break;
case CLEAR_CALENDAR_INTEGRATION:
return DEFAULT_STATE;
case SET_CALENDAR_AUTH_STATE: {
if (!action.msAuthState) {
// received request to delete the state
return set(state, 'msAuthState', undefined);
}
return set(state, 'msAuthState', {
...state.msAuthState,
...action.msAuthState
});
}
case SET_CALENDAR_AUTHORIZATION:
return set(state, 'authorization', action.authorization);
case SET_CALENDAR_EVENTS:
return set(state, 'events', action.events);
case SET_CALENDAR_INTEGRATION:
return {
...state,
integrationReady: action.integrationReady,
integrationType: action.integrationType
};
case SET_CALENDAR_PROFILE_EMAIL:
return set(state, 'profileEmail', action.email);
case SET_LOADING_CALENDAR_EVENTS:
return set(state, 'isLoadingEvents', action.isLoadingEvents);
}
return state;

View File

@@ -0,0 +1,78 @@
/* @flow */
import {
getCalendarEntries,
googleApi,
loadGoogleAPI,
signIn,
updateCalendarEvent,
updateProfile
} from '../../google-api';
/**
* A stateless collection of action creators that implements the expected
* interface for interacting with the Google API in order to get calendar data.
*
* @type {Object}
*/
export const googleCalendarApi = {
/**
* Retrieves the current calendar events.
*
* @param {number} fetchStartDays - The number of days to go back
* when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {function(): Promise<CalendarEntries>}
*/
getCalendarEntries,
/**
* Returns the email address for the currently logged in user.
*
* @returns {function(Dispatch<*>): Promise<string|never>}
*/
getCurrentEmail() {
return updateProfile();
},
/**
* Initializes the google api if needed.
*
* @returns {function(Dispatch<*>, Function): Promise<void>}
*/
load() {
return (dispatch: Dispatch<*>, getState: Function) => {
const { googleApiApplicationClientID }
= getState()['features/base/config'];
return dispatch(loadGoogleAPI(googleApiApplicationClientID));
};
},
/**
* Prompts the participant to sign in to the Google API Client Library.
*
* @returns {function(Dispatch<*>): Promise<string|never>}
*/
signIn,
/**
* Returns whether or not the user is currently signed in.
*
* @returns {function(): Promise<boolean>}
*/
_isSignedIn() {
return () => googleApi.isSignedIn();
},
/**
* Updates calendar event by generating new invite URL and editing the event
* adding some descriptive text and location.
*
* @param {string} id - The event id.
* @param {string} calendarId - The id of the calendar to use.
* @param {string} location - The location to save to the event.
* @returns {function(Dispatch<*>): Promise<string|never>}
*/
updateCalendarEvent
};

View File

@@ -0,0 +1,593 @@
/* @flow */
import { Client } from '@microsoft/microsoft-graph-client';
import rs from 'jsrsasign';
import { createDeferred } from '../../../../modules/util/helpers';
import parseURLParams from '../../base/config/parseURLParams';
import { parseStandardURIString } from '../../base/util';
import { getShareInfoText } from '../../invite';
import { setCalendarAPIAuthState } from '../actions';
/**
* Constants used for interacting with the Microsoft API.
*
* @private
* @type {object}
*/
const MS_API_CONFIGURATION = {
/**
* The URL to use when authenticating using Microsoft API.
* @type {string}
*/
AUTH_ENDPOINT:
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?',
CALENDAR_ENDPOINT: '/me/calendars',
/**
* The Microsoft API scopes to request access for calendar.
*
* @type {string}
*/
MS_API_SCOPES: 'openid profile Calendars.ReadWrite',
/**
* See https://docs.microsoft.com/en-us/azure/active-directory/develop/
* v2-oauth2-implicit-grant-flow#send-the-sign-in-request. This value is
* needed for passing in the proper domain_hint value when trying to refresh
* a token silently.
*
*
* @type {string}
*/
MS_CONSUMER_TENANT: '9188040d-6c67-4c5b-b112-36a304b66dad',
/**
* The redirect URL to be used by the Microsoft API on successful
* authentication.
*
* @type {string}
*/
REDIRECT_URI: `${window.location.origin}/static/msredirect.html`
};
/**
* Store the window from an auth request. That way it can be reused if a new
* request comes in and it can be used to indicate a request is in progress.
*
* @private
* @type {Object|null}
*/
let popupAuthWindow = null;
/**
* A stateless collection of action creators that implements the expected
* interface for interacting with the Microsoft API in order to get calendar
* data.
*
* @type {Object}
*/
export const microsoftCalendarApi = {
/**
* Retrieves the current calendar events.
*
* @param {number} fetchStartDays - The number of days to go back
* when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {function(Dispatch<*>, Function): Promise<CalendarEntries>}
*/
getCalendarEntries(fetchStartDays: ?number, fetchEndDays: ?number) {
return (dispatch: Dispatch<*>, getState: Function): Promise<*> => {
const state = getState()['features/calendar-sync'] || {};
const token = state.msAuthState && state.msAuthState.accessToken;
if (!token) {
return Promise.reject('Not authorized, please sign in!');
}
const client = Client.init({
authProvider: done => done(null, token)
});
return client
.api(MS_API_CONFIGURATION.CALENDAR_ENDPOINT)
.get()
.then(response => {
const calendarIds = response.value.map(en => en.id);
const getEventsPromises = calendarIds.map(id =>
requestCalendarEvents(
client, id, fetchStartDays, fetchEndDays));
return Promise.all(getEventsPromises);
})
// get .value of every element from the array of results,
// which is an array of events and flatten it to one array
// of events
.then(result => [].concat(...result))
.then(entries => entries.map(e => formatCalendarEntry(e)));
};
},
/**
* Returns the email address for the currently logged in user.
*
* @returns {function(Dispatch<*, Function>): Promise<string>}
*/
getCurrentEmail(): Function {
return (dispatch: Dispatch<*>, getState: Function) => {
const { msAuthState = {} }
= getState()['features/calendar-sync'] || {};
const email = msAuthState.userSigninName || '';
return Promise.resolve(email);
};
},
/**
* Sets the application ID to use for interacting with the Microsoft API.
*
* @returns {function(): Promise<void>}
*/
load(): Function {
return () => Promise.resolve();
},
/**
* Prompts the participant to sign in to the Microsoft API Client Library.
*
* @returns {function(Dispatch<*>, Function): Promise<void>}
*/
signIn(): Function {
return (dispatch: Dispatch<*>, getState: Function) => {
// Ensure only one popup window at a time.
if (popupAuthWindow) {
popupAuthWindow.focus();
return Promise.reject('Sign in already in progress.');
}
const signInDeferred = createDeferred();
const guids = {
authState: generateGuid(),
authNonce: generateGuid()
};
dispatch(setCalendarAPIAuthState(guids));
const { microsoftApiApplicationClientID }
= getState()['features/base/config'];
const authUrl = getAuthUrl(
microsoftApiApplicationClientID,
guids.authState,
guids.authNonce);
const h = 600;
const w = 480;
popupAuthWindow = window.open(
authUrl,
'Auth M$',
`width=${w}, height=${h}, top=${
(screen.height / 2) - (h / 2)}, left=${
(screen.width / 2) - (w / 2)}`);
const windowCloseCheck = setInterval(() => {
if (popupAuthWindow && popupAuthWindow.closed) {
signInDeferred.reject(
'Popup closed before completing auth.');
popupAuthWindow = null;
window.removeEventListener('message', handleAuth);
clearInterval(windowCloseCheck);
} else if (!popupAuthWindow) {
// This case probably happened because the user completed
// auth.
clearInterval(windowCloseCheck);
}
}, 500);
/**
* Callback with scope access to other variables that are part of
* the sign in request.
*
* @param {Object} event - The event from the post message.
* @private
* @returns {void}
*/
function handleAuth({ data }) {
if (!data || data.type !== 'ms-login') {
return;
}
window.removeEventListener('message', handleAuth);
popupAuthWindow && popupAuthWindow.close();
popupAuthWindow = null;
const params = getParamsFromHash(data.url);
const tokenParts = getValidatedTokenParts(
params, guids, microsoftApiApplicationClientID);
if (!tokenParts) {
signInDeferred.reject('Invalid token received');
return;
}
dispatch(setCalendarAPIAuthState({
authState: undefined,
accessToken: tokenParts.accessToken,
idToken: tokenParts.idToken,
tokenExpires: params.tokenExpires,
userDomainType: tokenParts.userDomainType,
userSigninName: tokenParts.userSigninName
}));
signInDeferred.resolve();
}
window.addEventListener('message', handleAuth);
return signInDeferred.promise;
};
},
/**
* Returns whether or not the user is currently signed in.
*
* @returns {function(Dispatch<*>, Function): Promise<boolean>}
*/
_isSignedIn(): Function {
return (dispatch: Dispatch<*>, getState: Function) => {
const now = new Date().getTime();
const state
= getState()['features/calendar-sync'].msAuthState || {};
const tokenExpires = parseInt(state.tokenExpires, 10);
const isExpired = now > tokenExpires && !isNaN(tokenExpires);
if (state.accessToken && isExpired) {
// token expired, let's refresh it
return dispatch(this._refreshAuthToken())
.then(() => true)
.catch(() => false);
}
return Promise.resolve(state.accessToken && !isExpired);
};
},
/**
* Renews an existing auth token so it can continue to be used.
*
* @private
* @returns {function(Dispatch<*>, Function): Promise<void>}
*/
_refreshAuthToken(): Function {
return (dispatch: Dispatch<*>, getState: Function) => {
const { microsoftApiApplicationClientID }
= getState()['features/base/config'];
const { msAuthState = {} }
= getState()['features/calendar-sync'] || {};
const refreshAuthUrl = getAuthRefreshUrl(
microsoftApiApplicationClientID,
msAuthState.userDomainType,
msAuthState.userSigninName);
const iframe = document.createElement('iframe');
iframe.setAttribute('id', 'auth-iframe');
iframe.setAttribute('name', 'auth-iframe');
iframe.setAttribute('style', 'display: none');
iframe.setAttribute('src', refreshAuthUrl);
const signInPromise = new Promise(resolve => {
iframe.onload = () => {
resolve(iframe.contentWindow.location.hash);
};
});
// The check for body existence is done for flow, which also runs
// against native where document.body may not be defined.
if (!document.body) {
return Promise.reject(
'Cannot refresh auth token in this environment');
}
document.body.appendChild(iframe);
return signInPromise.then(hash => {
const params = getParamsFromHash(hash);
dispatch(setCalendarAPIAuthState({
accessToken: params.access_token,
idToken: params.id_token,
tokenExpires: params.tokenExpires
}));
});
};
},
/**
* Updates calendar event by generating new invite URL and editing the event
* adding some descriptive text and location.
*
* @param {string} id - The event id.
* @param {string} calendarId - The id of the calendar to use.
* @param {string} location - The location to save to the event.
* @returns {function(Dispatch<*>): Promise<string|never>}
*/
updateCalendarEvent(id: string, calendarId: string, location: string) {
return (dispatch: Dispatch<*>, getState: Function): Promise<*> => {
const state = getState()['features/calendar-sync'] || {};
const token = state.msAuthState && state.msAuthState.accessToken;
if (!token) {
return Promise.reject('Not authorized, please sign in!');
}
const { dialInNumbersUrl } = getState()['features/base/config'];
const text = getShareInfoText(
location, dialInNumbersUrl !== undefined, true/* use html */);
const client = Client.init({
authProvider: done => done(null, token)
});
return client
.api(`/me/events/${id}`)
.get()
.then(description => {
const body = description.body;
if (description.bodyPreview) {
body.content = `${description.bodyPreview}<br><br>`;
}
// replace all new lines from the text with html <br>
// to make it pretty
body.content += text.split('\n').join('<br>');
return client
.api(`/me/calendar/events/${id}`)
.patch({
body,
location: {
'displayName': location
}
});
});
};
}
};
/**
* Parses the Microsoft calendar entries to a known format.
*
* @param {Object} entry - The Microsoft calendar entry.
* @private
* @returns {{
* calendarId: string,
* description: string,
* endDate: string,
* id: string,
* location: string,
* startDate: string,
* title: string
* }}
*/
function formatCalendarEntry(entry) {
return {
calendarId: entry.calendarId,
description: entry.body.content,
endDate: entry.end.dateTime,
id: entry.id,
location: entry.location.displayName,
startDate: entry.start.dateTime,
title: entry.subject
};
}
/**
* Generate a guid to be used for verifying token validity.
*
* @private
* @returns {string} The generated string.
*/
function generateGuid() {
const buf = new Uint16Array(8);
window.crypto.getRandomValues(buf);
return `${s4(buf[0])}${s4(buf[1])}-${s4(buf[2])}-${s4(buf[3])}-${
s4(buf[4])}-${s4(buf[5])}${s4(buf[6])}${s4(buf[7])}`;
}
/**
* Constructs and returns the URL to use for renewing an auth token.
*
* @param {string} appId - The Microsoft application id to log into.
* @param {string} userDomainType - The domain type of the application as
* provided by Microsoft.
* @param {string} userSigninName - The email of the user signed into the
* integration with Microsoft.
* @private
* @returns {string} - The auth URL.
*/
function getAuthRefreshUrl(appId, userDomainType, userSigninName) {
return [
getAuthUrl(appId, 'undefined', 'undefined'),
'prompt=none',
`domain_hint=${userDomainType}`,
`login_hint=${userSigninName}`
].join('&');
}
/**
* Constructs and returns the auth URL to use for login.
*
* @param {string} appId - The Microsoft application id to log into.
* @param {string} authState - The authState guid to use.
* @param {string} authNonce - The authNonce guid to use.
* @private
* @returns {string} - The auth URL.
*/
function getAuthUrl(appId, authState, authNonce) {
const authParams = [
'response_type=id_token+token',
`client_id=${appId}`,
`redirect_uri=${MS_API_CONFIGURATION.REDIRECT_URI}`,
`scope=${MS_API_CONFIGURATION.MS_API_SCOPES}`,
`state=${authState}`,
`nonce=${authNonce}`,
'response_mode=fragment'
].join('&');
return `${MS_API_CONFIGURATION.AUTH_ENDPOINT}${authParams}`;
}
/**
* Converts a url from an auth redirect into an object of parameters passed
* into the url.
*
* @param {string} url - The string to parse.
* @private
* @returns {Object}
*/
function getParamsFromHash(url) {
const params = parseURLParams(parseStandardURIString(url), true, 'hash');
// Get the number of seconds the token is valid for, subtract 5 minutes
// to account for differences in clock settings and convert to ms.
const expiresIn = (parseInt(params.expires_in, 10) - 300) * 1000;
const now = new Date();
const expireDate = new Date(now.getTime() + expiresIn);
params.tokenExpires = expireDate.getTime().toString();
return params;
}
/**
* Converts the parameters from a Microsoft auth redirect into an object of
* token parts. The value "null" will be returned if the params do not produce
* a valid token.
*
* @param {Object} tokenInfo - The token object.
* @param {Object} guids - The guids for authState and authNonce that should
* match in the token.
* @param {Object} appId - The Microsoft application this token is for.
* @private
* @returns {Object|null}
*/
function getValidatedTokenParts(tokenInfo, guids, appId) {
// Make sure the token matches the request source by matching the GUID.
if (tokenInfo.state !== guids.authState) {
return null;
}
const idToken = tokenInfo.id_token;
// A token must exist to be valid.
if (!idToken) {
return null;
}
const tokenParts = idToken.split('.');
if (tokenParts.length !== 3) {
return null;
}
const payload
= rs.KJUR.jws.JWS.readSafeJSONString(rs.b64utoutf8(tokenParts[1]));
if (payload.nonce !== guids.authNonce
|| payload.aud !== appId
|| payload.iss
!== `https://login.microsoftonline.com/${payload.tid}/v2.0`) {
return null;
}
const now = new Date();
// Adjust by 5 minutes to allow for inconsistencies in system clocks.
const notBefore = new Date((payload.nbf - 300) * 1000);
const expires = new Date((payload.exp + 300) * 1000);
if (now < notBefore || now > expires) {
return null;
}
return {
accessToken: tokenInfo.access_token,
idToken,
userDisplayName: payload.name,
userDomainType:
payload.tid === MS_API_CONFIGURATION.MS_CONSUMER_TENANT
? 'consumers' : 'organizations',
userSigninName: payload.preferred_username
};
}
/**
* Retrieves calendar entries from a specific calendar.
*
* @param {Object} client - The Microsoft-graph-client initialized.
* @param {string} calendarId - The calendar ID to use.
* @param {number} fetchStartDays - The number of days to go back
* when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {Promise<any> | Promise}
* @private
*/
function requestCalendarEvents( // eslint-disable-line max-params
client,
calendarId,
fetchStartDays,
fetchEndDays): Promise<*> {
const startDate = new Date();
const endDate = new Date();
startDate.setDate(startDate.getDate() + fetchStartDays);
endDate.setDate(endDate.getDate() + fetchEndDays);
const filter = `Start/DateTime ge '${
startDate.toISOString()}' and End/DateTime lt '${
endDate.toISOString()}'`;
return client
.api(`/me/calendars/${calendarId}/events`)
.filter(filter)
.select('id,subject,start,end,location,body')
.orderby('createdDateTime DESC')
.get()
.then(result => result.value.map(item => {
return {
...item,
calendarId
};
}));
}
/**
* Converts the passed in number to a string and ensure it is at least 4
* characters in length, prepending 0's as needed.
*
* @param {number} num - The number to pad and convert to a string.
* @private
* @returns {string} - The number converted to a string.
*/
function s4(num) {
let ret = num.toString(16);
while (ret.length < 4) {
ret = `0${ret}`;
}
return ret;
}

View File

@@ -4,6 +4,8 @@ import _ from 'lodash';
import React, { Component } from 'react';
import { connect as reactReduxConnect } from 'react-redux';
import VideoLayout from '../../../../modules/UI/videolayout/VideoLayout';
import { obtainConfig } from '../../base/config';
import { connect, disconnect } from '../../base/connection';
import { DialogContainer } from '../../base/dialog';
@@ -13,6 +15,12 @@ import { CalleeInfoContainer } from '../../invite';
import { LargeVideo } from '../../large-video';
import { NotificationsContainer } from '../../notifications';
import { SidePanel } from '../../side-panel';
import {
LAYOUTS,
getCurrentLayout,
shouldDisplayTileView
} from '../../video-layout';
import { default as Notice } from './Notice';
import {
Toolbox,
@@ -49,9 +57,10 @@ const FULL_SCREEN_EVENTS = [
* @private
* @type {Object}
*/
const LAYOUT_CLASSES = {
HORIZONTAL_FILMSTRIP: 'horizontal-filmstrip',
VERTICAL_FILMSTRIP: 'vertical-filmstrip'
const LAYOUT_CLASSNAMES = {
[LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW]: 'horizontal-filmstrip',
[LAYOUTS.TILE_VIEW]: 'tile-view',
[LAYOUTS.VERTICAL_FILMSTRIP_VIEW]: 'vertical-filmstrip'
};
/**
@@ -68,13 +77,18 @@ type Props = {
* The CSS class to apply to the root of {@link Conference} to modify the
* application layout.
*/
_layoutModeClassName: string,
_layoutClassName: string,
/**
* Conference room name.
*/
_room: string,
/**
* Whether or not the current UI layout should be in tile view.
*/
_shouldDisplayTileView: boolean,
dispatch: Function,
t: Function
}
@@ -143,6 +157,25 @@ class Conference extends Component<Props> {
}
}
/**
* Calls into legacy UI to update the application layout, if necessary.
*
* @inheritdoc
* returns {void}
*/
componentDidUpdate(prevProps) {
if (this.props._shouldDisplayTileView
=== prevProps._shouldDisplayTileView) {
return;
}
// TODO: For now VideoLayout is being called as LargeVideo and Filmstrip
// sizing logic is still handled outside of React. Once all components
// are in react they should calculate size on their own as much as
// possible and pass down sizings.
VideoLayout.refreshLayout();
}
/**
* Disconnect from the conference when component will be
* unmounted.
@@ -180,7 +213,7 @@ class Conference extends Component<Props> {
return (
<div
className = { this.props._layoutModeClassName }
className = { this.props._layoutClassName }
id = 'videoconference_page'
onMouseMove = { this._onShowToolbar }>
<Notice />
@@ -257,29 +290,19 @@ class Conference extends Component<Props> {
* @private
* @returns {{
* _iAmRecorder: boolean,
* _room: ?string
* _layoutClassName: string,
* _room: ?string,
* _shouldDisplayTileView: boolean
* }}
*/
function _mapStateToProps(state) {
const { room } = state['features/base/conference'];
const { iAmRecorder } = state['features/base/config'];
const currentLayout = getCurrentLayout(state);
return {
/**
* Whether the local participant is recording the conference.
*
* @private
*/
_iAmRecorder: iAmRecorder,
_layoutModeClassName: interfaceConfig.VERTICAL_FILMSTRIP
? LAYOUT_CLASSES.VERTICAL_FILMSTRIP
: LAYOUT_CLASSES.HORIZONTAL_FILMSTRIP,
/**
* Conference room name.
*/
_room: room
_iAmRecorder: state['features/base/config'].iAmRecorder,
_layoutClassName: LAYOUT_CLASSNAMES[currentLayout],
_room: state['features/base/conference'].room,
_shouldDisplayTileView: shouldDisplayTileView(state)
};
}

View File

@@ -324,6 +324,7 @@ class ConnectionIndicator extends Component {
* @returns {void}
*/
_onStatsUpdated(stats = {}) {
// Rely on React to batch setState actions.
const { connectionQuality } = stats;
const newPercentageState = typeof connectionQuality === 'undefined'
? {} : { percent: connectionQuality };
@@ -337,7 +338,6 @@ class ConnectionIndicator extends Component {
stats: newStats
});
// Rely on React to batch setState actions.
this._updateIndicatorAutoHide(newStats.percent);
}
@@ -410,8 +410,10 @@ class ConnectionIndicator extends Component {
const {
bandwidth,
bitrate,
e2eRtt,
framerate,
packetLoss,
region,
resolution,
transport
} = this.state.stats;
@@ -421,10 +423,12 @@ class ConnectionIndicator extends Component {
bandwidth = { bandwidth }
bitrate = { bitrate }
connectionSummary = { this._getConnectionStatusTip() }
e2eRtt = { e2eRtt }
framerate = { framerate }
isLocalVideo = { this.props.isLocalVideo }
onShowMore = { this._onToggleShowMore }
packetLoss = { packetLoss }
region = { region }
resolution = { resolution }
shouldShowMore = { this.state.showMoreStats }
transport = { transport } />

View File

@@ -2,7 +2,10 @@
import _ from 'lodash';
import { JitsiConnectionQualityEvents } from '../base/lib-jitsi-meet';
import {
JitsiConnectionQualityEvents,
JitsiE2ePingEvents
} from '../base/lib-jitsi-meet';
declare var APP: Object;
@@ -33,6 +36,17 @@ const statsEmitter = {
conference.on(JitsiConnectionQualityEvents.REMOTE_STATS_UPDATED,
(id, stats) => this._emitStatsUpdate(id, stats));
conference.on(
JitsiE2ePingEvents.E2E_RTT_CHANGED,
(participant, e2eRtt) => {
const stats = {
e2eRtt,
region: participant.getProperty('region')
};
this._emitStatsUpdate(participant.getId(), stats);
});
},
/**

View File

@@ -39,7 +39,12 @@ class ConnectionStatsTable extends Component {
connectionSummary: PropTypes.string,
/**
* Statistics related to framerates for each ssrc.
* The end-to-end round-trip-time.
*/
e2eRtt: PropTypes.number,
/**
* Statistics related to frame rates for each ssrc.
* {{
* [ ssrc ]: Number
* }}
@@ -47,7 +52,7 @@ class ConnectionStatsTable extends Component {
framerate: PropTypes.object,
/**
* Whether or not the statitics are for local video.
* Whether or not the statistics are for local video.
*/
isLocalVideo: PropTypes.bool,
@@ -65,6 +70,11 @@ class ConnectionStatsTable extends Component {
*/
packetLoss: PropTypes.object,
/**
* The region.
*/
region: PropTypes.string,
/**
* Statistics related to display resolutions for each ssrc.
* {{
@@ -208,6 +218,31 @@ class ConnectionStatsTable extends Component {
);
}
/**
* Creates a table row as a ReactElement for displaying end-to-end RTT and
* the region.
*
* @returns {ReactElement}
* @private
*/
_renderE2eRtt() {
const { e2eRtt, region, t } = this.props;
let str = e2eRtt ? `${e2eRtt.toFixed(0)}ms` : 'N/A';
if (region) {
str += ` (${region})`;
}
return (
<tr>
<td>
<span>{ t('connectionindicator.e2e_rtt') }</span>
</td>
<td>{ str }</td>
</tr>
);
}
/**
* Creates a table row as a ReactElement for displaying frame rate related
* statistics.
@@ -245,7 +280,6 @@ class ConnectionStatsTable extends Component {
if (packetLoss) {
const { download, upload } = packetLoss;
// eslint-disable-next-line no-extra-parens
packetLossTableData = (
<td>
<span className = 'connection-info__download'>
@@ -330,12 +364,15 @@ class ConnectionStatsTable extends Component {
* @returns {ReactElement}
*/
_renderStatistics() {
const isRemoteVideo = !this.props.isLocalVideo;
return (
<table className = 'connection-info__container'>
<tbody>
{ this._renderConnectionSummary() }
{ this._renderBitrate() }
{ this._renderPacketLoss() }
{ isRemoteVideo ? this._renderE2eRtt() : null }
{ this._renderResolution() }
{ this._renderFrameRate() }
</tbody>
@@ -354,7 +391,6 @@ class ConnectionStatsTable extends Component {
const { t, transport } = this.props;
if (!transport || transport.length === 0) {
// eslint-disable-next-line no-extra-parens
const NA = (
<tr key = 'address'>
<td>

View File

@@ -61,18 +61,16 @@ class DesktopPickerPane extends Component {
const classNames
= `desktop-picker-pane default-scrollbar source-type-${type}`;
const previews
= sources ? sources.map(
source =>
// eslint-disable-next-line react/jsx-wrap-multilines
= sources
? sources.map(source => (
<DesktopSourcePreview
key = { source.id }
onClick = { onClick }
onDoubleClick = { onDoubleClick }
selected = { source.id === selectedSourceId }
source = { source }
type = { type } />)
: ( // eslint-disable-line no-extra-parens
type = { type } />))
: (
<div className = 'desktop-picker-pane-spinner'>
<Spinner
isCompleting = { false }

View File

@@ -0,0 +1,11 @@
// @flow
/**
* The type of (redux) action to update the dropbox access token.
*
* {
* type: UPDATE_DROPBOX_TOKEN,
* token: string
* }
*/
export const UPDATE_DROPBOX_TOKEN = Symbol('UPDATE_DROPBOX_TOKEN');

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
// @flow
import { ReducerRegistry } from '../base/redux';
import { PersistenceRegistry } from '../base/storage';
import { UPDATE_DROPBOX_TOKEN } from './actionTypes';
/**
* The redux subtree of this feature.
*/
const STORE_NAME = 'features/dropbox';
/**
* Sets up the persistence of the feature {@code dropbox}.
*/
PersistenceRegistry.register(STORE_NAME);
ReducerRegistry.register(STORE_NAME, (state = {}, action) => {
switch (action.type) {
case UPDATE_DROPBOX_TOKEN:
return {
...state,
token: action.token
};
default:
return state;
}
});

View File

@@ -121,17 +121,15 @@ class Filmstrip extends Component<Props> {
&& <LocalThumbnail />
}
{
/* eslint-disable react/jsx-wrap-multilines */
this._sort(
this.props._participants,
isNarrowAspectRatio_)
.map(p =>
.map(p => (
<Thumbnail
key = { p.id }
participant = { p } />)
participant = { p } />))
/* eslint-enable react/jsx-wrap-multilines */
}
{
!this._separateLocalThumbnail

View File

@@ -8,6 +8,7 @@ import { dockToolbox } from '../../../toolbox';
import { setFilmstripHovered } from '../../actions';
import { shouldRemoteVideosBeVisible } from '../../functions';
import Toolbar from './Toolbar';
declare var interfaceConfig: Object;
@@ -185,9 +186,8 @@ function _mapStateToProps(state) {
&& state['features/toolbox'].visible
&& interfaceConfig.TOOLBAR_BUTTONS.length;
const remoteVideosVisible = shouldRemoteVideosBeVisible(state);
const className = `${remoteVideosVisible ? '' : 'hide-videos'} ${
reduceHeight ? 'reduce-height' : ''}`;
reduceHeight ? 'reduce-height' : ''}`.trim();
return {
_className: className,

View File

@@ -1,4 +1,5 @@
/* @flow */
import { getShareInfoText } from '../invite';
import {
SET_GOOGLE_API_PROFILE,
@@ -7,6 +8,21 @@ import {
import { GOOGLE_API_STATES } from './constants';
import googleApi from './googleApi';
/**
* Retrieves the current calendar events.
*
* @param {number} fetchStartDays - The number of days to go back when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {function(Dispatch<*>): Promise<CalendarEntries>}
*/
export function getCalendarEntries(
fetchStartDays: ?number, fetchEndDays: ?number) {
return () =>
googleApi.get()
.then(() =>
googleApi._getCalendarEntries(fetchStartDays, fetchEndDays));
}
/**
* Loads Google API.
*
@@ -14,9 +30,16 @@ import googleApi from './googleApi';
* @returns {Function}
*/
export function loadGoogleAPI(clientId: string) {
return (dispatch: Dispatch<*>) =>
return (dispatch: Dispatch<*>, getState: Function) =>
googleApi.get()
.then(() => googleApi.initializeClient(clientId))
.then(() => {
if (getState()['features/google-api'].googleAPIState
=== GOOGLE_API_STATES.NEEDS_LOADING) {
return googleApi.initializeClient(clientId);
}
return Promise.resolve();
})
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.LOADED }))
@@ -30,39 +53,6 @@ export function loadGoogleAPI(clientId: string) {
});
}
/**
* Prompts the participant to sign in to the Google API Client Library.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function signIn() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}));
}
/**
* Updates the profile data that is currently used.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function updateProfile() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}))
.then(() => googleApi.getCurrentUserProfile())
.then(profile => dispatch({
type: SET_GOOGLE_API_PROFILE,
profileEmail: profile.getEmail()
}));
}
/**
* Executes a request for a list of all YouTube broadcasts associated with
* user currently signed in to the Google API Client Library.
@@ -137,3 +127,82 @@ export function showAccountSelection() {
return () =>
googleApi.showAccountSelection();
}
/**
* Prompts the participant to sign in to the Google API Client Library.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function signIn() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}));
}
/**
* Logs out the user.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function signOut() {
return (dispatch: Dispatch<*>) =>
googleApi.get()
.then(() => googleApi.signOut())
.then(() => {
dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.LOADED
});
dispatch({
type: SET_GOOGLE_API_PROFILE,
profileEmail: ''
});
});
}
/**
* Updates the profile data that is currently used.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function updateProfile() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}))
.then(() => googleApi.getCurrentUserProfile())
.then(profile => {
dispatch({
type: SET_GOOGLE_API_PROFILE,
profileEmail: profile.getEmail()
});
return profile.getEmail();
});
}
/**
* Updates the calendar event and adds a location and text.
*
* @param {string} id - The event id to update.
* @param {string} calendarId - The calendar id to use.
* @param {string} location - The location to add to the event.
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function updateCalendarEvent(
id: string, calendarId: string, location: string) {
return (dispatch: Dispatch<*>, getState: Function) => {
const { dialInNumbersUrl } = getState()['features/base/config'];
const text = getShareInfoText(location, dialInNumbersUrl !== undefined);
return googleApi.get()
.then(() =>
googleApi._updateCalendarEntry(id, calendarId, location, text));
};
}

View File

@@ -1,29 +1,25 @@
import PropTypes from 'prop-types';
// @flow
import React, { Component } from 'react';
/**
* The type of the React {@code Component} props of {@link GoogleSignInButton}.
*/
type Props = {
// The callback to invoke when {@code GoogleSignInButton} is clicked.
onClick: Function,
// The text to display within {@code GoogleSignInButton}.
text: string
};
/**
* A React Component showing a button to sign in with Google.
*
* @extends Component
*/
export default class GoogleSignInButton extends Component {
/**
* {@code GoogleSignInButton} component's property types.
*
* @static
*/
static propTypes = {
/**
* The callback to invoke when the button is clicked.
*/
onClick: PropTypes.func,
/**
* The text to display in the button.
*/
text: PropTypes.string
};
export default class GoogleSignInButton extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*

View File

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

View File

@@ -1,14 +1,23 @@
// @flow
/**
* The Google API scopes to request access to for streaming.
* The Google API scopes to request access for streaming and calendar.
*
* @type {Array<string>}
*/
export const GOOGLE_API_SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly'
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/calendar'
];
/**
* Array of API discovery doc URLs for APIs used by the googleApi.
*
* @type {string[]}
*/
export const DISCOVERY_DOCS
= [ 'https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest' ];
/**
* An enumeration of the different states the Google API can be in.
*

View File

@@ -1,4 +1,4 @@
import { GOOGLE_API_SCOPES } from './constants';
import { GOOGLE_API_SCOPES, DISCOVERY_DOCS } from './constants';
const GOOGLE_API_CLIENT_LIBRARY_URL = 'https://apis.google.com/js/api.js';
@@ -67,6 +67,7 @@ const googleApi = {
setTimeout(() => {
api.client.init({
clientId,
discoveryDocs: DISCOVERY_DOCS,
scope: GOOGLE_API_SCOPES.join(' ')
})
.then(resolve)
@@ -86,6 +87,7 @@ const googleApi = {
.then(api => Boolean(api
&& api.auth2
&& api.auth2.getAuthInstance
&& api.auth2.getAuthInstance()
&& api.auth2.getAuthInstance().isSignedIn
&& api.auth2.getAuthInstance().isSignedIn.get()));
},
@@ -183,6 +185,165 @@ const googleApi = {
});
},
/**
* Sign out from the Google API Client Library.
*
* @returns {Promise}
*/
signOut() {
return this.get()
.then(api =>
api.auth2
&& api.auth2.getAuthInstance
&& api.auth2.getAuthInstance()
&& api.auth2.getAuthInstance().signOut());
},
/**
* Parses the google calendar entries to a known format.
*
* @param {Object} entry - The google calendar entry.
* @returns {{
* calendarId: string,
* description: string,
* endDate: string,
* id: string,
* location: string,
* startDate: string,
* title: string}}
* @private
*/
_convertCalendarEntry(entry) {
return {
calendarId: entry.calendarId,
description: entry.description,
endDate: entry.end.dateTime,
id: entry.id,
location: entry.location,
startDate: entry.start.dateTime,
title: entry.summary
};
},
/**
* Retrieves calendar entries from all available calendars.
*
* @param {number} fetchStartDays - The number of days to go back
* when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {Promise<CalendarEntry>}
* @private
*/
_getCalendarEntries(fetchStartDays, fetchEndDays) {
return this.get()
.then(() => this.isSignedIn())
.then(isSignedIn => {
if (!isSignedIn) {
return null;
}
// user can edit the events, so we want only those that
// can be edited
return this._getGoogleApiClient()
.client.calendar.calendarList.list();
})
.then(calendarList => {
// no result, maybe not signed in
if (!calendarList) {
return Promise.resolve();
}
const calendarIds
= calendarList.result.items.map(en => {
return {
id: en.id,
accessRole: en.accessRole
};
});
const promises = calendarIds.map(({ id, accessRole }) => {
const startDate = new Date();
const endDate = new Date();
startDate.setDate(startDate.getDate() + fetchStartDays);
endDate.setDate(endDate.getDate() + fetchEndDays);
// retrieve the events and adds to the result the calendarId
return this._getGoogleApiClient()
.client.calendar.events.list({
'calendarId': id,
'timeMin': startDate.toISOString(),
'timeMax': endDate.toISOString(),
'showDeleted': false,
'singleEvents': true,
'orderBy': 'startTime'
})
.then(result => result.result.items
.map(item => {
const resultItem = { ...item };
// add the calendarId only for the events
// we can edit
if (accessRole === 'writer'
|| accessRole === 'owner') {
resultItem.calendarId = id;
}
return resultItem;
}));
});
return Promise.all(promises)
.then(results => [].concat(...results))
.then(entries =>
entries.map(e => this._convertCalendarEntry(e)));
});
},
/* eslint-disable max-params */
/**
* Updates the calendar event and adds a location and text.
*
* @param {string} id - The event id to update.
* @param {string} calendarId - The calendar id to use.
* @param {string} location - The location to add to the event.
* @param {string} text - The description text to set/append.
* @returns {Promise<T | never>}
* @private
*/
_updateCalendarEntry(id, calendarId, location, text) {
return this.get()
.then(() => this.isSignedIn())
.then(isSignedIn => {
if (!isSignedIn) {
return null;
}
return this._getGoogleApiClient()
.client.calendar.events.get({
'calendarId': calendarId,
'eventId': id
}).then(event => {
let newDescription = text;
if (event.result.description) {
newDescription = `${event.result.description}\n\n${
text}`;
}
return this._getGoogleApiClient()
.client.calendar.events.patch({
'calendarId': calendarId,
'eventId': id,
'description': newDescription,
'location': location
});
});
});
},
/* eslint-enable max-params */
/**
* Returns the global Google API Client Library object. Direct use of this
* method is discouraged; instead use the {@link get} method.

View File

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

Some files were not shown because too many files have changed in this diff Show More