diff --git a/images/jane_logo_72.png b/images/jane_logo_72.png index 5672d8595ee1..f8e682de5799 100644 Binary files a/images/jane_logo_72.png and b/images/jane_logo_72.png differ diff --git a/images/watermark.png b/images/watermark.png index 3c4ae443d6ba..e356b2aee15f 100644 Binary files a/images/watermark.png and b/images/watermark.png differ diff --git a/react/features/analytics/AnalyticsEvents.js b/react/features/analytics/AnalyticsEvents.js index b8006ffc582d..2568fdc0e2e3 100644 --- a/react/features/analytics/AnalyticsEvents.js +++ b/react/features/analytics/AnalyticsEvents.js @@ -1,4 +1,5 @@ -import _ from "lodash"; +import _ from 'lodash'; + /** * The constant for the event type 'track'. * TODO: keep these constants in a single place. Can we import them from @@ -801,9 +802,25 @@ export function createConnectionQualityChangedEvent(strength, stats) { * Remove the track id from the ConnectionQualityChangedEvent property. * If we don't want to send the trackId along with the property to amplitude. * - * @param {Object} event - event object. - * @returns {Object|number|string|undefined} property. + * @param {Object} event - Event object. + * @returns {Object|number|string|undefined} Property. */ function removeTrackIdFromEventPropertyObject(event) { return event && _.isObject(event) && Object.values(event)[0]; } + +/** + * Creates an event for an action on the waiting area page. + * + * @param {string} action - The action that the event represents. + * @param {boolean} attributes - Additional attributes to attach to the event. + * @returns {Object} The event in a format suitable for sending via + * sendAnalytics. + */ +export function createWaitingAreaPageEvent(action, attributes = {}) { + return { + action, + attributes, + source: 'waiting.area' + }; +} diff --git a/react/features/app/actions.js b/react/features/app/actions.js index dba35461d73d..75fd421ca0a3 100644 --- a/react/features/app/actions.js +++ b/react/features/app/actions.js @@ -1,8 +1,8 @@ // @flow import type { Dispatch } from 'redux'; - import { setRoom } from '../base/conference'; +import { enableJaneWaitingArea, isJaneWaitingAreaEnabled } from '../jane-waiting-area-native'; import { configWillLoad, createFakeConfig, @@ -131,7 +131,11 @@ export function appNavigate(uri: ?string) { // FIXME: unify with web, currently the connection and track creation happens in conference.js. if (room && navigator.product === 'ReactNative') { dispatch(createDesiredLocalTracks()); - dispatch(connect()); + if (isJaneWaitingAreaEnabled(getState())) { + dispatch(enableJaneWaitingArea(true)); + } else { + dispatch(connect()); + } } }; } diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js index b1eba1c71e9b..aed1676edf02 100644 --- a/react/features/base/conference/actions.js +++ b/react/features/base/conference/actions.js @@ -27,7 +27,7 @@ import { import { getLocalTracks, trackAdded, trackRemoved } from '../tracks'; import { getBackendSafeRoomName, - getJitsiMeetGlobalNS + getJitsiMeetGlobalNS, sendBeaconToJaneRN } from '../util'; import { @@ -417,7 +417,7 @@ export function conferenceWillLeave(conference: Object) { if (url && surveyUrl) { Linking.openURL(surveyUrl).then(() => { - sendBeaconRn(url, data).then(r => { + sendBeaconToJaneRN(url, data).then(r => { console.log(r, 'response'); }) .catch(e => { @@ -434,18 +434,6 @@ export function conferenceWillLeave(conference: Object) { }; } - -// eslint-disable-next-line require-jsdoc,no-unused-vars,no-empty-function -function sendBeaconRn(url, data) { - return fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'text/plain; charset=UTF-8' - }, - body: data - }); -} - /** * Initializes a new conference. * diff --git a/react/features/base/conference/functions.js b/react/features/base/conference/functions.js index 8bb8304ce095..2a7684cf44a7 100644 --- a/react/features/base/conference/functions.js +++ b/react/features/base/conference/functions.js @@ -2,6 +2,7 @@ import _ from 'lodash'; +import jwtDecode from 'jwt-decode'; import { JitsiTrackErrors } from '../lib-jitsi-meet'; import { getLocalParticipant, @@ -351,3 +352,23 @@ export function sendLocalParticipant( conference.setDisplayName(name); } + +/** + * Check if the call is the test call. + * + * + * @param {Function|Object} state - The redux store, state, or + * {@code getState} function. + * @returns {boolean} + */ +export function isJaneTestCall(state) { + const { jwt } = state['features/base/jwt']; + const jwtPayload = jwt && jwtDecode(jwt) ?? null; + const context = jwtPayload && jwtPayload.context ?? null; + const user = context && context.user ?? null; + const participantId = user && user.participant_id; + const videoChatSessionId = context && context.video_chat_session_id; + const participantEmail = user && user.email; + + return participantId === 0 && videoChatSessionId === 0 && participantEmail === 'test@test.com'; +} diff --git a/react/features/base/environment/utils.js b/react/features/base/environment/utils.js index 386c49b9eb26..54fffae6c0fe 100644 --- a/react/features/base/environment/utils.js +++ b/react/features/base/environment/utils.js @@ -1,6 +1,7 @@ // @flow import Platform from '../react/Platform'; +import { Dimensions } from 'react-native'; /** * Returns whether or not the current environment is a mobile device. @@ -36,3 +37,7 @@ export function checkChromeExtensionsInstalled(config: Object = {}) { (config.chromeExtensionsInfo || []).map(info => extensionInstalledFunction(info)) ); } + +export function iphoneHasNotch () { + return Platform.OS === 'ios' && Dimensions.get('window').height > 811 && !Platform.isPad || false; +} diff --git a/react/features/base/i18n/dateUtil.js b/react/features/base/i18n/dateUtil.js index 154ff066d0b9..3dbb388a41f3 100644 --- a/react/features/base/i18n/dateUtil.js +++ b/react/features/base/i18n/dateUtil.js @@ -100,3 +100,14 @@ function _getSupportedLocale() { return supportedLocale || 'en'; } + +/** + * Returns a unix timestamp in milliseconds({@code number}). + * + * @param {Date | string} date - The date from jwt token. + * @returns {number} + */ +export function getTimeStamp(date) { + return moment(date, 'YYYY-MM-DD HH:mm:ss') + .valueOf(); +} diff --git a/react/features/base/react/components/functions.js b/react/features/base/react/components/functions.js new file mode 100644 index 000000000000..3c22c8e7054d --- /dev/null +++ b/react/features/base/react/components/functions.js @@ -0,0 +1,30 @@ +// @flow + +import { + checkLocalParticipantCanJoin, + isJaneWaitingAreaEnabled +} from '../../../jane-waiting-area-native/functions'; +import { getLocalParticipantType } from '../../participants/functions'; + +/** + * Returns the field value in a platform generic way. + * + * @param {Object | string} fieldParameter - The parameter passed through the change event function. + * @returns {string} + */ +export function getFieldValue(fieldParameter: Object | string) { + return typeof fieldParameter === 'string' ? fieldParameter : fieldParameter?.target?.value; +} + +// eslint-disable-next-line require-jsdoc +export function shouldShowPreCallMessage(state: Object) { + const participantType = getLocalParticipantType(state); + const { remoteParticipantsStatuses } = state['features/jane-waiting-area-native']; + + if (isJaneWaitingAreaEnabled(state)) { + return participantType !== 'StaffMember' + && !checkLocalParticipantCanJoin(remoteParticipantsStatuses, participantType); + } + + return true; +} diff --git a/react/features/base/react/components/native/PreCallMessage.js b/react/features/base/react/components/native/PreCallMessage.js new file mode 100644 index 000000000000..31c98b061588 --- /dev/null +++ b/react/features/base/react/components/native/PreCallMessage.js @@ -0,0 +1,229 @@ +// @flow +/* eslint-disable require-jsdoc*/ + +import React, { Component } from 'react/index'; +import _ from 'lodash'; + +import { + Animated, + Text, + Image, + TouchableOpacity, Easing +} from 'react-native'; +import styles, { WAITING_MESSAGE_CONTIANER_BACKGROUND_COLOR } from './styles'; +import { getLocalizedDateFormatter, translate, getTimeStamp } from '../../../i18n'; +import { connect } from '../../../redux'; +import { getParticipantCount } from '../../../participants'; +import { getRemoteTracks } from '../../../tracks'; +import jwtDecode from 'jwt-decode'; +import { isJaneTestCall } from '../../../conference'; +import { Icon, IconClose } from '../../../../base/icons'; +import { isIPhoneX } from '../../../../base/styles/functions.native'; +import { getLocalParticipantType } from '../../../../base/participants/functions'; +import { isJaneWaitingAreaEnabled } from '../../../../jane-waiting-area-native'; +import { shouldShowPreCallMessage } from '../functions'; + +const watermarkImg = require('../../../../../../images/watermark.png'); + +const WATERMARK_ANIMATION_INPUT_RANGE = [ 0, 0.5, 1 ]; +const WATERMARK_ANIMATION_OUTPUT_RANGE = [ 0.1, 1, 0.1 ]; + +type Props = { + appointmentStartAt: string, + conferenceHasStarted: boolean, + isStaffMember: boolean, + isTestCall: boolean, + isWaitingAreaPageEnabled: boolean, + showPreCallMessage: boolean +}; + +type State = { + beforeAppointmentStart: boolean, + showPreCallMessage: boolean +}; + +class PreCallMessage extends Component { + + _interval; + + constructor(props: Props) { + super(props); + this.state = { + beforeAppointmentStart: false, + showPreCallMessage: props.showPreCallMessage + }; + this.animatedValue = new Animated.Value(0); + this._onClose = this._onClose.bind(this); + } + + componentDidMount() { + this._startTimer(); + this._animate(); + } + + _animate() { + this.animatedValue.setValue(0); + Animated.timing( + this.animatedValue, + { + toValue: 1, + duration: 2000, + easing: Easing.linear + } + ) + .start(() => this._animate()); + } + + _startTimer() { + const { appointmentStartAt, conferenceHasStarted } = this.props; + + if (appointmentStartAt && !conferenceHasStarted) { + const appointmentStartAtTimeStamp = getTimeStamp(appointmentStartAt); + const now = new Date().getTime(); + + if (now < appointmentStartAtTimeStamp) { + this.setState({ + beforeAppointmentStart: true + }, () => { + this._setInterval(appointmentStartAtTimeStamp); + }); + } + } + } + + _setInterval(appointmentStartTimeStamp) { + this._interval = setInterval(() => { + const { conferenceHasStarted } = this.props; + const now = new Date().getTime(); + + if ((appointmentStartTimeStamp < now) || conferenceHasStarted) { + this.setState({ + beforeAppointmentStart: false + }, () => { + this._stopTimer(); + }); + } + }, 1000); + } + + _stopTimer() { + if (this._interval) { + clearInterval(this._interval); + } + } + + _onClose() { + this.setState({ + showPreCallMessage: false + }); + } + + _getPreCallMessage() { + const { isTestCall, appointmentStartAt, isWaitingAreaPageEnabled } = this.props; + const { beforeAppointmentStart } = this.state; + + let header = 'Waiting for the other participant to join...'; + + let message = 'Sit back, relax and take a moment for yourself.'; + + if (beforeAppointmentStart && appointmentStartAt) { + const timeStamp = getTimeStamp(appointmentStartAt); + + header = `Your appointment will begin at ${getLocalizedDateFormatter(timeStamp) + .format('hh:mm A')}`; + } + + if (isTestCall) { + header = 'Testing your audio and video...'; + message = 'When you are done testing your audio and video, ' + + 'hang up to close this screen. Begin your online appointment from your upcoming appointments page.'; + } + + if (isWaitingAreaPageEnabled) { + header = 'Waiting for the practitioner...'; + } + + return ( + { header} + { message } + ); + } + + _renderCloseBtn() { + return ( + + ); + } + + render() { + const { conferenceHasStarted } = this.props; + const { showPreCallMessage } = this.state; + + if (conferenceHasStarted) { + return null; + } + + const animate = showPreCallMessage ? this.animatedValue.interpolate({ + inputRange: WATERMARK_ANIMATION_INPUT_RANGE, + outputRange: WATERMARK_ANIMATION_OUTPUT_RANGE + }) : 1; + + const image = (); + const backgroundColor = showPreCallMessage ? WAITING_MESSAGE_CONTIANER_BACKGROUND_COLOR : 'transparent'; + const paddingTop = isIPhoneX() ? 60 : 30; + + return ( + + { + image + } + + { + showPreCallMessage && this._getPreCallMessage() + } + { + showPreCallMessage && this._renderCloseBtn() + } + ); + } +} + +function _mapStateToProps(state) { + const { jwt } = state['features/base/jwt']; + const participantCount = getParticipantCount(state); + const remoteTracks = getRemoteTracks(state['features/base/tracks']); + const participantType = getLocalParticipantType(state); + const jwtPayload = jwt && jwtDecode(jwt); + const isWaitingAreaPageEnabled = isJaneWaitingAreaEnabled(state); + const appointmentStartAt = _.get(jwtPayload, 'context.start_at') || ''; + const showPreCallMessage = shouldShowPreCallMessage(state); + + return { + conferenceHasStarted: participantCount > 1 && remoteTracks.length > 0, + isTestCall: isJaneTestCall(state), + isStaffMember: participantType === 'StaffMember', + appointmentStartAt, + isWaitingAreaPageEnabled, + showPreCallMessage + }; +} + +export default connect(_mapStateToProps)(translate(PreCallMessage)); diff --git a/react/features/base/react/components/native/styles.js b/react/features/base/react/components/native/styles.js index 2847cb414a81..d5e49242d867 100644 --- a/react/features/base/react/components/native/styles.js +++ b/react/features/base/react/components/native/styles.js @@ -1,10 +1,12 @@ // @flow -import { BoxModel, ColorPalette } from '../../../styles'; +import { BoxModel, ColorPalette, JaneWeb } from '../../../styles'; const OVERLAY_FONT_COLOR = 'rgba(255, 255, 255, 0.6)'; const SECONDARY_ACTION_BUTTON_SIZE = 30; +export const WAITING_MESSAGE_CONTIANER_BACKGROUND_COLOR = 'rgba(98,98,110,0.75)'; + export const AVATAR_SIZE = 65; export const UNDERLAY_COLOR = 'rgba(255, 255, 255, 0.2)'; @@ -203,6 +205,49 @@ const SECTION_LIST_STYLES = { } }; +const WATING_MESSAGE_STYLES = { + preCallMessageContainer: { + paddingBottom: 20, + flexDirection: 'row', + width: '100%' + }, + + watermarkWrapper: { + width: 70, + justifyContent: 'flex-start', + alignItems: 'center' + }, + + messageWrapper: { + flex: 1 + }, + + preCallMessageHeader: { + fontSize: 15, + color: ColorPalette.white, + textAlign: 'left', + ...JaneWeb.boldFont + }, + + preCallMessageText: { + marginTop: 5, + fontSize: 12, + color: ColorPalette.white, + textAlign: 'left', + ...JaneWeb.boldFont + }, + + preCallMessageCloseBtn: { + width: 30 + }, + + watermark: { + aspectRatio: 300 / 248, + width: 50, + height: undefined + } +}; + export const TINTED_VIEW_DEFAULT = { backgroundColor: ColorPalette.appBackground, opacity: 0.8 @@ -214,5 +259,6 @@ export const TINTED_VIEW_DEFAULT = { */ export default { ...PAGED_LIST_STYLES, - ...SECTION_LIST_STYLES + ...SECTION_LIST_STYLES, + ...WATING_MESSAGE_STYLES }; diff --git a/react/features/base/styles/components/styles/ColorPalette.js b/react/features/base/styles/components/styles/ColorPalette.js index 1925f89340b0..8a09cde14022 100644 --- a/react/features/base/styles/components/styles/ColorPalette.js +++ b/react/features/base/styles/components/styles/ColorPalette.js @@ -3,6 +3,7 @@ */ const BLACK = '#111111'; +const JANE_DARK_COLOR = '#009097'; /** * The application's color palette. */ @@ -31,6 +32,10 @@ export const ColorPalette = { warning: 'rgb(215, 121, 118)', white: '#FFFFFF', + manatee: '#8E8E9D', + santasGray: '#A3A2B1', + manateeLight: '#9998A7', + /** * These are colors from the atlaskit to be used on mobile, when needed. * @@ -45,5 +50,12 @@ export const ColorPalette = { /** Jane */ jane: '#00c1ca', janeDarkGrey: '#333333', - janeLight: '#DAF6F7' + janeLight: '#DAF6F7', + + /** action button */ + btnBorder: '#DDDDDD', + disabledBtnBackground: '#DDDDDD', + btnTextDefault: '#333333', + + janeDarkColor: JANE_DARK_COLOR }; diff --git a/react/features/base/tracks/functions.js b/react/features/base/tracks/functions.js index 0810da23c76b..5e30e8b3ddc8 100644 --- a/react/features/base/tracks/functions.js +++ b/react/features/base/tracks/functions.js @@ -178,6 +178,29 @@ export function getLocalTracks(tracks, includePending = false) { return tracks.filter(t => t.local && (t.jitsiTrack || includePending)); } +/** + * Returns an array containing the remote tracks with or without a (valid) + * {@code JitsiTrack}. + * + * @param {Track[]} tracks - An array containing all remote tracks. + * @param {boolean} [includePending] - Indicates whether a remote track is to be + * returned if it is still pending. A remote track is pending if + * {@code getUserMedia} is still executing to create it and, consequently, its + * {@code jitsiTrack} property is {@code undefined}. By default a pending remote + * track is not returned. + * @returns {Track[]} + */ +export function getRemoteTracks(tracks, includePending = false) { + // XXX A remote track is considered ready only once it has its `jitsiTrack` + // property set by the `TRACK_ADDED` action. Until then there is a stub + // added just before the `getUserMedia` call with a cancellable + // `gumInProgress` property which then can be used to destroy the track that + // has not yet been added to the redux store. Once GUM is cancelled, it will + // never make it to the store nor there will be any + // `TRACK_ADDED`/`TRACK_REMOVED` actions dispatched for it. + return tracks.filter(t => !t.local && (t.jitsiTrack || includePending)); +} + /** * Returns local video track. * diff --git a/react/features/base/util/httpUtils.js b/react/features/base/util/httpUtils.js index 9dfa598dd57c..321aed9ed3bb 100644 --- a/react/features/base/util/httpUtils.js +++ b/react/features/base/util/httpUtils.js @@ -42,3 +42,21 @@ export function doGetJSON(url, retry) { return fetchPromise; } + + +// send beacon to jane +export function sendBeaconToJaneRN(url, data, errorMsg = null) { + return fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain; charset=UTF-8' + }, + body: data + }) + .then(res => { + if (!res.ok) { + const errorMsg = errorMsg ? errorMsg : res.statusText; + throw Error(errorMsg); + } + }); +} diff --git a/react/features/conference/components/native/Conference.js b/react/features/conference/components/native/Conference.js index e904cffa5873..348d01652367 100644 --- a/react/features/conference/components/native/Conference.js +++ b/react/features/conference/components/native/Conference.js @@ -28,6 +28,7 @@ import { BackButtonRegistry } from '../../../mobile/back-button'; import { AddPeopleDialog, CalleeInfoContainer } from '../../../invite'; import { Captions } from '../../../subtitles'; import { isToolboxVisible, setToolboxVisible, Toolbox } from '../../../toolbox'; +import JaneWaitingArea from '../../../jane-waiting-area-native/components/JaneWaitingArea.native'; import { AbstractConference, @@ -38,6 +39,8 @@ import NavigationBar from './NavigationBar'; import styles, { NAVBAR_GRADIENT_COLORS } from './styles'; import type { AbstractProps } from '../AbstractConference'; +import { getLocalParticipantFromJwt, getLocalParticipantType +} from '../../../base/participants'; /** * The type of the React {@code Component} props of {@link Conference}. @@ -109,7 +112,8 @@ type Props = AbstractProps & { /** * The redux {@code dispatch} function. */ - dispatch: Function + dispatch: Function, + janeWaitingAreaEnabled: boolean }; /** @@ -152,8 +156,10 @@ class Conference extends AbstractConference { * @returns {void} */ componentWillUnmount() { + // Tear handling any hardware button presses for back navigation down. BackButtonRegistry.removeListener(this._onHardwareBackPress); + } /** @@ -252,7 +258,8 @@ class Conference extends AbstractConference { _largeVideoParticipantId, _reducedUI, _shouldDisplayTileView, - _toolboxVisible + _toolboxVisible, + _janeWaitingAreaEnabled } = this.props; const showGradient = _toolboxVisible; const applyGradientStretching = _filmstripVisible && isNarrowAspectRatio(this) && !_shouldDisplayTileView; @@ -315,8 +322,9 @@ class Conference extends AbstractConference { - { _shouldDisplayTileView || } - + {_shouldDisplayTileView || } + {_janeWaitingAreaEnabled && } {/* * The Toolbox is in a stacking layer below the Filmstrip. */} @@ -342,7 +350,6 @@ class Conference extends AbstractConference { - { this._renderConferenceNotification() } ); @@ -431,6 +438,9 @@ function _mapStateToProps(state) { joining, leaving } = state['features/base/conference']; + const { + janeWaitingAreaEnabled + } = state['features/jane-waiting-area-native']; const { reducedUI } = state['features/base/responsive-ui']; // XXX There is a window of time between the successful establishment of the @@ -444,6 +454,7 @@ function _mapStateToProps(state) { // are leaving one. const connecting_ = connecting || (connection && (joining || (!conference && !leaving))); + const { jwt } = state['features/base/jwt']; return { ...abstractMapStateToProps(state), @@ -500,7 +511,11 @@ function _mapStateToProps(state) { * @private * @type {boolean} */ - _toolboxVisible: isToolboxVisible(state) + _toolboxVisible: isToolboxVisible(state), + _janeWaitingAreaEnabled: janeWaitingAreaEnabled, + _jwt: jwt, + _participantType: getLocalParticipantType(state), + _participant: getLocalParticipantFromJwt(state) }; } diff --git a/react/features/filmstrip/components/native/TileView.js b/react/features/filmstrip/components/native/TileView.js index 4171bf67b837..dc86b8daaea1 100644 --- a/react/features/filmstrip/components/native/TileView.js +++ b/react/features/filmstrip/components/native/TileView.js @@ -21,6 +21,8 @@ import { import Thumbnail from './Thumbnail'; import styles from './styles'; +import PreCallMessage + from '../../../base/react/components/native/PreCallMessage'; /** * The type of the React {@link Component} props of {@link TileView}. @@ -134,6 +136,7 @@ class TileView extends Component { return ( + { + + const containerStyle = props.disabled ? styles.disabledButtonContainer : styles.joinButtonContainer; + const titleStyle = props.disabled ? styles.disabledButtonText : styles.joinButtonText; + + return ( + + { + props.title + } + + ); +}; diff --git a/react/features/jane-waiting-area-native/components/DialogBox.native.js b/react/features/jane-waiting-area-native/components/DialogBox.native.js new file mode 100644 index 000000000000..6e8ea2133e5f --- /dev/null +++ b/react/features/jane-waiting-area-native/components/DialogBox.native.js @@ -0,0 +1,374 @@ +// @flow +/* eslint-disable require-jsdoc, react/no-multi-comp, react/jsx-handler-names*/ + +import React, { Component } from 'react'; +import { Image, Linking, Text, View, Clipboard } from 'react-native'; +import { connect } from '../../base/redux'; +import { + checkLocalParticipantCanJoin, + updateParticipantReadyStatus +} from '../functions'; +import { getLocalParticipantFromJwt, getLocalParticipantType } from '../../base/participants'; +import jwtDecode from 'jwt-decode'; +import moment from 'moment'; +import { + enableJaneWaitingArea, + setJaneWaitingAreaAuthState, + updateRemoteParticipantsStatuses +} from '../actions'; +import { getLocalizedDateFormatter } from '../../base/i18n'; +import { connect as startConference } from '../../base/connection'; +import styles from './styles'; +import { ActionButton } from './ActionButton.native'; +import { WebView } from 'react-native-webview'; +import _ from 'lodash'; +import { createWaitingAreaPageEvent, sendAnalytics } from '../../analytics'; + +type DialogTitleProps = { + participantType: string, + localParticipantCanJoin: boolean, + authState: string +} + +type DialogBoxProps = { + joinConferenceAction: Function, + startConferenceAction: Function, + enableJaneWaitingAreaAction: Function, + jwtPayload: Object, + jwt: string, + participantType: string, + updateRemoteParticipantsStatusesAction: Function, + setJaneWaitingAreaAuthStateAction: Function, + locationURL: string, + remoteParticipantsStatuses: Array, + authState: string +}; + +type SocketWebViewProps = { + onError: Function, + onMessageUpdate: Function, + locationURL: string +} + +const getWebViewUrl = locationURL => { + let uri = locationURL.href; + + uri = `${uri}&RNsocket=true`; + + return uri; +}; + +const SocketWebView = (props: SocketWebViewProps) => { + const injectedJavascript = `(function() { + window.postMessage = function(data) { + window.ReactNativeWebView.postMessage(data); + }; + })()`; + + return ( + + ); +}; + +class DialogBox extends Component { + + constructor(props) { + super(props); + this._joinConference = this._joinConference.bind(this); + this._webviewOnError = this._webviewOnError.bind(this); + this._return = this._return.bind(this); + this._onMessageUpdate = this._onMessageUpdate.bind(this); + } + + componentDidMount() { + const { jwt } = this.props; + + Clipboard.setString(''); + sendAnalytics( + createWaitingAreaPageEvent('loaded', undefined)); + updateParticipantReadyStatus(jwt, 'waiting'); + } + + + _webviewOnError(error) { + try { + throw new Error(error); + } catch (e) { + sendAnalytics( + createWaitingAreaPageEvent('webview.error', { + error + })); + this._joinConference(); + } + } + + componentWillUnmount(): * { + const { updateRemoteParticipantsStatusesAction } = this.props; + + updateRemoteParticipantsStatusesAction([]); + } + + _joinConference() { + const { startConferenceAction, enableJaneWaitingAreaAction, jwt } = this.props; + + updateParticipantReadyStatus(jwt, 'joined'); + enableJaneWaitingAreaAction(false); + startConferenceAction(); + } + + _getStartDate() { + const { jwtPayload } = this.props; + const startAt = _.get(jwtPayload, 'context.start_at') ?? ''; + + if (startAt) { + return ( + { + getLocalizedDateFormatter(startAt) + .format('MMMM D, YYYY') + } + ); + } + + return null; + } + + _getStartTimeAndEndTime() { + const { jwtPayload } = this.props; + const startAt = _.get(jwtPayload, 'context.start_at') ?? ''; + const endAt = _.get(jwtPayload, 'context.end_at') ?? ''; + + if (!startAt || !endAt) { + return null; + } + + return ( + { + `${getLocalizedDateFormatter(startAt) + .format('h:mm')} - ${getLocalizedDateFormatter(endAt) + .format('h:mm A')}` + } + ); + } + + _getDuration() { + const { jwtPayload } = this.props; + const startAt = _.get(jwtPayload, 'context.start_at') ?? ''; + const endAt = _.get(jwtPayload, 'context.end_at') ?? ''; + + if (!startAt || !endAt) { + return null; + } + const duration = getLocalizedDateFormatter(endAt) + .valueOf() - getLocalizedDateFormatter(startAt) + .valueOf(); + + + return ( + { + `${moment.duration(duration) + .asMinutes()} Minutes` + } + ); + } + + _getBtnText() { + const { participantType } = this.props; + + return participantType === 'StaffMember' ? 'Admit Client' : 'Begin'; + } + + _return() { + const { jwtPayload, jwt } = this.props; + const leaveWaitingAreaUrl = _.get(jwtPayload, 'context.leave_waiting_area_url') ?? ''; + + sendAnalytics( + createWaitingAreaPageEvent('return.button', { + event: 'clicked' + })); + updateParticipantReadyStatus(jwt, 'left'); + Linking.openURL(leaveWaitingAreaUrl); + } + + _parseJsonMessage(string) { + try { + return string && JSON.parse(string) && JSON.parse(string).message; + } catch (e) { + return null; + } + } + + _onMessageUpdate(event) { + const { updateRemoteParticipantsStatusesAction, setJaneWaitingAreaAuthStateAction } = this.props; + const webViewEvent = this._parseJsonMessage(event.nativeEvent.data); + const remoteParticipantsStatuses = webViewEvent && webViewEvent.remoteParticipantsStatuses ?? null; + + console.log(webViewEvent, 'incoming web view event'); + + if (remoteParticipantsStatuses) { + updateRemoteParticipantsStatusesAction(remoteParticipantsStatuses); + } + + if (webViewEvent && webViewEvent.error) { + sendAnalytics( + createWaitingAreaPageEvent('webview.error', { + error: webViewEvent.error + })); + if (webViewEvent.error.error === 'Signature has expired') { + setJaneWaitingAreaAuthStateAction('failed'); + } else { + this._joinConference(); + } + } + } + + render() { + const { + participantType, + jwtPayload, + locationURL, + remoteParticipantsStatuses, + authState + } = this.props; + const localParticipantCanJoin = checkLocalParticipantCanJoin(remoteParticipantsStatuses, participantType); + + return ( + + + + + + + { + + } + { + + } + + + { + jwtPayload && jwtPayload.context && jwtPayload.context.treatment + } + + + { + jwtPayload && jwtPayload.context && jwtPayload.context.practitioner_name + } + + { + this._getStartDate() + } + { + this._getStartTimeAndEndTime() + } + { + this._getDuration() + } + + + + + { authState !== 'failed' + && } + { + authState === 'failed' + && + } + + + + ); + + } +} + +function mapStateToProps(state): Object { + const { jwt } = state['features/base/jwt']; + const jwtPayload = jwt && jwtDecode(jwt) ?? null; + const participant = getLocalParticipantFromJwt(state); + const participantType = getLocalParticipantType(state); + const { locationURL } = state['features/base/connection']; + const { remoteParticipantsStatuses, authState } = state['features/jane-waiting-area-native']; + + return { + jwt, + jwtPayload, + participantType, + participant, + locationURL, + remoteParticipantsStatuses, + authState + }; +} + +const mapDispatchToProps = { + startConferenceAction: startConference, + enableJaneWaitingAreaAction: enableJaneWaitingArea, + updateRemoteParticipantsStatusesAction: updateRemoteParticipantsStatuses, + setJaneWaitingAreaAuthStateAction: setJaneWaitingAreaAuthState +}; + +export default connect(mapStateToProps, mapDispatchToProps)(DialogBox); + +const DialogTitleHeader = (props: DialogTitleProps) => { + const { participantType, authState, localParticipantCanJoin } = props; + const tokenExpiredHeader = 'Your appointment booking has expired'; + let header; + + if (participantType === 'StaffMember') { + if (localParticipantCanJoin) { + header = 'Your patient is ready to begin the session.'; + } else { + header = 'Waiting for your client...'; + } + } else if (localParticipantCanJoin) { + header = 'Your practitioner is ready to begin the session.'; + } else { + header = 'Your practitioner will let you into the session when ready...'; + } + + return ({ authState === 'failed' ? tokenExpiredHeader : header }); +}; + +const DialogTitleMsg = (props: DialogTitleProps) => { + const { participantType, authState, localParticipantCanJoin } = props; + let message; + + if (!localParticipantCanJoin) { + message = 'Test your audio and video while you wait.'; + } else if (participantType === 'StaffMember') { + message = 'When you are ready to begin, click on button below to admit your client into the video session.'; + } else { + message = ''; + } + + return ({ authState === 'failed' ? '' : message }); +}; diff --git a/react/features/jane-waiting-area-native/components/JaneWaitingArea.native.js b/react/features/jane-waiting-area-native/components/JaneWaitingArea.native.js new file mode 100644 index 000000000000..c052ca0e16af --- /dev/null +++ b/react/features/jane-waiting-area-native/components/JaneWaitingArea.native.js @@ -0,0 +1,31 @@ +// @flow +/* eslint-disable require-jsdoc*/ +import React, { Component } from 'react'; +import { connect } from '../../base/redux'; +import { translate } from '../../base/i18n'; +import DialogBox from './DialogBox.native'; + +type Props = { + appstate: Object, + jwt: string +}; + +class JaneWaitingAreaNative extends Component { + + render() { + return (this.props.appstate && this.props.appstate.appState === 'active' + && ) || null; + } +} + +function mapStateToProps(state): Object { + const appstate = state['features/background']; + const { jwt } = state['features/base/jwt']; + + return { + appstate, + jwt + }; +} + +export default connect(mapStateToProps)(translate(JaneWaitingAreaNative)); diff --git a/react/features/jane-waiting-area-native/components/styles.js b/react/features/jane-waiting-area-native/components/styles.js new file mode 100644 index 000000000000..9b375fa646f6 --- /dev/null +++ b/react/features/jane-waiting-area-native/components/styles.js @@ -0,0 +1,95 @@ +import { JaneWeb, ColorPalette, BoxModel } from '../../base/styles'; + +export default { + janeWaitingAreaContainer: { + width: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 30 + }, + janeWaitingAreaDialogBoxWrapper: { + width: '80%', + backgroundColor: 'white', + borderRadius: 5, + flexDirection: 'column' + }, + janeWaitingAreaDialogBoxInnerWrapper: { + flexDirection: 'row', + paddingTop: 20, + paddingBottom: 15 + }, + logoWrapper: { + width: '25%', + alignItems: 'center' + }, + logo: { + marginBottom: 4 * BoxModel.margin, + width: '50%', + height: undefined, + aspectRatio: 1437 / 1188 + }, + messageWrapper: { + width: '75%', + alignItems: 'flex-start', + paddingRight: 15 + }, + infoDetailContainer: { + marginTop: 20 + }, + title: { + fontSize: 14, + color: ColorPalette.santasGray, + ...JaneWeb.semiBoldFont + }, + titleMsg: { + fontSize: 12, + marginTop: 20, + color: ColorPalette.manateeLight, + ...JaneWeb.regularFont + }, + msgText: { + fontSize: 12, + color: ColorPalette.manatee, + ...JaneWeb.boldFont + }, + actionButtonContainer: { + width: 150, + height: 35, + backgroundColor: ColorPalette.white, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 5, + borderColor: ColorPalette.btnBorder, + borderWidth: 1 + }, + actionBtnTitle: { + color: ColorPalette.btnTextDefault, + fontSize: 14, + ...JaneWeb.regularFont + }, + actionButtonWrapper: { + width: '100%', + height: 60, + justifyContent: 'center', + alignItems: 'center', + borderTopWidth: 1, + borderColor: ColorPalette.btnBorder + }, + joinButtonContainer: { + backgroundColor: ColorPalette.jane + }, + joinButtonText: { + color: ColorPalette.white + }, + disabledButtonContainer: { + backgroundColor: ColorPalette.disabledBtnBackground + }, + disabledButtonText: { + color: ColorPalette.manatee + }, + socketView: { + height: 0, + width: 0 + } +}; diff --git a/react/features/jane-waiting-area-native/functions.js b/react/features/jane-waiting-area-native/functions.js new file mode 100644 index 000000000000..a53969bc0dec --- /dev/null +++ b/react/features/jane-waiting-area-native/functions.js @@ -0,0 +1,58 @@ +// @flow +/* eslint-disable require-jsdoc,max-len, no-undef*/ +import jwtDecode from 'jwt-decode'; +import _ from 'lodash'; +import { + createWaitingAreaPageEvent, + sendAnalytics +} from '../analytics'; + +export function isJaneWaitingAreaEnabled(state: Object): boolean { + const { jwt } = state['features/base/jwt']; + const jwtPayload = jwt && jwtDecode(jwt) ?? null; + const janeWaitingAreaEnabled = _.get(jwtPayload, 'context.waiting_area_enabled') ?? false; + + return state['features/base/config'].janeWaitingAreaEnabled || janeWaitingAreaEnabled; +} + +export function updateParticipantReadyStatus(jwt: string, status: string): void { + try { + const jwtPayload = jwt && jwtDecode(jwt) ?? {}; + const updateParticipantStatusUrl = _.get(jwtPayload, 'context.update_participant_status_url') ?? ''; + const info = { status }; + + sendAnalytics(createWaitingAreaPageEvent( + 'participant.status.changed', + { status } + )); + + return fetch(updateParticipantStatusUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + 'jwt': jwt, + 'info': info + }) + }).then(res => { + if (!res.ok) { + throw Error('Can not update current participant\'s status.'); + } + }); + } catch (error) { + sendAnalytics(createWaitingAreaPageEvent('error', { error })); + console.error(error); + } +} + +export function checkLocalParticipantCanJoin(remoteParticipantsStatuses, participantType) { + return remoteParticipantsStatuses && remoteParticipantsStatuses.length > 0 && remoteParticipantsStatuses.some(v => { + if (participantType === 'StaffMember') { + return v.info && (v.info.status === 'joined' || v.info.status === 'waiting'); + } + + return v.info && v.info.status === 'joined'; + + }) ?? false; +} diff --git a/react/features/jane-waiting-area-native/index.js b/react/features/jane-waiting-area-native/index.js new file mode 100644 index 000000000000..8be933c84a0a --- /dev/null +++ b/react/features/jane-waiting-area-native/index.js @@ -0,0 +1,6 @@ +export * from './actions'; +export * from './functions'; + +export { default as JaneWaitingArea } from './components/JaneWaitingArea'; + +import './reducer'; diff --git a/react/features/jane-waiting-area-native/logger.js b/react/features/jane-waiting-area-native/logger.js new file mode 100644 index 000000000000..25381223242f --- /dev/null +++ b/react/features/jane-waiting-area-native/logger.js @@ -0,0 +1,5 @@ +// @flow + +import { getLogger } from '../../../../janecode/video-chat-web/react/features/base/logging/functions'; + +export default getLogger('features/jane-waiting-area-native'); diff --git a/react/features/jane-waiting-area-native/reducer.js b/react/features/jane-waiting-area-native/reducer.js new file mode 100644 index 000000000000..4db97991e6c8 --- /dev/null +++ b/react/features/jane-waiting-area-native/reducer.js @@ -0,0 +1,41 @@ +import { ReducerRegistry } from '../base/redux'; + +import { + ENABLE_JANE_WAITING_AREA_PAGE, + SET_JANE_WAITING_AREA_AUTH_STATE, + UPDATE_REMOTE_PARTICIPANT_STATUSES +} from './actionTypes'; + +const DEFAULT_STATE = { + janeWaitingAreaEnabled: false, + remoteParticipantsStatuses: [], + authState: '' +}; + +ReducerRegistry.register( + 'features/jane-waiting-area-native', (state = DEFAULT_STATE, action) => { + switch (action.type) { + case ENABLE_JANE_WAITING_AREA_PAGE: + return { + ...state, + janeWaitingAreaEnabled: action.janeWaitingAreaEnabled + }; + case SET_JANE_WAITING_AREA_AUTH_STATE: { + return { + ...state, + authState: action.value + }; + } + case UPDATE_REMOTE_PARTICIPANT_STATUSES: { + return { + ...state, + remoteParticipantsStatuses: action.value + }; + } + + default: + return state; + } + } +); + diff --git a/react/features/large-video/components/LargeVideo.native.js b/react/features/large-video/components/LargeVideo.native.js index 35fc9673c6ee..372d3e031b46 100644 --- a/react/features/large-video/components/LargeVideo.native.js +++ b/react/features/large-video/components/LargeVideo.native.js @@ -8,6 +8,8 @@ import { connect } from '../../base/redux'; import { DimensionsDetector } from '../../base/responsive-ui'; import { StyleType } from '../../base/styles'; +import PreCallMessage + from '../../base/react/components/native/PreCallMessage.js'; import { AVATAR_SIZE } from './styles'; /** @@ -137,6 +139,7 @@ class LargeVideo extends Component { useConnectivityInfoLabel = { useConnectivityInfoLabel } zOrder = { 0 } zoomEnabled = { true } /> + ); } @@ -153,6 +156,7 @@ class LargeVideo extends Component { * }} */ function _mapStateToProps(state) { + return { _participantId: state['features/large-video'].participantId, _styles: ColorSchemeRegistry.get(state, 'LargeVideo') diff --git a/react/features/toolbox/components/HangupButton.js b/react/features/toolbox/components/HangupButton.js index c182dba2346e..4dfcc111273d 100644 --- a/react/features/toolbox/components/HangupButton.js +++ b/react/features/toolbox/components/HangupButton.js @@ -9,6 +9,7 @@ import { translate } from '../../base/i18n'; import { connect } from '../../base/redux'; import { AbstractHangupButton } from '../../base/toolbox'; import type { AbstractButtonProps } from '../../base/toolbox'; +import { updateParticipantReadyStatus } from '../../jane-waiting-area-native'; /** * The type of the React {@code Component} props of {@link HangupButton}. @@ -18,7 +19,9 @@ type Props = AbstractButtonProps & { /** * The redux {@code dispatch} function. */ - dispatch: Function + dispatch: Function, + appstate: string, + jwt: string }; /** @@ -48,6 +51,7 @@ class HangupButton extends AbstractHangupButton { // FIXME: these should be unified. if (navigator.product === 'ReactNative') { this.props.dispatch(appNavigate(undefined)); + updateParticipantReadyStatus(props.jwt, 'left'); } else { this.props.dispatch(disconnect(true)); } @@ -66,4 +70,23 @@ class HangupButton extends AbstractHangupButton { } } -export default translate(connect()(HangupButton)); +/** + * Maps part of the Redux state to the props of the component. + * + * @param {Object} state - The Redux state. + * @returns {{ + * appstate: string, + * jwtPayload: Object + * }} + */ +function mapStateToProps(state): Object { + const appstate = state['features/background']; + const { jwt } = state['features/base/jwt']; + + return { + appstate, + jwt + }; +} + +export default translate(connect(mapStateToProps)(HangupButton)); diff --git a/react/features/toolbox/components/native/Toolbox.js b/react/features/toolbox/components/native/Toolbox.js index c3ceb9b7c021..2c327664f566 100644 --- a/react/features/toolbox/components/native/Toolbox.js +++ b/react/features/toolbox/components/native/Toolbox.js @@ -1,7 +1,7 @@ // @flow import React, { PureComponent } from 'react'; -import { View } from 'react-native'; +import { View, Button } from 'react-native'; import { ColorSchemeRegistry } from '../../../base/color-scheme'; import { CHAT_ENABLED, getFeatureFlag } from '../../../base/flags'; @@ -15,7 +15,6 @@ import { isToolboxVisible } from '../../functions'; import AudioMuteButton from '../AudioMuteButton'; import HangupButton from '../HangupButton'; - import OverflowMenuButton from './OverflowMenuButton'; import styles from './styles'; import VideoMuteButton from '../VideoMuteButton'; @@ -43,7 +42,8 @@ type Props = { /** * The redux {@code dispatch} function. */ - dispatch: Function + dispatch: Function, + _janeWaitingAreaEnabled: boolean }; /** @@ -105,7 +105,7 @@ class Toolbox extends PureComponent { * @returns {React$Node} */ _renderToolbar() { - const { _chatEnabled, _styles } = this.props; + const { _chatEnabled, _styles, _janeWaitingAreaEnabled } = this.props; const { buttonStyles, buttonStylesBorderless, hangupButtonStyles, toggledButtonStyles } = _styles; return ( @@ -113,7 +113,7 @@ class Toolbox extends PureComponent { pointerEvents = 'box-none' style = { styles.toolbar }> { - _chatEnabled + _chatEnabled && !_janeWaitingAreaEnabled && { - + toggledStyles = { toggledButtonStyles } />} ); } @@ -156,10 +156,13 @@ class Toolbox extends PureComponent { * }} */ function _mapStateToProps(state: Object): Object { + const { janeWaitingAreaEnabled } = state['features/jane-waiting-area-native']; + return { _chatEnabled: getFeatureFlag(state, CHAT_ENABLED, true), _styles: ColorSchemeRegistry.get(state, 'Toolbox'), - _visible: isToolboxVisible(state) + _visible: isToolboxVisible(state), + _janeWaitingAreaEnabled: janeWaitingAreaEnabled }; }