This commit is contained in:
23
react/features/mobile/audio-mode/actionTypes.ts
Normal file
23
react/features/mobile/audio-mode/actionTypes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The type of redux action to set Audio Mode device list.
|
||||
*
|
||||
* {
|
||||
* type: _SET_AUDIOMODE_DEVICES,
|
||||
* devices: Array
|
||||
* }
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
export const _SET_AUDIOMODE_DEVICES = '_SET_AUDIOMODE_DEVICES';
|
||||
|
||||
/**
|
||||
* The type of redux action to set Audio Mode module's subscriptions.
|
||||
*
|
||||
* {
|
||||
* type: _SET_AUDIOMODE_SUBSCRIPTIONS,
|
||||
* subscriptions: Array|undefined
|
||||
* }
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
export const _SET_AUDIOMODE_SUBSCRIPTIONS = '_SET_AUDIOMODE_SUBSCRIPTIONS';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { openSheet } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconVolumeUp } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
|
||||
import AudioRoutePickerDialog from './AudioRoutePickerDialog';
|
||||
|
||||
/**
|
||||
* Implements an {@link AbstractButton} to open the audio device list.
|
||||
*/
|
||||
class AudioDeviceToggleButton extends AbstractButton<AbstractButtonProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.audioRoute';
|
||||
override icon = IconVolumeUp;
|
||||
override label = 'toolbar.accessibilityLabel.audioRoute';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
this.props.dispatch(openSheet(AudioRoutePickerDialog));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default translate(connect()(AudioDeviceToggleButton));
|
||||
@@ -0,0 +1,329 @@
|
||||
import { sortBy } from 'lodash-es';
|
||||
import React, { Component } from 'react';
|
||||
import { NativeModules, Text, TextStyle, TouchableHighlight, View, ViewStyle } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { hideSheet } from '../../../base/dialog/actions';
|
||||
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
|
||||
import { bottomSheetStyles } from '../../../base/dialog/components/native/styles';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import {
|
||||
IconBluetooth,
|
||||
IconCar,
|
||||
IconDeviceHeadphone,
|
||||
IconPhoneRinging,
|
||||
IconVolumeUp
|
||||
} from '../../../base/icons/svg';
|
||||
import { StyleType } from '../../../base/styles/functions.any';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
const { AudioMode } = NativeModules;
|
||||
|
||||
/**
|
||||
* Type definition for a single entry in the device list.
|
||||
*/
|
||||
interface IDevice {
|
||||
|
||||
/**
|
||||
* Name of the icon which will be rendered on the right.
|
||||
*/
|
||||
icon: Function;
|
||||
|
||||
/**
|
||||
* True if the element is selected (will be highlighted in blue),
|
||||
* false otherwise.
|
||||
*/
|
||||
selected: boolean;
|
||||
|
||||
/**
|
||||
* Text which will be rendered in the row.
|
||||
*/
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* Device type.
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Unique device ID.
|
||||
*/
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Raw" device, as returned by native.
|
||||
*/
|
||||
export interface IRawDevice {
|
||||
|
||||
/**
|
||||
* Display name for the device.
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Is this device selected?
|
||||
*/
|
||||
selected: boolean;
|
||||
|
||||
/**
|
||||
* Device type.
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Unique device ID.
|
||||
*/
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code AudioRoutePickerDialog}'s React {@code Component} prop types.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Object describing available devices.
|
||||
*/
|
||||
_devices: Array<IRawDevice>;
|
||||
|
||||
/**
|
||||
* Used for hiding the dialog when the selection was completed.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code AudioRoutePickerDialog}'s React {@code Component} state types.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* Array of available devices.
|
||||
*/
|
||||
devices: Array<IDevice>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps each device type to a display name and icon.
|
||||
*/
|
||||
const deviceInfoMap = {
|
||||
BLUETOOTH: {
|
||||
icon: IconBluetooth,
|
||||
text: 'audioDevices.bluetooth',
|
||||
type: 'BLUETOOTH'
|
||||
},
|
||||
CAR: {
|
||||
icon: IconCar,
|
||||
text: 'audioDevices.car',
|
||||
type: 'CAR'
|
||||
},
|
||||
EARPIECE: {
|
||||
icon: IconPhoneRinging,
|
||||
text: 'audioDevices.phone',
|
||||
type: 'EARPIECE'
|
||||
},
|
||||
HEADPHONES: {
|
||||
icon: IconDeviceHeadphone,
|
||||
text: 'audioDevices.headphones',
|
||||
type: 'HEADPHONES'
|
||||
},
|
||||
SPEAKER: {
|
||||
icon: IconVolumeUp,
|
||||
text: 'audioDevices.speaker',
|
||||
type: 'SPEAKER'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements a React {@code Component} which prompts the user when a password
|
||||
* is required to join a conference.
|
||||
*/
|
||||
class AudioRoutePickerDialog extends Component<IProps, IState> {
|
||||
override state = {
|
||||
/**
|
||||
* Available audio devices, it will be set in
|
||||
* {@link #getDerivedStateFromProps()}.
|
||||
*/
|
||||
devices: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#getDerivedStateFromProps()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
static getDerivedStateFromProps(props: IProps) {
|
||||
const { _devices: devices, t } = props;
|
||||
|
||||
if (!devices) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const audioDevices = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const infoMap = deviceInfoMap[device.type as keyof typeof deviceInfoMap];
|
||||
|
||||
// Skip devices with unknown type.
|
||||
if (!infoMap) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = t(infoMap.text);
|
||||
|
||||
// iOS provides descriptive names for these, use it.
|
||||
if ((device.type === 'BLUETOOTH' || device.type === 'CAR') && device.name) {
|
||||
text = device.name;
|
||||
}
|
||||
|
||||
if (infoMap) {
|
||||
const info = {
|
||||
...infoMap,
|
||||
selected: Boolean(device.selected),
|
||||
text,
|
||||
uid: device.uid
|
||||
};
|
||||
|
||||
audioDevices.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure devices is alphabetically sorted.
|
||||
return {
|
||||
devices: sortBy(audioDevices, 'text')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new {@code PasswordRequiredPrompt} instance.
|
||||
*
|
||||
* @param {IProps} props - The read-only React {@code Component} props with
|
||||
* which the new instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Trigger an initial update.
|
||||
AudioMode.updateDeviceList?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns a function which handles the selection of a device
|
||||
* on the sheet. The selected device will be used by {@code AudioMode}.
|
||||
*
|
||||
* @param {IDevice} device - Object representing the selected device.
|
||||
* @private
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onSelectDeviceFn(device: IDevice) {
|
||||
return () => {
|
||||
this.props.dispatch(hideSheet());
|
||||
AudioMode.setAudioDevice(device.uid || device.type);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single device.
|
||||
*
|
||||
* @param {IDevice} device - Object representing a single device.
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderDevice(device: IDevice) {
|
||||
const { icon, selected, text } = device;
|
||||
const selectedStyle = selected ? styles.selectedText : {};
|
||||
const borderRadiusHighlightStyles = {
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16
|
||||
};
|
||||
const speakerDeviceIsNotSelected = device.type !== 'SPEAKER';
|
||||
|
||||
return (
|
||||
<TouchableHighlight
|
||||
key = { device.type }
|
||||
onPress = { this._onSelectDeviceFn(device) }
|
||||
style = { speakerDeviceIsNotSelected && borderRadiusHighlightStyles }
|
||||
underlayColor = { BaseTheme.palette.ui04 } >
|
||||
<View style = { styles.deviceRow as ViewStyle } >
|
||||
<Icon
|
||||
src = { icon }
|
||||
style = { [ styles.deviceIcon, bottomSheetStyles.buttons.iconStyle, selectedStyle
|
||||
] as StyleType[] } />
|
||||
<Text
|
||||
style = { [ styles.deviceText, bottomSheetStyles.buttons.labelStyle, selectedStyle
|
||||
] as TextStyle[] } >
|
||||
{ text }
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a "fake" device row indicating there are no devices.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderNoDevices() {
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { styles.deviceRow as ViewStyle } >
|
||||
<Icon
|
||||
src = { deviceInfoMap.SPEAKER.icon }
|
||||
style = { [ styles.deviceIcon, bottomSheetStyles.buttons.iconStyle ] as StyleType[] } />
|
||||
<Text style = { [ styles.deviceText, bottomSheetStyles.buttons.labelStyle ] as TextStyle[] } >
|
||||
{ t('audioDevices.none') }
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { devices } = this.state;
|
||||
let content;
|
||||
|
||||
if (devices.length === 0) {
|
||||
content = this._renderNoDevices();
|
||||
} else {
|
||||
content = this.state.devices.map(this._renderDevice, this);
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheet>
|
||||
{ content }
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_devices: state['features/mobile/audio-mode'].devices
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AudioRoutePickerDialog));
|
||||
46
react/features/mobile/audio-mode/components/styles.ts
Normal file
46
react/features/mobile/audio-mode/components/styles.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { MD_ITEM_HEIGHT } from '../../../base/dialog/components/native/styles';
|
||||
import { createStyleSheet } from '../../../base/styles/functions.any';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
/**
|
||||
* The React {@code Component} styles of {@code AudioRoutePickerDialog}.
|
||||
*
|
||||
* It uses a {@code BottomSheet} and these have been implemented as per the
|
||||
* Material Design guidelines:
|
||||
* {@link https://material.io/guidelines/components/bottom-sheets.html}.
|
||||
*/
|
||||
export default createStyleSheet({
|
||||
/**
|
||||
* Base style for each row.
|
||||
*/
|
||||
deviceRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
height: MD_ITEM_HEIGHT,
|
||||
marginLeft: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for the {@code Icon} element in a row.
|
||||
*/
|
||||
deviceIcon: {
|
||||
color: BaseTheme.palette.icon01,
|
||||
fontSize: BaseTheme.spacing[4]
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for the {@code Text} element in a row.
|
||||
*/
|
||||
deviceText: {
|
||||
color: BaseTheme.palette.text01,
|
||||
fontSize: 16,
|
||||
marginLeft: BaseTheme.spacing[5]
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for a row which is marked as selected.
|
||||
*/
|
||||
selectedText: {
|
||||
color: BaseTheme.palette.action01
|
||||
}
|
||||
});
|
||||
3
react/features/mobile/audio-mode/logger.ts
Normal file
3
react/features/mobile/audio-mode/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../../base/logging/functions';
|
||||
|
||||
export default getLogger('features/mobile/audio-mode');
|
||||
176
react/features/mobile/audio-mode/middleware.ts
Normal file
176
react/features/mobile/audio-mode/middleware.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app/actionTypes';
|
||||
import { SET_AUDIO_ONLY } from '../../base/audio-only/actionTypes';
|
||||
import {
|
||||
CONFERENCE_FAILED,
|
||||
CONFERENCE_JOINED,
|
||||
CONFERENCE_LEFT
|
||||
} from '../../base/conference/actionTypes';
|
||||
import { getCurrentConference } from '../../base/conference/functions';
|
||||
import { SET_CONFIG } from '../../base/config/actionTypes';
|
||||
import { AUDIO_FOCUS_DISABLED } from '../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../base/flags/functions';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
import { parseURIString } from '../../base/util/uri';
|
||||
|
||||
import { _SET_AUDIOMODE_DEVICES, _SET_AUDIOMODE_SUBSCRIPTIONS } from './actionTypes';
|
||||
import logger from './logger';
|
||||
|
||||
const { AudioMode } = NativeModules;
|
||||
const AudioModeEmitter = new NativeEventEmitter(AudioMode);
|
||||
|
||||
/**
|
||||
* Middleware that captures conference actions and sets the correct audio mode
|
||||
* based on the type of conference. Audio-only conferences don't use the speaker
|
||||
* by default, and video conferences do.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
/* eslint-disable no-fallthrough */
|
||||
|
||||
switch (action.type) {
|
||||
case _SET_AUDIOMODE_SUBSCRIPTIONS:
|
||||
_setSubscriptions(store);
|
||||
break;
|
||||
case APP_WILL_UNMOUNT: {
|
||||
store.dispatch({
|
||||
type: _SET_AUDIOMODE_SUBSCRIPTIONS,
|
||||
subscriptions: undefined
|
||||
});
|
||||
break;
|
||||
}
|
||||
case APP_WILL_MOUNT:
|
||||
_appWillMount(store);
|
||||
case CONFERENCE_FAILED:
|
||||
case CONFERENCE_LEFT:
|
||||
|
||||
/*
|
||||
* NOTE: We moved the audio mode setting from CONFERENCE_WILL_JOIN to
|
||||
* CONFERENCE_JOINED because in case of a locked room, the app goes
|
||||
* through CONFERENCE_FAILED state and gets to CONFERENCE_JOINED only
|
||||
* after a correct password, so we want to make sure we have the correct
|
||||
* audio mode set up when we finally get to the conf, but also make sure
|
||||
* that the app is in the right audio mode if the user leaves the
|
||||
* conference after the password prompt appears.
|
||||
*/
|
||||
case CONFERENCE_JOINED:
|
||||
case SET_AUDIO_ONLY:
|
||||
return _updateAudioMode(store, next, action);
|
||||
|
||||
case SET_CONFIG: {
|
||||
const { locationURL } = store.getState()['features/base/connection'];
|
||||
const location = parseURIString(locationURL?.href ?? '');
|
||||
|
||||
/**
|
||||
* Don't touch the current value if there is no room in the URL. This
|
||||
* avoids audio cutting off for a moment right after the user leaves
|
||||
* a meeting. The next meeting join will set it to the right value.
|
||||
*/
|
||||
if (location.room) {
|
||||
const { startSilent } = action.config;
|
||||
|
||||
AudioMode.setDisabled?.(Boolean(startSilent));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-enable no-fallthrough */
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Notifies this feature that the action {@link APP_WILL_MOUNT} is being
|
||||
* dispatched within a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _appWillMount(store: IStore) {
|
||||
const subscriptions = [
|
||||
AudioModeEmitter.addListener(AudioMode.DEVICE_CHANGE_EVENT, _onDevicesUpdate, store)
|
||||
];
|
||||
|
||||
store.dispatch({
|
||||
type: _SET_AUDIOMODE_SUBSCRIPTIONS,
|
||||
subscriptions
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles audio device changes. The list will be stored on the redux store.
|
||||
*
|
||||
* @param {Object} devices - The current list of devices.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onDevicesUpdate(devices: any) {
|
||||
// @ts-ignore
|
||||
const { dispatch } = this; // eslint-disable-line @typescript-eslint/no-invalid-this
|
||||
|
||||
dispatch({
|
||||
type: _SET_AUDIOMODE_DEVICES,
|
||||
devices
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies this feature that the action
|
||||
* {@link _SET_AUDIOMODE_SUBSCRIPTIONS} is being dispatched within
|
||||
* a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _setSubscriptions({ getState }: IStore) {
|
||||
const { subscriptions } = getState()['features/mobile/audio-mode'];
|
||||
|
||||
if (subscriptions) {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the audio mode based on the current (redux) state.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} in the specified {@code store}.
|
||||
* @param {Action} action - The redux action which is
|
||||
* being dispatched in the specified {@code store}.
|
||||
* @private
|
||||
* @returns {*} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _updateAudioMode({ getState }: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
const state = getState();
|
||||
const conference = getCurrentConference(state);
|
||||
const { enabled: audioOnly } = state['features/base/audio-only'];
|
||||
let mode: string;
|
||||
|
||||
if (getFeatureFlag(state, AUDIO_FOCUS_DISABLED, false)) {
|
||||
return result;
|
||||
} else if (conference) {
|
||||
mode = audioOnly ? AudioMode.AUDIO_CALL : AudioMode.VIDEO_CALL;
|
||||
} else {
|
||||
mode = AudioMode.DEFAULT;
|
||||
}
|
||||
|
||||
AudioMode.setMode(mode).catch((err: any) => logger.error(`Failed to set audio mode ${String(mode)}: ${err}`));
|
||||
|
||||
return result;
|
||||
}
|
||||
36
react/features/mobile/audio-mode/reducer.ts
Normal file
36
react/features/mobile/audio-mode/reducer.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import ReducerRegistry from '../../base/redux/ReducerRegistry';
|
||||
import { equals, set } from '../../base/redux/functions';
|
||||
|
||||
import { _SET_AUDIOMODE_DEVICES, _SET_AUDIOMODE_SUBSCRIPTIONS } from './actionTypes';
|
||||
import { IRawDevice } from './components/AudioRoutePickerDialog';
|
||||
|
||||
export interface IMobileAudioModeState {
|
||||
devices: IRawDevice[];
|
||||
subscriptions: {
|
||||
remove: Function;
|
||||
}[];
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
devices: [],
|
||||
subscriptions: []
|
||||
};
|
||||
|
||||
ReducerRegistry.register<IMobileAudioModeState>('features/mobile/audio-mode',
|
||||
(state = DEFAULT_STATE, action): IMobileAudioModeState => {
|
||||
switch (action.type) {
|
||||
case _SET_AUDIOMODE_DEVICES: {
|
||||
const { devices } = action;
|
||||
|
||||
if (equals(state.devices, devices)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return set(state, 'devices', devices);
|
||||
}
|
||||
case _SET_AUDIOMODE_SUBSCRIPTIONS:
|
||||
return set(state, 'subscriptions', action.subscriptions);
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
26
react/features/mobile/background/actionTypes.ts
Normal file
26
react/features/mobile/background/actionTypes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The type of redux action used for app state subscription.
|
||||
*
|
||||
* {
|
||||
* type: _SET_APP_STATE_SUBSCRIPTION,
|
||||
* subscription: NativeEventSubscription
|
||||
* }
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
export const _SET_APP_STATE_SUBSCRIPTION = '_SET_APP_STATE_SUBSCRIPTION';
|
||||
|
||||
/**
|
||||
* The type of redux action which signals that the app state has changed (in
|
||||
* terms of execution mode). The app state can be one of 'active', 'inactive',
|
||||
* or 'background'.
|
||||
*
|
||||
* {
|
||||
* type: APP_STATE_CHANGED,
|
||||
* appState: string
|
||||
* }
|
||||
*
|
||||
* @public
|
||||
* @see {@link https://facebook.github.io/react-native/docs/appstate.html}
|
||||
*/
|
||||
export const APP_STATE_CHANGED = 'APP_STATE_CHANGED';
|
||||
39
react/features/mobile/background/actions.ts
Normal file
39
react/features/mobile/background/actions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NativeEventSubscription } from 'react-native';
|
||||
|
||||
import { APP_STATE_CHANGED, _SET_APP_STATE_SUBSCRIPTION } from './actionTypes';
|
||||
|
||||
/**
|
||||
* Sets subscription for app state.
|
||||
*
|
||||
* @param {Function} subscription - Subscription for the native event.
|
||||
* @private
|
||||
* @returns {{
|
||||
* type: _SET_APP_STATE_SUBSCRIPTION,
|
||||
* subscription: NativeEventSubscription
|
||||
* }}
|
||||
*/
|
||||
export function _setAppStateSubscription(subscription?: NativeEventSubscription) {
|
||||
return {
|
||||
type: _SET_APP_STATE_SUBSCRIPTION,
|
||||
subscription
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that the App state has changed (in terms of execution state). The
|
||||
* application can be in 3 states: 'active', 'inactive' and 'background'.
|
||||
*
|
||||
* @param {string} appState - The new App state.
|
||||
* @public
|
||||
* @returns {{
|
||||
* type: APP_STATE_CHANGED,
|
||||
* appState: string
|
||||
* }}
|
||||
* @see {@link https://facebook.github.io/react-native/docs/appstate.html}
|
||||
*/
|
||||
export function appStateChanged(appState: string) {
|
||||
return {
|
||||
type: APP_STATE_CHANGED,
|
||||
appState
|
||||
};
|
||||
}
|
||||
3
react/features/mobile/background/logger.ts
Normal file
3
react/features/mobile/background/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../../base/logging/functions';
|
||||
|
||||
export default getLogger('features/mobile/background');
|
||||
83
react/features/mobile/background/middleware.native.ts
Normal file
83
react/features/mobile/background/middleware.native.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { AppState } from 'react-native';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app/actionTypes';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
|
||||
import { _setAppStateSubscription, appStateChanged } from './actions';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Middleware that captures App lifetime actions and subscribes to application
|
||||
* state changes. When the application state changes it will fire the action
|
||||
* required to mute or unmute the local video in case the application goes to
|
||||
* the background or comes back from it.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
* @see {@link https://facebook.github.io/react-native/docs/appstate.html}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
|
||||
case APP_WILL_MOUNT: {
|
||||
const { dispatch } = store;
|
||||
|
||||
_setAppStateListener(store, _onAppStateChange.bind(undefined, dispatch));
|
||||
|
||||
// Because there is no change taking place when the app mounts,
|
||||
// we need to force registering the appState status.
|
||||
const appStateInterval = setInterval(() => {
|
||||
const { currentState } = AppState;
|
||||
|
||||
if (currentState !== 'unknown') {
|
||||
clearInterval(appStateInterval);
|
||||
|
||||
_onAppStateChange(dispatch, currentState);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case APP_WILL_UNMOUNT:
|
||||
_setAppStateListener(store, undefined);
|
||||
break;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Called by React Native's AppState API to notify that the application state
|
||||
* has changed. Dispatches the change within the (associated) redux store.
|
||||
*
|
||||
* @param {Dispatch} dispatch - The redux {@code dispatch} function.
|
||||
* @param {string} appState - The current application execution state.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onAppStateChange(dispatch: IStore['dispatch'], appState: string) {
|
||||
dispatch(appStateChanged(appState));
|
||||
|
||||
logger.info(`appState changed to: ${appState}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the feature filmstrip that the action
|
||||
* {@link _SET_IMMERSIVE_LISTENER} is being dispatched within a specific redux
|
||||
* store.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified action is being
|
||||
* dispatched.
|
||||
* @param {any} listener - Listener for app state status.
|
||||
* @private
|
||||
* @returns {Object} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _setAppStateListener({ dispatch, getState }: IStore, listener: any) {
|
||||
const { subscription } = getState()['features/mobile/background'];
|
||||
|
||||
subscription?.remove();
|
||||
|
||||
dispatch(_setAppStateSubscription(listener ? AppState.addEventListener('change', listener) : undefined));
|
||||
}
|
||||
37
react/features/mobile/background/reducer.ts
Normal file
37
react/features/mobile/background/reducer.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NativeEventSubscription } from 'react-native';
|
||||
|
||||
import ReducerRegistry from '../../base/redux/ReducerRegistry';
|
||||
|
||||
import { APP_STATE_CHANGED, _SET_APP_STATE_SUBSCRIPTION } from './actionTypes';
|
||||
|
||||
export interface IMobileBackgroundState {
|
||||
appState: string;
|
||||
subscription?: NativeEventSubscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default/initial redux state of the feature background.
|
||||
*/
|
||||
const DEFAULT_STATE = {
|
||||
appState: ''
|
||||
};
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
ReducerRegistry.register<IMobileBackgroundState>('features/mobile/background', (state = DEFAULT_STATE, action): IMobileBackgroundState => {
|
||||
switch (action.type) {
|
||||
|
||||
case _SET_APP_STATE_SUBSCRIPTION:
|
||||
return {
|
||||
...state,
|
||||
subscription: action.subscription
|
||||
};
|
||||
|
||||
case APP_STATE_CHANGED:
|
||||
return {
|
||||
...state,
|
||||
appState: action.appState
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
66
react/features/mobile/call-integration/CallKit.ts
Normal file
66
react/features/mobile/call-integration/CallKit.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
|
||||
import { getName } from '../../app/functions.native';
|
||||
|
||||
/**
|
||||
* Thin wrapper around Apple's CallKit functionality.
|
||||
*
|
||||
* In CallKit requests are performed via actions (either user or system started)
|
||||
* and async events are reported via dedicated methods. This class exposes that
|
||||
* functionality in the form of methods and events. One important thing to note
|
||||
* is that even if an action is started by the system (because the user pressed
|
||||
* the "end call" button in the CallKit view, for example) the event will be
|
||||
* emitted in the same way as it would if the action originated from calling
|
||||
* the "endCall" method in this class, for example.
|
||||
*
|
||||
* Emitted events:
|
||||
* - performAnswerCallAction: The user pressed the answer button.
|
||||
* - performEndCallAction: The call should be ended.
|
||||
* - performSetMutedCallAction: The call muted state should change. The
|
||||
* ancillary `data` object contains a `muted` attribute.
|
||||
* - providerDidReset: The system has reset, all calls should be terminated.
|
||||
* This event gets no associated data.
|
||||
*
|
||||
* All events get a `data` object with a `callUUID` property, unless stated
|
||||
* otherwise.
|
||||
*/
|
||||
let CallKit = NativeModules.RNCallKit;
|
||||
|
||||
// XXX Rather than wrapping RNCallKit in a new class and forwarding the many
|
||||
// methods of the latter to the former, add the one additional method that we
|
||||
// need to RNCallKit.
|
||||
if (CallKit) {
|
||||
const eventEmitter = new NativeEventEmitter(CallKit);
|
||||
|
||||
CallKit = {
|
||||
...CallKit,
|
||||
addListener: eventEmitter.addListener.bind(eventEmitter),
|
||||
registerSubscriptions(context: any, delegate: any) {
|
||||
CallKit.setProviderConfiguration({
|
||||
iconTemplateImageName: 'CallKitIcon',
|
||||
localizedName: getName()
|
||||
});
|
||||
|
||||
return [
|
||||
CallKit.addListener(
|
||||
'performEndCallAction',
|
||||
delegate._onPerformEndCallAction,
|
||||
context),
|
||||
CallKit.addListener(
|
||||
'performSetMutedCallAction',
|
||||
delegate._onPerformSetMutedCallAction,
|
||||
context),
|
||||
|
||||
// According to CallKit's documentation, when the system resets
|
||||
// we should terminate all calls. Hence, providerDidReset is
|
||||
// the same to us as performEndCallAction.
|
||||
CallKit.addListener(
|
||||
'providerDidReset',
|
||||
delegate._onPerformEndCallAction,
|
||||
context)
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default CallKit;
|
||||
BIN
react/features/mobile/call-integration/CallKitIcon.png
Normal file
BIN
react/features/mobile/call-integration/CallKitIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 260 B |
33
react/features/mobile/call-integration/ConnectionService.ts
Normal file
33
react/features/mobile/call-integration/ConnectionService.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
|
||||
let ConnectionService = NativeModules.ConnectionService;
|
||||
|
||||
// XXX Rather than wrapping ConnectionService in a new class and forwarding
|
||||
// the many methods of the latter to the former, add the one additional
|
||||
// method that we need to ConnectionService.
|
||||
if (ConnectionService) {
|
||||
const eventEmitter = new NativeEventEmitter(ConnectionService);
|
||||
|
||||
ConnectionService = {
|
||||
...ConnectionService,
|
||||
addListener: eventEmitter.addListener.bind(eventEmitter),
|
||||
registerSubscriptions(context: any, delegate: any) {
|
||||
return [
|
||||
ConnectionService.addListener(
|
||||
'org.jitsi.meet:features/connection_service#disconnect',
|
||||
delegate._onPerformEndCallAction,
|
||||
context),
|
||||
ConnectionService.addListener(
|
||||
'org.jitsi.meet:features/connection_service#abort',
|
||||
delegate._onPerformEndCallAction,
|
||||
context)
|
||||
];
|
||||
},
|
||||
setMuted() {
|
||||
// Currently no-op, but remember to remove when implemented on
|
||||
// the native side
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default ConnectionService;
|
||||
13
react/features/mobile/call-integration/actionTypes.ts
Normal file
13
react/features/mobile/call-integration/actionTypes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The type of redux action to set CallKit's and ConnectionService's event
|
||||
* subscriptions.
|
||||
*
|
||||
* {
|
||||
* type: _SET_CALL_INTEGRATION_SUBSCRIPTIONS,
|
||||
* subscriptions: Array|undefined
|
||||
* }
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
export const _SET_CALL_INTEGRATION_SUBSCRIPTIONS
|
||||
= '_SET_CALL_INTEGRATION_SUBSCRIPTIONS';
|
||||
20
react/features/mobile/call-integration/functions.ts
Normal file
20
react/features/mobile/call-integration/functions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IStateful } from '../../base/app/types';
|
||||
import { CALL_INTEGRATION_ENABLED } from '../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../base/flags/functions';
|
||||
import { toState } from '../../base/redux/functions';
|
||||
|
||||
/**
|
||||
* Checks if call integration is enabled or not.
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux store or {@code getState}
|
||||
* function.
|
||||
* @returns {string} - Default URL for the app.
|
||||
*/
|
||||
export function isCallIntegrationEnabled(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
const { disableCallIntegration } = state['features/base/settings'];
|
||||
const flag = getFeatureFlag(state, CALL_INTEGRATION_ENABLED);
|
||||
|
||||
// The feature flag has precedence.
|
||||
return flag ?? !disableCallIntegration;
|
||||
}
|
||||
520
react/features/mobile/call-integration/middleware.ts
Normal file
520
react/features/mobile/call-integration/middleware.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import { Alert, NativeModules, Platform } from 'react-native';
|
||||
import { AnyAction } from 'redux';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { createTrackMutedEvent } from '../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../analytics/functions';
|
||||
import { appNavigate } from '../../app/actions.native';
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app/actionTypes';
|
||||
import { SET_AUDIO_ONLY } from '../../base/audio-only/actionTypes';
|
||||
import {
|
||||
CONFERENCE_FAILED,
|
||||
CONFERENCE_JOINED,
|
||||
CONFERENCE_JOIN_IN_PROGRESS,
|
||||
CONFERENCE_LEFT,
|
||||
CONFERENCE_WILL_LEAVE
|
||||
} from '../../base/conference/actionTypes';
|
||||
import {
|
||||
getConferenceName,
|
||||
getCurrentConference
|
||||
} from '../../base/conference/functions';
|
||||
import { IJitsiConference } from '../../base/conference/reducer';
|
||||
import { getInviteURL } from '../../base/connection/functions';
|
||||
import { setAudioMuted } from '../../base/media/actions';
|
||||
import { MEDIA_TYPE } from '../../base/media/constants';
|
||||
import { isVideoMutedByAudioOnly } from '../../base/media/functions';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
import {
|
||||
TRACK_ADDED,
|
||||
TRACK_REMOVED,
|
||||
TRACK_UPDATED
|
||||
} from '../../base/tracks/actionTypes';
|
||||
import { isLocalTrackMuted } from '../../base/tracks/functions.native';
|
||||
|
||||
import CallKit from './CallKit';
|
||||
import ConnectionService from './ConnectionService';
|
||||
import { _SET_CALL_INTEGRATION_SUBSCRIPTIONS } from './actionTypes';
|
||||
import { isCallIntegrationEnabled } from './functions';
|
||||
|
||||
const { AudioMode } = NativeModules;
|
||||
const CallIntegration = CallKit || ConnectionService;
|
||||
|
||||
/**
|
||||
* Middleware that captures system actions and hooks up CallKit.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
CallIntegration && MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case _SET_CALL_INTEGRATION_SUBSCRIPTIONS:
|
||||
return _setCallKitSubscriptions(store, next, action);
|
||||
|
||||
case APP_WILL_MOUNT:
|
||||
return _appWillMount(store, next, action);
|
||||
|
||||
case APP_WILL_UNMOUNT:
|
||||
store.dispatch({
|
||||
type: _SET_CALL_INTEGRATION_SUBSCRIPTIONS,
|
||||
subscriptions: undefined
|
||||
});
|
||||
break;
|
||||
|
||||
case CONFERENCE_FAILED:
|
||||
return _conferenceFailed(store, next, action);
|
||||
|
||||
case CONFERENCE_JOINED:
|
||||
return _conferenceJoined(store, next, action);
|
||||
|
||||
// If a conference is being left in a graceful manner then
|
||||
// the CONFERENCE_WILL_LEAVE fires as soon as the conference starts
|
||||
// disconnecting. We need to destroy the call on the native side as soon
|
||||
// as possible, because the disconnection process is asynchronous and
|
||||
// Android not always supports two simultaneous calls at the same time
|
||||
// (even though it should according to the spec).
|
||||
case CONFERENCE_LEFT:
|
||||
case CONFERENCE_WILL_LEAVE:
|
||||
return _conferenceLeft(store, next, action);
|
||||
|
||||
case CONFERENCE_JOIN_IN_PROGRESS:
|
||||
return _conferenceWillJoin(store, next, action);
|
||||
|
||||
case SET_AUDIO_ONLY:
|
||||
return _setAudioOnly(store, next, action);
|
||||
|
||||
case TRACK_ADDED:
|
||||
case TRACK_REMOVED:
|
||||
case TRACK_UPDATED:
|
||||
return _syncTrackState(store, next, action);
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Notifies the feature callkit that the action {@link APP_WILL_MOUNT} is being
|
||||
* dispatched within a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} in the specified {@code store}.
|
||||
* @param {Action} action - The redux action {@code APP_WILL_MOUNT} which is
|
||||
* being dispatched in the specified {@code store}.
|
||||
* @private
|
||||
* @returns {*} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _appWillMount({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
|
||||
const context = {
|
||||
dispatch,
|
||||
getState
|
||||
};
|
||||
|
||||
const delegate = {
|
||||
_onPerformSetMutedCallAction,
|
||||
_onPerformEndCallAction
|
||||
};
|
||||
|
||||
if (isCallIntegrationEnabled(getState)) {
|
||||
const subscriptions = CallIntegration.registerSubscriptions(context, delegate);
|
||||
|
||||
subscriptions && dispatch({
|
||||
type: _SET_CALL_INTEGRATION_SUBSCRIPTIONS,
|
||||
subscriptions
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the feature callkit that the action {@link CONFERENCE_FAILED} is
|
||||
* being dispatched within a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} in the specified {@code store}.
|
||||
* @param {Action} action - The redux action {@code CONFERENCE_FAILED} which is
|
||||
* being dispatched in the specified {@code store}.
|
||||
* @private
|
||||
* @returns {*} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _conferenceFailed({ getState }: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
|
||||
if (!isCallIntegrationEnabled(getState)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (!action.error.recoverable) {
|
||||
const { callUUID } = action.conference;
|
||||
|
||||
if (callUUID) {
|
||||
delete action.conference.callUUID;
|
||||
|
||||
CallIntegration.reportCallFailed(callUUID);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the feature callkit that the action {@link CONFERENCE_JOINED} is
|
||||
* being dispatched within a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} in the specified {@code store}.
|
||||
* @param {Action} action - The redux action {@code CONFERENCE_JOINED} which is
|
||||
* being dispatched in the specified {@code store}.
|
||||
* @private
|
||||
* @returns {*} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _conferenceJoined({ getState }: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
|
||||
if (!isCallIntegrationEnabled(getState)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { callUUID } = action.conference;
|
||||
|
||||
if (callUUID) {
|
||||
CallIntegration.reportConnectedOutgoingCall(callUUID)
|
||||
.then(() => {
|
||||
// iOS 13 doesn't like the mute state to be false before the call is started
|
||||
// so we update it here in case the user selected startWithAudioMuted.
|
||||
if (Platform.OS === 'ios') {
|
||||
_updateCallIntegrationMuted(action.conference, getState());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Currently errors here are only emitted by Android.
|
||||
//
|
||||
// Some Samsung devices will fail to fully engage ConnectionService if no SIM card
|
||||
// was ever installed on the device. We could check for it, but it would require
|
||||
// the CALL_PHONE permission, which is not something we want to do, so fallback to
|
||||
// not using ConnectionService.
|
||||
_handleConnectionServiceFailure(getState());
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the feature callkit that the action {@link CONFERENCE_LEFT} is being
|
||||
* dispatched within a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} in the specified {@code store}.
|
||||
* @param {Action} action - The redux action {@code CONFERENCE_LEFT} which is
|
||||
* being dispatched in the specified {@code store}.
|
||||
* @private
|
||||
* @returns {*} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _conferenceLeft({ getState }: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
|
||||
if (!isCallIntegrationEnabled(getState)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { callUUID } = action.conference;
|
||||
|
||||
if (callUUID) {
|
||||
delete action.conference.callUUID;
|
||||
|
||||
CallIntegration.endCall(callUUID);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the feature callkit that the action {@link CONFERENCE_WILL_JOIN} is
|
||||
* being dispatched within a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} in the specified {@code store}.
|
||||
* @param {Action} action - The redux action {@code CONFERENCE_WILL_JOIN} which
|
||||
* is being dispatched in the specified {@code store}.
|
||||
* @private
|
||||
* @returns {*} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _conferenceWillJoin({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
|
||||
if (!isCallIntegrationEnabled(getState)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { conference } = action;
|
||||
const state = getState();
|
||||
const { callHandle, callUUID } = state['features/base/config'];
|
||||
const url = getInviteURL(state);
|
||||
const handle = callHandle || url.toString();
|
||||
const hasVideo = !isVideoMutedByAudioOnly(state);
|
||||
|
||||
// If we already have a callUUID set, don't start a new call.
|
||||
if (conference.callUUID) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// When assigning the callUUID, do so in upper case, since iOS will return
|
||||
// it upper-cased.
|
||||
conference.callUUID = (callUUID || uuidv4()).toUpperCase();
|
||||
|
||||
CallIntegration.startCall(conference.callUUID, handle, hasVideo)
|
||||
.then(() => {
|
||||
const displayName = getConferenceName(state);
|
||||
|
||||
CallIntegration.updateCall(
|
||||
conference.callUUID,
|
||||
{
|
||||
displayName,
|
||||
hasVideo
|
||||
});
|
||||
|
||||
// iOS 13 doesn't like the mute state to be false before the call is started
|
||||
// so delay it until the conference was joined.
|
||||
if (Platform.OS !== 'ios') {
|
||||
_updateCallIntegrationMuted(conference, state);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// Currently this error codes are emitted only by Android.
|
||||
//
|
||||
if (error.code === 'CREATE_OUTGOING_CALL_FAILED') {
|
||||
// We're not tracking the call anymore - it doesn't exist on
|
||||
// the native side.
|
||||
delete conference.callUUID;
|
||||
|
||||
dispatch(appNavigate(undefined));
|
||||
Alert.alert(
|
||||
'Call aborted',
|
||||
'There\'s already another call in progress.'
|
||||
+ ' Please end it first and try again.',
|
||||
[
|
||||
{ text: 'OK' }
|
||||
],
|
||||
{ cancelable: false });
|
||||
} else {
|
||||
// Some devices fail because the CALL_PHONE permission is not granted, which is
|
||||
// nonsense, because it's not needed for self-managed connections.
|
||||
// Some other devices fail because ConnectionService is not supported.
|
||||
// Be that as it may, fallback to non-ConnectionService audio device handling.
|
||||
|
||||
_handleConnectionServiceFailure(state);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a ConnectionService fatal error by falling back to non-ConnectionService device management.
|
||||
*
|
||||
* @param {Object} state - Redux store.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _handleConnectionServiceFailure(state: IReduxState) {
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
if (conference) {
|
||||
// We're not tracking the call anymore.
|
||||
delete conference.callUUID;
|
||||
|
||||
// ConnectionService has fatally failed. Alas, this also means audio device management would be broken, so
|
||||
// fallback to not using ConnectionService.
|
||||
// NOTE: We are not storing this in Settings, in case it's a transient issue, as far fetched as
|
||||
// that may be.
|
||||
if (AudioMode.setUseConnectionService) {
|
||||
AudioMode.setUseConnectionService(false);
|
||||
|
||||
const hasVideo = !isVideoMutedByAudioOnly(state);
|
||||
|
||||
// Set the desired audio mode, since we just reset the whole thing.
|
||||
AudioMode.setMode(hasVideo ? AudioMode.VIDEO_CALL : AudioMode.AUDIO_CALL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles CallKit's event {@code performEndCallAction}.
|
||||
*
|
||||
* @param {Object} event - The details of the CallKit event
|
||||
* {@code performEndCallAction}.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onPerformEndCallAction({ callUUID }: { callUUID: string; }) {
|
||||
// @ts-ignore
|
||||
const { dispatch, getState } = this; // eslint-disable-line @typescript-eslint/no-invalid-this
|
||||
const conference = getCurrentConference(getState);
|
||||
|
||||
if (conference?.callUUID === callUUID) {
|
||||
// We arrive here when a call is ended by the system, for example, when
|
||||
// another incoming call is received and the user selects "End &
|
||||
// Accept".
|
||||
delete conference.callUUID;
|
||||
|
||||
dispatch(appNavigate(undefined));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles CallKit's event {@code performSetMutedCallAction}.
|
||||
*
|
||||
* @param {Object} event - The details of the CallKit event
|
||||
* {@code performSetMutedCallAction}.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onPerformSetMutedCallAction({ callUUID, muted }: { callUUID: string; muted: boolean; }) {
|
||||
// @ts-ignore
|
||||
const { dispatch, getState } = this; // eslint-disable-line @typescript-eslint/no-invalid-this
|
||||
const conference = getCurrentConference(getState);
|
||||
|
||||
if (conference?.callUUID === callUUID) {
|
||||
muted = Boolean(muted); // eslint-disable-line no-param-reassign
|
||||
|
||||
sendAnalytics(
|
||||
createTrackMutedEvent('audio', 'call-integration', muted));
|
||||
|
||||
dispatch(setAudioMuted(muted, /* ensureTrack */ true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update CallKit with the audio only state of the conference. When a conference
|
||||
* is in audio only mode we will tell CallKit the call has no video. This
|
||||
* affects how the call is saved in the recent calls list.
|
||||
*
|
||||
* XXX: Note that here we are taking the `audioOnly` value straight from the
|
||||
* action, instead of examining the state. This is intentional, as setting the
|
||||
* audio only involves multiple actions which will be reflected in the state
|
||||
* later, but we are just interested in knowing if the mode is going to be
|
||||
* set or not.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} in the specified {@code store}.
|
||||
* @param {Action} action - The redux action which is being dispatched in the
|
||||
* specified {@code store}.
|
||||
* @private
|
||||
* @returns {*} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _setAudioOnly({ getState }: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
const state = getState();
|
||||
|
||||
if (!isCallIntegrationEnabled(state)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
if (conference?.callUUID) {
|
||||
CallIntegration.updateCall(
|
||||
conference.callUUID,
|
||||
{ hasVideo: !action.audioOnly });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the feature callkit that the action
|
||||
* {@link _SET_CALL_INTEGRATION_SUBSCRIPTIONS} is being dispatched within
|
||||
* a specific redux {@code store}.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} in the specified {@code store}.
|
||||
* @param {Action} action - The redux action
|
||||
* {@code _SET_CALL_INTEGRATION_SUBSCRIPTIONS} which is being dispatched in
|
||||
* the specified {@code store}.
|
||||
* @private
|
||||
* @returns {*} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _setCallKitSubscriptions({ getState }: IStore, next: Function, action: AnyAction) {
|
||||
const { subscriptions } = getState()['features/call-integration'];
|
||||
|
||||
if (subscriptions) {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return next(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize the muted state of tracks with CallKit.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified {@code action}
|
||||
* is being dispatched.
|
||||
* @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
|
||||
* specified {@code action} in the specified {@code store}.
|
||||
* @param {Action} action - The redux action which is being dispatched in the
|
||||
* specified {@code store}.
|
||||
* @private
|
||||
* @returns {*} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _syncTrackState({ getState }: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
|
||||
if (!isCallIntegrationEnabled(getState)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { jitsiTrack } = action.track;
|
||||
const state = getState();
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
if (jitsiTrack.isLocal() && conference?.callUUID) {
|
||||
switch (jitsiTrack.getType()) {
|
||||
case 'audio': {
|
||||
_updateCallIntegrationMuted(conference, state);
|
||||
break;
|
||||
}
|
||||
case 'video': {
|
||||
CallIntegration.updateCall(
|
||||
conference.callUUID,
|
||||
{ hasVideo: !isVideoMutedByAudioOnly(state) });
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the muted state in the native side.
|
||||
*
|
||||
* @param {Object} conference - The current active conference.
|
||||
* @param {Object} state - The redux store state.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _updateCallIntegrationMuted(conference: IJitsiConference, state: IReduxState) {
|
||||
const muted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
|
||||
|
||||
CallIntegration.setMuted(conference.callUUID, muted);
|
||||
}
|
||||
21
react/features/mobile/call-integration/reducer.ts
Normal file
21
react/features/mobile/call-integration/reducer.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import ReducerRegistry from '../../base/redux/ReducerRegistry';
|
||||
import { set } from '../../base/redux/functions';
|
||||
|
||||
import CallKit from './CallKit';
|
||||
import ConnectionService from './ConnectionService';
|
||||
import { _SET_CALL_INTEGRATION_SUBSCRIPTIONS } from './actionTypes';
|
||||
|
||||
export interface ICallIntegrationState {
|
||||
subscriptions?: any;
|
||||
}
|
||||
|
||||
(CallKit || ConnectionService) && ReducerRegistry.register<ICallIntegrationState>(
|
||||
'features/call-integration',
|
||||
(state = {}, action): ICallIntegrationState => {
|
||||
switch (action.type) {
|
||||
case _SET_CALL_INTEGRATION_SUBSCRIPTIONS:
|
||||
return set(state, 'subscriptions', action.subscriptions);
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
20
react/features/mobile/external-api/actionTypes.ts
Normal file
20
react/features/mobile/external-api/actionTypes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The type of the action which indicates the SDK is ready to be closed.
|
||||
*
|
||||
* @returns {{
|
||||
* type: READY_TO_CLOSE
|
||||
* }}
|
||||
*/
|
||||
export const READY_TO_CLOSE = 'READY_TO_CLOSE';
|
||||
|
||||
/**
|
||||
* The type of the action which sets the list of known participant IDs which
|
||||
* have an active screen share.
|
||||
*
|
||||
* @returns {{
|
||||
* type: SCREEN_SHARE_PARTICIPANTS_UPDATED,
|
||||
* participantIds: Array<string>
|
||||
* }}
|
||||
*/
|
||||
export const SCREEN_SHARE_PARTICIPANTS_UPDATED
|
||||
= 'SCREEN_SHARE_PARTICIPANTS_UPDATED';
|
||||
33
react/features/mobile/external-api/actions.ts
Normal file
33
react/features/mobile/external-api/actions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { READY_TO_CLOSE, SCREEN_SHARE_PARTICIPANTS_UPDATED } from './actionTypes';
|
||||
|
||||
|
||||
/**
|
||||
* Creates a (redux) action which signals that the SDK is ready to be closed.
|
||||
*
|
||||
* @returns {{
|
||||
* type: READY_TO_CLOSE
|
||||
* }}
|
||||
*/
|
||||
export function readyToClose() {
|
||||
return {
|
||||
type: READY_TO_CLOSE
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a (redux) action which signals that the list of known participants
|
||||
* with screen shares has changed.
|
||||
*
|
||||
* @param {string} participantIds - The participants which currently have active
|
||||
* screen share streams.
|
||||
* @returns {{
|
||||
* type: SCREEN_SHARE_PARTICIPANTS_UPDATED,
|
||||
* participantId: string
|
||||
* }}
|
||||
*/
|
||||
export function setParticipantsWithScreenShare(participantIds: Array<string>) {
|
||||
return {
|
||||
type: SCREEN_SHARE_PARTICIPANTS_UPDATED,
|
||||
participantIds
|
||||
};
|
||||
}
|
||||
47
react/features/mobile/external-api/functions.ts
Normal file
47
react/features/mobile/external-api/functions.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { debounce } from 'lodash-es';
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
import { IParticipant } from '../../base/participants/types';
|
||||
|
||||
import { readyToClose } from './actions';
|
||||
|
||||
|
||||
/**
|
||||
* Sends a specific event to the native counterpart of the External API. Native
|
||||
* apps may listen to such events via the mechanisms provided by the (native)
|
||||
* mobile Jitsi Meet SDK.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @param {string} name - The name of the event to send.
|
||||
* @param {Object} data - The details/specifics of the event to send determined
|
||||
* by/associated with the specified {@code name}.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function sendEvent(store: Object, name: string, data: Object) {
|
||||
NativeModules.ExternalAPI.sendEvent(name, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced sending of `readyToClose`.
|
||||
*/
|
||||
export const _sendReadyToClose = debounce(dispatch => {
|
||||
dispatch(readyToClose());
|
||||
}, 2500, { leading: true });
|
||||
|
||||
/**
|
||||
* Returns a participant info object based on the passed participant object from redux.
|
||||
*
|
||||
* @param {Participant} participant - The participant object from the redux store.
|
||||
* @returns {Object} - The participant info object.
|
||||
*/
|
||||
export function participantToParticipantInfo(participant: IParticipant) {
|
||||
return {
|
||||
isLocal: participant.local,
|
||||
email: participant.email,
|
||||
name: participant.name,
|
||||
participantId: participant.id,
|
||||
displayName: participant.displayName,
|
||||
avatarUrl: participant.avatarURL,
|
||||
role: participant.role
|
||||
};
|
||||
}
|
||||
3
react/features/mobile/external-api/logger.ts
Normal file
3
react/features/mobile/external-api/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../../base/logging/functions';
|
||||
|
||||
export default getLogger('features/mobile/external-api');
|
||||
881
react/features/mobile/external-api/middleware.ts
Normal file
881
react/features/mobile/external-api/middleware.ts
Normal file
@@ -0,0 +1,881 @@
|
||||
/* eslint-disable lines-around-comment */
|
||||
|
||||
import { debounce } from 'lodash-es';
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
// @ts-ignore
|
||||
import { ENDPOINT_TEXT_MESSAGE_NAME } from '../../../../modules/API/constants';
|
||||
import { appNavigate } from '../../app/actions.native';
|
||||
import { IStore } from '../../app/types';
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app/actionTypes';
|
||||
import {
|
||||
CONFERENCE_BLURRED,
|
||||
CONFERENCE_FAILED,
|
||||
CONFERENCE_FOCUSED,
|
||||
CONFERENCE_JOINED,
|
||||
CONFERENCE_LEFT,
|
||||
CONFERENCE_UNIQUE_ID_SET,
|
||||
CONFERENCE_WILL_JOIN,
|
||||
ENDPOINT_MESSAGE_RECEIVED,
|
||||
SET_ROOM
|
||||
} from '../../base/conference/actionTypes';
|
||||
import { JITSI_CONFERENCE_URL_KEY } from '../../base/conference/constants';
|
||||
import {
|
||||
forEachConference,
|
||||
getCurrentConference,
|
||||
isRoomValid
|
||||
} from '../../base/conference/functions';
|
||||
import { IJitsiConference } from '../../base/conference/reducer';
|
||||
import { overwriteConfig } from '../../base/config/actions';
|
||||
import { getWhitelistedJSON } from '../../base/config/functions.native';
|
||||
import { CONNECTION_DISCONNECTED } from '../../base/connection/actionTypes';
|
||||
import {
|
||||
JITSI_CONNECTION_CONFERENCE_KEY,
|
||||
JITSI_CONNECTION_URL_KEY
|
||||
} from '../../base/connection/constants';
|
||||
import { getURLWithoutParams } from '../../base/connection/utils';
|
||||
import { JitsiConferenceEvents, JitsiRecordingConstants } from '../../base/lib-jitsi-meet';
|
||||
import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from '../../base/media/actionTypes';
|
||||
import { toggleCameraFacingMode } from '../../base/media/actions';
|
||||
import { MEDIA_TYPE, VIDEO_TYPE } from '../../base/media/constants';
|
||||
import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../../base/participants/actionTypes';
|
||||
import {
|
||||
getLocalParticipant,
|
||||
getParticipantById,
|
||||
getRemoteParticipants,
|
||||
isScreenShareParticipantById
|
||||
} from '../../base/participants/functions';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../../base/redux/StateListenerRegistry';
|
||||
import { toggleScreensharing } from '../../base/tracks/actions.native';
|
||||
import { CAMERA_FACING_MODE_MESSAGE } from '../../base/tracks/constants';
|
||||
import { getLocalTracks, isLocalTrackMuted } from '../../base/tracks/functions.native';
|
||||
import { ITrack } from '../../base/tracks/types';
|
||||
import { CLOSE_CHAT, OPEN_CHAT } from '../../chat/actionTypes';
|
||||
import { closeChat, openChat, sendMessage, setPrivateMessageRecipient } from '../../chat/actions.native';
|
||||
import { isEnabled as isDropboxEnabled } from '../../dropbox/functions.native';
|
||||
import { hideNotification, showNotification } from '../../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../../notifications/constants';
|
||||
import { RECORDING_SESSION_UPDATED } from '../../recording/actionTypes';
|
||||
import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../recording/constants';
|
||||
import { getActiveSession } from '../../recording/functions';
|
||||
import { setRequestingSubtitles } from '../../subtitles/actions.any';
|
||||
import { CUSTOM_BUTTON_PRESSED } from '../../toolbox/actionTypes';
|
||||
import { muteLocal } from '../../video-menu/actions.native';
|
||||
import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture/actionTypes';
|
||||
// @ts-ignore
|
||||
import { isExternalAPIAvailable } from '../react-native-sdk/functions';
|
||||
|
||||
import { READY_TO_CLOSE } from './actionTypes';
|
||||
import { setParticipantsWithScreenShare } from './actions';
|
||||
import { participantToParticipantInfo, sendEvent } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Event which will be emitted on the native side when a chat message is received
|
||||
* through the channel.
|
||||
*/
|
||||
const CHAT_MESSAGE_RECEIVED = 'CHAT_MESSAGE_RECEIVED';
|
||||
|
||||
/**
|
||||
* Event which will be emitted on the native side when the chat dialog is displayed/closed.
|
||||
*/
|
||||
const CHAT_TOGGLED = 'CHAT_TOGGLED';
|
||||
|
||||
/**
|
||||
* Event which will be emitted on the native side to indicate the conference
|
||||
* has ended either by user request or because an error was produced.
|
||||
*/
|
||||
const CONFERENCE_TERMINATED = 'CONFERENCE_TERMINATED';
|
||||
|
||||
/**
|
||||
* Event which will be emitted on the native side to indicate a message was received
|
||||
* through the channel.
|
||||
*/
|
||||
const ENDPOINT_TEXT_MESSAGE_RECEIVED = 'ENDPOINT_TEXT_MESSAGE_RECEIVED';
|
||||
|
||||
/**
|
||||
* Event which will be emitted on the native side to indicate a participant toggles
|
||||
* the screen share.
|
||||
*/
|
||||
const SCREEN_SHARE_TOGGLED = 'SCREEN_SHARE_TOGGLED';
|
||||
|
||||
/**
|
||||
* Event which will be emitted on the native side with the participant info array.
|
||||
*/
|
||||
const PARTICIPANTS_INFO_RETRIEVED = 'PARTICIPANTS_INFO_RETRIEVED';
|
||||
|
||||
/**
|
||||
* Event which will be emitted on the native side to indicate the recording status has changed.
|
||||
*/
|
||||
const RECORDING_STATUS_CHANGED = 'RECORDING_STATUS_CHANGED';
|
||||
|
||||
const externalAPIEnabled = isExternalAPIAvailable();
|
||||
|
||||
let eventEmitter: any;
|
||||
|
||||
const { ExternalAPI } = NativeModules;
|
||||
|
||||
if (externalAPIEnabled) {
|
||||
eventEmitter = new NativeEventEmitter(ExternalAPI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that captures Redux actions and uses the ExternalAPI module to
|
||||
* turn them into native events so the application knows about them.
|
||||
*
|
||||
* @param {Store} store - Redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
externalAPIEnabled && MiddlewareRegistry.register(store => next => action => {
|
||||
const oldAudioMuted = store.getState()['features/base/media'].audio.muted;
|
||||
const result = next(action);
|
||||
const { type } = action;
|
||||
|
||||
switch (type) {
|
||||
case APP_WILL_MOUNT:
|
||||
_registerForNativeEvents(store);
|
||||
break;
|
||||
case APP_WILL_UNMOUNT:
|
||||
_unregisterForNativeEvents();
|
||||
break;
|
||||
case CONFERENCE_FAILED: {
|
||||
const { error, ...data } = action;
|
||||
|
||||
// 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 (!error.recoverable) {
|
||||
_sendConferenceEvent(store, /* action */ {
|
||||
error: _toErrorString(error),
|
||||
...data
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CONFERENCE_LEFT:
|
||||
_sendConferenceEvent(store, action);
|
||||
break;
|
||||
|
||||
case CONFERENCE_JOINED:
|
||||
_sendConferenceEvent(store, action);
|
||||
_registerForEndpointTextMessages(store);
|
||||
break;
|
||||
|
||||
case CONFERENCE_BLURRED:
|
||||
sendEvent(store, CONFERENCE_BLURRED, {});
|
||||
break;
|
||||
|
||||
case CONFERENCE_FOCUSED:
|
||||
sendEvent(store, CONFERENCE_FOCUSED, {});
|
||||
break;
|
||||
|
||||
case CONFERENCE_UNIQUE_ID_SET: {
|
||||
const { conference } = action;
|
||||
|
||||
sendEvent(
|
||||
store,
|
||||
CONFERENCE_UNIQUE_ID_SET,
|
||||
/* data */ {
|
||||
sessionId: conference.getMeetingUniqueId()
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case CONNECTION_DISCONNECTED: {
|
||||
// FIXME: This is a hack. See the description in the JITSI_CONNECTION_CONFERENCE_KEY constant definition.
|
||||
// Check if this connection was attached to any conference.
|
||||
// If it wasn't, fake a CONFERENCE_TERMINATED event.
|
||||
const { connection } = action;
|
||||
const conference = connection[JITSI_CONNECTION_CONFERENCE_KEY];
|
||||
|
||||
if (!conference) {
|
||||
// This action will arrive late, so the locationURL stored on the state is no longer valid.
|
||||
const locationURL = connection[JITSI_CONNECTION_URL_KEY];
|
||||
|
||||
sendEvent(
|
||||
store,
|
||||
CONFERENCE_TERMINATED,
|
||||
/* data */ {
|
||||
url: _normalizeUrl(locationURL)
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case CUSTOM_BUTTON_PRESSED: {
|
||||
const { id, text } = action;
|
||||
|
||||
sendEvent(
|
||||
store,
|
||||
CUSTOM_BUTTON_PRESSED,
|
||||
/* data */ {
|
||||
id,
|
||||
text
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case ENDPOINT_MESSAGE_RECEIVED: {
|
||||
const { participant, data } = action;
|
||||
|
||||
if (data?.name === ENDPOINT_TEXT_MESSAGE_NAME) {
|
||||
sendEvent(
|
||||
store,
|
||||
ENDPOINT_TEXT_MESSAGE_RECEIVED,
|
||||
/* data */ {
|
||||
message: data.text,
|
||||
senderId: participant.getId()
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case ENTER_PICTURE_IN_PICTURE:
|
||||
sendEvent(store, type, /* data */ {});
|
||||
break;
|
||||
|
||||
case OPEN_CHAT:
|
||||
case CLOSE_CHAT: {
|
||||
sendEvent(
|
||||
store,
|
||||
CHAT_TOGGLED,
|
||||
/* data */ {
|
||||
isOpen: action.type === OPEN_CHAT
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case PARTICIPANT_JOINED:
|
||||
case PARTICIPANT_LEFT: {
|
||||
// Skip these events while not in a conference. SDK users can still retrieve them.
|
||||
const { conference } = store.getState()['features/base/conference'];
|
||||
|
||||
if (!conference) {
|
||||
break;
|
||||
}
|
||||
|
||||
const { participant } = action;
|
||||
|
||||
const isVirtualScreenshareParticipant = isScreenShareParticipantById(store.getState(), participant.id);
|
||||
|
||||
if (isVirtualScreenshareParticipant) {
|
||||
break;
|
||||
}
|
||||
|
||||
sendEvent(
|
||||
store,
|
||||
action.type,
|
||||
participantToParticipantInfo(participant) /* data */
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case READY_TO_CLOSE:
|
||||
sendEvent(store, type, /* data */ {});
|
||||
break;
|
||||
|
||||
case RECORDING_SESSION_UPDATED: {
|
||||
const {
|
||||
error,
|
||||
id,
|
||||
initiator,
|
||||
liveStreamViewURL,
|
||||
mode,
|
||||
status,
|
||||
terminator,
|
||||
timestamp
|
||||
} = action.sessionData;
|
||||
|
||||
const getId = (obj: any) => typeof obj === 'object' ? obj.getId() : obj;
|
||||
const getError = (err: any) => typeof err === 'object' ? String(err) : err;
|
||||
|
||||
sendEvent(
|
||||
store,
|
||||
RECORDING_STATUS_CHANGED,
|
||||
/* data */ {
|
||||
error: getError(error),
|
||||
id,
|
||||
initiator: getId(initiator),
|
||||
liveStreamViewURL,
|
||||
mode,
|
||||
status,
|
||||
terminator: getId(terminator),
|
||||
timestamp
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case SET_ROOM:
|
||||
_maybeTriggerEarlyConferenceWillJoin(store, action);
|
||||
break;
|
||||
|
||||
case SET_AUDIO_MUTED:
|
||||
if (action.muted !== oldAudioMuted) {
|
||||
sendEvent(
|
||||
store,
|
||||
'AUDIO_MUTED_CHANGED',
|
||||
/* data */ {
|
||||
muted: action.muted
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case SET_VIDEO_MUTED:
|
||||
sendEvent(
|
||||
store,
|
||||
'VIDEO_MUTED_CHANGED',
|
||||
/* data */ {
|
||||
muted: action.muted
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Listen for changes to the known media tracks and look
|
||||
* for updates to screen shares for emitting native events.
|
||||
* The listener is debounced to avoid state thrashing that might occur,
|
||||
* especially when switching in or out of p2p.
|
||||
*/
|
||||
externalAPIEnabled && StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/tracks'],
|
||||
/* listener */ debounce((tracks: ITrack[], store: IStore) => {
|
||||
const oldScreenShares = store.getState()['features/mobile/external-api'].screenShares || [];
|
||||
const newScreenShares = tracks
|
||||
.filter(track => track.mediaType === MEDIA_TYPE.SCREENSHARE || track.videoType === VIDEO_TYPE.DESKTOP)
|
||||
.map(track => track.participantId);
|
||||
|
||||
oldScreenShares.forEach(participantId => {
|
||||
if (!newScreenShares.includes(participantId)) {
|
||||
sendEvent(
|
||||
store,
|
||||
SCREEN_SHARE_TOGGLED,
|
||||
/* data */ {
|
||||
participantId,
|
||||
sharing: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
newScreenShares.forEach(participantId => {
|
||||
if (!oldScreenShares.includes(participantId)) {
|
||||
sendEvent(
|
||||
store,
|
||||
SCREEN_SHARE_TOGGLED,
|
||||
/* data */ {
|
||||
participantId,
|
||||
sharing: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
store.dispatch(setParticipantsWithScreenShare(newScreenShares));
|
||||
|
||||
}, 100));
|
||||
|
||||
/**
|
||||
* Registers for events sent from the native side via NativeEventEmitter.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _registerForNativeEvents(store: IStore) {
|
||||
const { getState, dispatch } = store;
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.HANG_UP, () => {
|
||||
dispatch(appNavigate(undefined));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.SET_AUDIO_MUTED, ({ muted }: any) => {
|
||||
dispatch(muteLocal(muted, MEDIA_TYPE.AUDIO));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.SET_VIDEO_MUTED, ({ muted }: any) => {
|
||||
dispatch(muteLocal(muted, MEDIA_TYPE.VIDEO));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.SEND_ENDPOINT_TEXT_MESSAGE, ({ to, message }: any) => {
|
||||
const conference = getCurrentConference(getState());
|
||||
|
||||
try {
|
||||
conference?.sendEndpointMessage(to, {
|
||||
name: ENDPOINT_TEXT_MESSAGE_NAME,
|
||||
text: message
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Cannot send endpointMessage', error);
|
||||
}
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.TOGGLE_SCREEN_SHARE, ({ enabled }: any) => {
|
||||
dispatch(toggleScreensharing(enabled));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.RETRIEVE_PARTICIPANTS_INFO, ({ requestId }: any) => {
|
||||
|
||||
const participantsInfo = [];
|
||||
const remoteParticipants = getRemoteParticipants(store);
|
||||
const localParticipant = getLocalParticipant(store);
|
||||
|
||||
localParticipant && participantsInfo.push(participantToParticipantInfo(localParticipant));
|
||||
remoteParticipants.forEach(participant => {
|
||||
if (!participant.fakeParticipant) {
|
||||
participantsInfo.push(participantToParticipantInfo(participant));
|
||||
}
|
||||
});
|
||||
|
||||
sendEvent(
|
||||
store,
|
||||
PARTICIPANTS_INFO_RETRIEVED,
|
||||
/* data */ {
|
||||
participantsInfo,
|
||||
requestId
|
||||
});
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.OPEN_CHAT, ({ to }: any) => {
|
||||
const participant = getParticipantById(store, to);
|
||||
|
||||
dispatch(openChat(participant));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.CLOSE_CHAT, () => {
|
||||
dispatch(closeChat());
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.SEND_CHAT_MESSAGE, ({ message, to }: any) => {
|
||||
const participant = getParticipantById(store, to);
|
||||
|
||||
if (participant) {
|
||||
dispatch(setPrivateMessageRecipient(participant));
|
||||
}
|
||||
|
||||
dispatch(sendMessage(message));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.SET_CLOSED_CAPTIONS_ENABLED,
|
||||
({ enabled, displaySubtitles, language }: any) => {
|
||||
dispatch(setRequestingSubtitles(enabled, displaySubtitles, language));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.TOGGLE_CAMERA, () => {
|
||||
dispatch(toggleCameraFacingMode());
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.SHOW_NOTIFICATION,
|
||||
({ appearance, description, timeout, title, uid }: any) => {
|
||||
const validTypes = Object.values(NOTIFICATION_TYPE);
|
||||
const validTimeouts = Object.values(NOTIFICATION_TIMEOUT_TYPE);
|
||||
|
||||
if (!validTypes.includes(appearance)) {
|
||||
logger.error(`Invalid notification type "${appearance}". Expecting one of ${validTypes}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validTimeouts.includes(timeout)) {
|
||||
logger.error(`Invalid notification timeout "${timeout}". Expecting one of ${validTimeouts}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(showNotification({
|
||||
appearance,
|
||||
description,
|
||||
title,
|
||||
uid
|
||||
}, timeout));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.HIDE_NOTIFICATION, ({ uid }: any) => {
|
||||
dispatch(hideNotification(uid));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.START_RECORDING, (
|
||||
{
|
||||
mode,
|
||||
dropboxToken,
|
||||
shouldShare,
|
||||
rtmpStreamKey,
|
||||
rtmpBroadcastID,
|
||||
youtubeStreamKey,
|
||||
youtubeBroadcastID,
|
||||
extraMetadata = {},
|
||||
transcription
|
||||
}: any) => {
|
||||
const state = store.getState();
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
if (!conference) {
|
||||
logger.error('Conference is not defined');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropboxToken && !isDropboxEnabled(state)) {
|
||||
logger.error('Failed starting recording: dropbox is not enabled on this deployment');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === JitsiRecordingConstants.mode.STREAM && !(youtubeStreamKey || rtmpStreamKey)) {
|
||||
logger.error('Failed starting recording: missing youtube or RTMP stream key');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let recordingConfig;
|
||||
|
||||
if (mode === JitsiRecordingConstants.mode.FILE) {
|
||||
const { recordingService } = state['features/base/config'];
|
||||
|
||||
if (!recordingService?.enabled && !dropboxToken) {
|
||||
logger.error('Failed starting recording: the recording service is not enabled');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropboxToken) {
|
||||
recordingConfig = {
|
||||
mode: JitsiRecordingConstants.mode.FILE,
|
||||
appData: JSON.stringify({
|
||||
'file_recording_metadata': {
|
||||
...extraMetadata,
|
||||
'upload_credentials': {
|
||||
'service_name': RECORDING_TYPES.DROPBOX,
|
||||
'token': dropboxToken
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
} else {
|
||||
recordingConfig = {
|
||||
mode: JitsiRecordingConstants.mode.FILE,
|
||||
appData: JSON.stringify({
|
||||
'file_recording_metadata': {
|
||||
...extraMetadata,
|
||||
'share': shouldShare
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
} else if (mode === JitsiRecordingConstants.mode.STREAM) {
|
||||
recordingConfig = {
|
||||
broadcastId: youtubeBroadcastID || rtmpBroadcastID,
|
||||
mode: JitsiRecordingConstants.mode.STREAM,
|
||||
streamId: youtubeStreamKey || rtmpStreamKey
|
||||
};
|
||||
}
|
||||
|
||||
// Start audio / video recording, if requested.
|
||||
if (typeof recordingConfig !== 'undefined') {
|
||||
conference.startRecording(recordingConfig);
|
||||
}
|
||||
|
||||
if (transcription) {
|
||||
store.dispatch(setRequestingSubtitles(true, false, null, true));
|
||||
conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
isTranscribingEnabled: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.STOP_RECORDING, ({ mode, transcription }: any) => {
|
||||
const state = store.getState();
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
if (!conference) {
|
||||
logger.error('Conference is not defined');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (transcription) {
|
||||
store.dispatch(setRequestingSubtitles(false, false, null));
|
||||
conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
isTranscribingEnabled: false
|
||||
});
|
||||
}
|
||||
|
||||
if (![ JitsiRecordingConstants.mode.FILE, JitsiRecordingConstants.mode.STREAM ].includes(mode)) {
|
||||
logger.error('Invalid recording mode provided!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSession = getActiveSession(state, mode);
|
||||
|
||||
if (!activeSession?.id) {
|
||||
logger.error('No recording or streaming session found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
conference.stopRecording(activeSession.id);
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.OVERWRITE_CONFIG, ({ config }: any) => {
|
||||
const whitelistedConfig = getWhitelistedJSON('config', config);
|
||||
|
||||
logger.info(`Overwriting config with: ${JSON.stringify(whitelistedConfig)}`);
|
||||
|
||||
dispatch(overwriteConfig(whitelistedConfig));
|
||||
});
|
||||
|
||||
eventEmitter.addListener(ExternalAPI.SEND_CAMERA_FACING_MODE_MESSAGE, ({ to, facingMode }: any) => {
|
||||
const conference = getCurrentConference(getState());
|
||||
|
||||
if (!to) {
|
||||
logger.warn('Participant id not set');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
conference?.sendEndpointMessage(to, {
|
||||
name: CAMERA_FACING_MODE_MESSAGE,
|
||||
facingMode
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister for events sent from the native side via NativeEventEmitter.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _unregisterForNativeEvents() {
|
||||
eventEmitter.removeAllListeners(ExternalAPI.HANG_UP);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.SET_AUDIO_MUTED);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.SET_VIDEO_MUTED);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.SEND_ENDPOINT_TEXT_MESSAGE);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.TOGGLE_SCREEN_SHARE);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.RETRIEVE_PARTICIPANTS_INFO);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.OPEN_CHAT);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.CLOSE_CHAT);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.SEND_CHAT_MESSAGE);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.SET_CLOSED_CAPTIONS_ENABLED);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.TOGGLE_CAMERA);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.SHOW_NOTIFICATION);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.HIDE_NOTIFICATION);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.START_RECORDING);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.STOP_RECORDING);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.OVERWRITE_CONFIG);
|
||||
eventEmitter.removeAllListeners(ExternalAPI.SEND_CAMERA_FACING_MODE_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers for endpoint messages sent on conference data channel.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _registerForEndpointTextMessages(store: IStore) {
|
||||
const conference = getCurrentConference(store.getState());
|
||||
|
||||
conference?.on(
|
||||
JitsiConferenceEvents.MESSAGE_RECEIVED,
|
||||
(id: string, message: string, timestamp: number) => {
|
||||
sendEvent(
|
||||
store,
|
||||
CHAT_MESSAGE_RECEIVED,
|
||||
/* data */ {
|
||||
senderId: id,
|
||||
message,
|
||||
isPrivate: false,
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
conference?.on(
|
||||
JitsiConferenceEvents.PRIVATE_MESSAGE_RECEIVED,
|
||||
(id: string, message: string, timestamp: number) => {
|
||||
sendEvent(
|
||||
store,
|
||||
CHAT_MESSAGE_RECEIVED,
|
||||
/* data */ {
|
||||
senderId: id,
|
||||
message,
|
||||
isPrivate: true,
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a {@code String} representation of a specific error {@code Object}.
|
||||
*
|
||||
* @param {Error|Object|string} error - The error {@code Object} to return a
|
||||
* {@code String} representation of.
|
||||
* @returns {string} A {@code String} representation of the specified
|
||||
* {@code error}.
|
||||
*/
|
||||
function _toErrorString(
|
||||
error: Error | { message?: string; name?: string; } | string) {
|
||||
// XXX In lib-jitsi-meet and jitsi-meet we utilize errors in the form of
|
||||
// strings, Error instances, and plain objects which resemble Error.
|
||||
return (
|
||||
error
|
||||
? typeof error === 'string'
|
||||
? error
|
||||
: Error.prototype.toString.apply(error)
|
||||
: '');
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@link SET_ROOM} action happens for a valid conference room this method
|
||||
* will emit an early {@link CONFERENCE_WILL_JOIN} event to let the external API
|
||||
* know that a conference is being joined. Before that happens a connection must
|
||||
* be created and only then base/conference feature would emit
|
||||
* {@link CONFERENCE_WILL_JOIN}. That is fine for the Jitsi Meet app, because
|
||||
* that's the a conference instance gets created, but it's too late for
|
||||
* the external API to learn that. The latter {@link CONFERENCE_WILL_JOIN} is
|
||||
* swallowed in {@link _swallowEvent}.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @param {Action} action - The redux action.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _maybeTriggerEarlyConferenceWillJoin(store: IStore, action: AnyAction) {
|
||||
const { locationURL } = store.getState()['features/base/connection'];
|
||||
const { room } = action;
|
||||
|
||||
isRoomValid(room) && locationURL && sendEvent(
|
||||
store,
|
||||
CONFERENCE_WILL_JOIN,
|
||||
/* data */ {
|
||||
url: _normalizeUrl(locationURL)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the given URL for presentation over the external API.
|
||||
*
|
||||
* @param {URL} url -The URL to normalize.
|
||||
* @returns {string} - The normalized URL as a string.
|
||||
*/
|
||||
function _normalizeUrl(url: URL) {
|
||||
return getURLWithoutParams(url).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event to the native counterpart of the External API for a specific
|
||||
* conference-related redux action.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @param {Action} action - The redux action.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _sendConferenceEvent(
|
||||
store: IStore,
|
||||
action: {
|
||||
conference: IJitsiConference;
|
||||
isAudioMuted?: boolean;
|
||||
type: string;
|
||||
url?: string;
|
||||
}) {
|
||||
const { conference, type, ...data } = action;
|
||||
|
||||
// For these (redux) actions, conference identifies a JitsiConference
|
||||
// instance. The external API cannot transport such an object so we have to
|
||||
// transport an "equivalent".
|
||||
if (conference) { // @ts-ignore
|
||||
data.url = _normalizeUrl(conference[JITSI_CONFERENCE_URL_KEY]);
|
||||
|
||||
const localTracks = getLocalTracks(store.getState()['features/base/tracks']);
|
||||
const isAudioMuted = isLocalTrackMuted(localTracks, MEDIA_TYPE.AUDIO);
|
||||
|
||||
data.isAudioMuted = isAudioMuted;
|
||||
}
|
||||
|
||||
if (_swallowEvent(store, action, data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let type_;
|
||||
|
||||
switch (type) {
|
||||
case CONFERENCE_FAILED:
|
||||
case CONFERENCE_LEFT:
|
||||
type_ = CONFERENCE_TERMINATED;
|
||||
break;
|
||||
default:
|
||||
type_ = type;
|
||||
break;
|
||||
}
|
||||
|
||||
sendEvent(store, type_, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether to not send a {@code CONFERENCE_LEFT} event to the native
|
||||
* counterpart of the External API.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @param {Action} action - The redux action which is causing the sending of the
|
||||
* event.
|
||||
* @param {Object} data - The details/specifics of the event to send determined
|
||||
* by/associated with the specified {@code action}.
|
||||
* @returns {boolean} If the specified event is to not be sent, {@code true};
|
||||
* otherwise, {@code false}.
|
||||
*/
|
||||
function _swallowConferenceLeft({ getState }: IStore, action: AnyAction, { url }: { url: string; }) {
|
||||
// XXX Internally, we work with JitsiConference instances. Externally
|
||||
// though, we deal with URL strings. The relation between the two is many to
|
||||
// one so it's technically and practically possible (by externally loading
|
||||
// the same URL string multiple times) to try to send CONFERENCE_LEFT
|
||||
// externally for a URL string which identifies a JitsiConference that the
|
||||
// app is internally legitimately working with.
|
||||
let swallowConferenceLeft = false;
|
||||
|
||||
url
|
||||
&& forEachConference(getState, (conference, conferenceURL) => {
|
||||
if (conferenceURL && conferenceURL.toString() === url) {
|
||||
swallowConferenceLeft = true;
|
||||
}
|
||||
|
||||
return !swallowConferenceLeft;
|
||||
});
|
||||
|
||||
return swallowConferenceLeft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether to not send a specific event to the native counterpart of
|
||||
* the External API.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @param {Action} action - The redux action which is causing the sending of the
|
||||
* event.
|
||||
* @param {Object} data - The details/specifics of the event to send determined
|
||||
* by/associated with the specified {@code action}.
|
||||
* @returns {boolean} If the specified event is to not be sent, {@code true};
|
||||
* otherwise, {@code false}.
|
||||
*/
|
||||
function _swallowEvent(store: IStore, action: AnyAction, data: any) {
|
||||
switch (action.type) {
|
||||
case CONFERENCE_LEFT:
|
||||
return _swallowConferenceLeft(store, action, data);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
25
react/features/mobile/external-api/reducer.ts
Normal file
25
react/features/mobile/external-api/reducer.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import ReducerRegistry from '../../base/redux/ReducerRegistry';
|
||||
|
||||
import { SCREEN_SHARE_PARTICIPANTS_UPDATED } from './actionTypes';
|
||||
|
||||
export interface IMobileExternalApiState {
|
||||
screenShares: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
screenShares: []
|
||||
};
|
||||
|
||||
ReducerRegistry.register<IMobileExternalApiState>('features/mobile/external-api',
|
||||
(state = DEFAULT_STATE, action): IMobileExternalApiState => {
|
||||
switch (action.type) {
|
||||
case SCREEN_SHARE_PARTICIPANTS_UPDATED: {
|
||||
return {
|
||||
...state,
|
||||
screenShares: action.participantIds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
12
react/features/mobile/full-screen/actionTypes.ts
Normal file
12
react/features/mobile/full-screen/actionTypes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The type of (redux) action to set the react-native-immersive's change event
|
||||
* subscription.
|
||||
*
|
||||
* {
|
||||
* type: _SET_IMMERSIVE_SUBSCRIPTION,
|
||||
* subscription: Function
|
||||
* }
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
export const _SET_IMMERSIVE_SUBSCRIPTION = '_SET_IMMERSIVE_SUBSCRIPTION';
|
||||
21
react/features/mobile/full-screen/actions.ts
Normal file
21
react/features/mobile/full-screen/actions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NativeEventSubscription } from 'react-native';
|
||||
|
||||
import { _SET_IMMERSIVE_SUBSCRIPTION } from './actionTypes';
|
||||
|
||||
/**
|
||||
* Sets the change event listener to be used with react-native-immersive's API.
|
||||
*
|
||||
* @param {Function} subscription - The function to be used with
|
||||
* react-native-immersive's API as the change event listener.
|
||||
* @protected
|
||||
* @returns {{
|
||||
* type: _SET_IMMERSIVE_SUBSCRIPTION,
|
||||
* subscription: ?NativeEventSubscription
|
||||
* }}
|
||||
*/
|
||||
export function _setImmersiveSubscription(subscription?: NativeEventSubscription) {
|
||||
return {
|
||||
type: _SET_IMMERSIVE_SUBSCRIPTION,
|
||||
subscription
|
||||
};
|
||||
}
|
||||
22
react/features/mobile/full-screen/functions.ts
Normal file
22
react/features/mobile/full-screen/functions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IReduxState } from '../../app/types';
|
||||
import { getCurrentConference } from '../../base/conference/functions';
|
||||
import { isAnyDialogOpen } from '../../base/dialog/functions';
|
||||
import { FULLSCREEN_ENABLED } from '../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../base/flags/functions';
|
||||
import { isLocalVideoTrackDesktop } from '../../base/tracks/functions.any';
|
||||
|
||||
/**
|
||||
* Checks whether full-screen state should be used or not.
|
||||
*
|
||||
* @param {IReduxState} state - The redux state.
|
||||
* @returns {boolean} - Whether full-screen state should be used or not.
|
||||
*/
|
||||
export function shouldUseFullScreen(state: IReduxState) {
|
||||
const { enabled: audioOnly } = state['features/base/audio-only'];
|
||||
const conference = getCurrentConference(state);
|
||||
const dialogOpen = isAnyDialogOpen(state);
|
||||
const fullscreenEnabled = getFeatureFlag(state, FULLSCREEN_ENABLED, true);
|
||||
const isDesktopSharing = isLocalVideoTrackDesktop(state);
|
||||
|
||||
return conference ? !audioOnly && !dialogOpen && !isDesktopSharing && fullscreenEnabled : false;
|
||||
}
|
||||
3
react/features/mobile/full-screen/logger.ts
Normal file
3
react/features/mobile/full-screen/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../../base/logging/functions';
|
||||
|
||||
export default getLogger('features/full-screen');
|
||||
102
react/features/mobile/full-screen/middleware.ts
Normal file
102
react/features/mobile/full-screen/middleware.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import ImmersiveMode from 'react-native-immersive-mode';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app/actionTypes';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../../base/redux/StateListenerRegistry';
|
||||
|
||||
import { _setImmersiveSubscription } from './actions';
|
||||
import { shouldUseFullScreen } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
type BarVisibilityType = {
|
||||
navigationBottomBar: boolean;
|
||||
statusBar: boolean;
|
||||
};
|
||||
|
||||
type ImmersiveListener = (visibility: BarVisibilityType) => void;
|
||||
|
||||
/**
|
||||
* Middleware that captures conference actions and activates or deactivates the
|
||||
* full screen mode. On iOS it hides the status bar, and on Android it uses the
|
||||
* immersive mode:
|
||||
* https://developer.android.com/training/system-ui/immersive.html
|
||||
* In immersive mode the status and navigation bars are hidden and thus the
|
||||
* entire screen will be covered by our application.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case APP_WILL_MOUNT: {
|
||||
_setImmersiveListener(store, _onImmersiveChange.bind(undefined, store));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case APP_WILL_UNMOUNT:
|
||||
_setImmersiveListener(store, undefined);
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ shouldUseFullScreen,
|
||||
/* listener */ fullScreen => _setFullScreen(fullScreen)
|
||||
);
|
||||
|
||||
/**
|
||||
* Handler for Immersive mode changes. This will be called when Android's
|
||||
* immersive mode changes. This can happen without us wanting, so re-evaluate if
|
||||
* immersive mode is desired and reactivate it if needed.
|
||||
*
|
||||
* @param {Object} store - The redux store.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onImmersiveChange({ getState }: IStore) {
|
||||
const state = getState();
|
||||
const { appState } = state['features/mobile/background'];
|
||||
|
||||
if (appState === 'active') {
|
||||
_setFullScreen(shouldUseFullScreen(state));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates/deactivates the full screen mode. On iOS it will hide the status
|
||||
* bar, and on Android it will turn immersive mode on.
|
||||
*
|
||||
* @param {boolean} fullScreen - True to set full screen mode, false to
|
||||
* deactivate it.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _setFullScreen(fullScreen: boolean) {
|
||||
logger.info(`Setting full-screen mode: ${fullScreen}`);
|
||||
ImmersiveMode.fullLayout(fullScreen);
|
||||
ImmersiveMode.setBarMode(fullScreen ? 'Full' : 'Normal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the feature filmstrip that the action
|
||||
* {@link _SET_IMMERSIVE_LISTENER} is being dispatched within a specific redux
|
||||
* store.
|
||||
*
|
||||
* @param {Store} store - The redux store in which the specified action is being
|
||||
* dispatched.
|
||||
* @param {Function} listener - Listener for immersive state.
|
||||
* @private
|
||||
* @returns {Object} The value returned by {@code next(action)}.
|
||||
*/
|
||||
function _setImmersiveListener({ dispatch, getState }: IStore, listener?: ImmersiveListener) {
|
||||
const { subscription } = getState()['features/full-screen'];
|
||||
|
||||
subscription?.remove();
|
||||
|
||||
dispatch(_setImmersiveSubscription(listener ? ImmersiveMode.addEventListener(listener) : undefined));
|
||||
}
|
||||
21
react/features/mobile/full-screen/reducer.ts
Normal file
21
react/features/mobile/full-screen/reducer.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NativeEventSubscription } from 'react-native';
|
||||
|
||||
import ReducerRegistry from '../../base/redux/ReducerRegistry';
|
||||
|
||||
import { _SET_IMMERSIVE_SUBSCRIPTION } from './actionTypes';
|
||||
|
||||
export interface IFullScreenState {
|
||||
subscription?: NativeEventSubscription;
|
||||
}
|
||||
|
||||
ReducerRegistry.register<IFullScreenState>('features/full-screen', (state = {}, action): IFullScreenState => {
|
||||
switch (action.type) {
|
||||
case _SET_IMMERSIVE_SUBSCRIPTION:
|
||||
return {
|
||||
...state,
|
||||
subscription: action.subscription
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
1
react/features/mobile/navigation/actionTypes.ts
Normal file
1
react/features/mobile/navigation/actionTypes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const _ROOT_NAVIGATION_READY = '_ROOT_NAVIGATION_READY';
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SafeAreaView, Text, View, ViewStyle } from 'react-native';
|
||||
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
|
||||
|
||||
import { TEXT_COLOR, navigationStyles } from './styles';
|
||||
|
||||
|
||||
const ConnectingPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<JitsiScreen style = { navigationStyles.connectingScreenContainer }>
|
||||
<View style = { navigationStyles.connectingScreenContent as ViewStyle }>
|
||||
<SafeAreaView>
|
||||
<LoadingIndicator
|
||||
color = { TEXT_COLOR }
|
||||
size = 'large' />
|
||||
<Text style = { navigationStyles.connectingScreenText }>
|
||||
{ t('connectingOverlay.joiningRoom') }
|
||||
</Text>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</JitsiScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectingPage;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { GestureResponderEvent } from 'react-native';
|
||||
|
||||
import { StyleType } from '../../../base/styles/functions.native';
|
||||
import Button from '../../../base/ui/components/native/Button';
|
||||
import IconButton from '../../../base/ui/components/native/IconButton';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
|
||||
import { navigationStyles } from './styles';
|
||||
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Icon button color.
|
||||
*/
|
||||
color?: string;
|
||||
|
||||
/**
|
||||
* Is the button disabled?
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* ID of the header navigation button.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Label of the button.
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* Callback to invoke when the {@code HeaderNavigationButton} is clicked/pressed.
|
||||
*/
|
||||
onPress?: (e?: GestureResponderEvent | React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* The ImageSource to be rendered as image.
|
||||
*/
|
||||
src?: any;
|
||||
|
||||
/**
|
||||
* Style of the button.
|
||||
*/
|
||||
style?: StyleType;
|
||||
|
||||
/**
|
||||
* Header has two actions.
|
||||
*/
|
||||
twoActions?: boolean;
|
||||
}
|
||||
|
||||
const HeaderNavigationButton = ({ color, id, disabled, label, onPress, src, style, twoActions }: IProps) => {
|
||||
|
||||
let btnStyle;
|
||||
let labelStyle;
|
||||
|
||||
if (disabled) {
|
||||
btnStyle = navigationStyles.headerNavigationButtonDisabled;
|
||||
labelStyle = twoActions
|
||||
? navigationStyles.headerNavigationButtonLabelBoldDisabled
|
||||
: navigationStyles.headerNavigationButtonLabelDisabled;
|
||||
} else {
|
||||
btnStyle = navigationStyles.headerNavigationButton;
|
||||
labelStyle = twoActions
|
||||
? navigationStyles.headerNavigationButtonLabelBold
|
||||
: navigationStyles.headerNavigationButtonLabel;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
src ? (
|
||||
<IconButton
|
||||
color = { color }
|
||||
id = { id }
|
||||
onPress = { onPress }
|
||||
size = { 24 }
|
||||
src = { src }
|
||||
style = { [
|
||||
navigationStyles.headerNavigationButtonIcon,
|
||||
style
|
||||
] } />
|
||||
) : (
|
||||
<Button
|
||||
disabled = { disabled }
|
||||
id = { id }
|
||||
labelKey = { label }
|
||||
labelStyle = { labelStyle }
|
||||
onClick = { onPress }
|
||||
style = { [
|
||||
btnStyle,
|
||||
style
|
||||
] }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderNavigationButton;
|
||||
@@ -0,0 +1,136 @@
|
||||
import { NavigationContainer, Theme } from '@react-navigation/native';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StatusBar } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import DialInSummary from '../../../invite/components/dial-in-summary/native/DialInSummary';
|
||||
import Prejoin from '../../../prejoin/components/native/Prejoin';
|
||||
import UnsafeRoomWarning from '../../../prejoin/components/native/UnsafeRoomWarning';
|
||||
import { isUnsafeRoomWarningEnabled } from '../../../prejoin/functions';
|
||||
import VisitorsQueue from '../../../visitors/components/native/VisitorsQueue';
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
import WelcomePage from '../../../welcome/components/WelcomePage';
|
||||
import { isWelcomePageEnabled } from '../../../welcome/functions';
|
||||
import { _ROOT_NAVIGATION_READY } from '../actionTypes';
|
||||
import { rootNavigationRef } from '../rootNavigationContainerRef';
|
||||
import { screen } from '../routes';
|
||||
import {
|
||||
conferenceNavigationContainerScreenOptions,
|
||||
connectingScreenOptions,
|
||||
dialInSummaryScreenOptions,
|
||||
navigationContainerTheme,
|
||||
preJoinScreenOptions,
|
||||
unsafeMeetingScreenOptions,
|
||||
visitorsScreenOptions,
|
||||
welcomeScreenOptions
|
||||
} from '../screenOptions';
|
||||
|
||||
import ConnectingPage from './ConnectingPage';
|
||||
import ConferenceNavigationContainer
|
||||
from './conference/components/ConferenceNavigationContainer';
|
||||
|
||||
const RootStack = createStackNavigator();
|
||||
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Is unsafe room warning available?
|
||||
*/
|
||||
isUnsafeRoomWarningAvailable: boolean;
|
||||
|
||||
/**
|
||||
* Is welcome page available?
|
||||
*/
|
||||
isWelcomePageAvailable: boolean;
|
||||
}
|
||||
|
||||
|
||||
const RootNavigationContainer = ({ dispatch, isUnsafeRoomWarningAvailable, isWelcomePageAvailable }: IProps) => {
|
||||
const initialRouteName = isWelcomePageAvailable
|
||||
? screen.welcome.main : screen.connecting;
|
||||
const onReady = useCallback(() => {
|
||||
dispatch({
|
||||
type: _ROOT_NAVIGATION_READY,
|
||||
ready: true
|
||||
});
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
independent = { true }
|
||||
onReady = { onReady }
|
||||
ref = { rootNavigationRef }
|
||||
theme = { navigationContainerTheme as Theme }>
|
||||
<StatusBar
|
||||
animated = { true }
|
||||
backgroundColor = 'transparent'
|
||||
barStyle = { 'light-content' }
|
||||
translucent = { true } />
|
||||
<RootStack.Navigator
|
||||
initialRouteName = { initialRouteName }>
|
||||
{
|
||||
isWelcomePageAvailable
|
||||
&& <>
|
||||
<RootStack.Screen // @ts-ignore
|
||||
component = { WelcomePage }
|
||||
name = { screen.welcome.main }
|
||||
options = { welcomeScreenOptions } />
|
||||
<RootStack.Screen
|
||||
|
||||
// @ts-ignore
|
||||
component = { DialInSummary }
|
||||
name = { screen.dialInSummary }
|
||||
options = { dialInSummaryScreenOptions } />
|
||||
</>
|
||||
}
|
||||
<RootStack.Screen
|
||||
component = { ConnectingPage }
|
||||
name = { screen.connecting }
|
||||
options = { connectingScreenOptions } />
|
||||
<RootStack.Screen
|
||||
component = { Prejoin }
|
||||
name = { screen.preJoin }
|
||||
options = { preJoinScreenOptions } />
|
||||
{
|
||||
isUnsafeRoomWarningAvailable
|
||||
&& <RootStack.Screen
|
||||
component = { UnsafeRoomWarning }
|
||||
name = { screen.unsafeRoomWarning }
|
||||
options = { unsafeMeetingScreenOptions } />
|
||||
}
|
||||
<RootStack.Screen
|
||||
component = { VisitorsQueue }
|
||||
name = { screen.visitorsQueue }
|
||||
options = { visitorsScreenOptions } />
|
||||
<RootStack.Screen
|
||||
component = { ConferenceNavigationContainer }
|
||||
name = { screen.conference.root }
|
||||
options = { conferenceNavigationContainerScreenOptions } />
|
||||
</RootStack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps part of the Redux store to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
isUnsafeRoomWarningAvailable: isUnsafeRoomWarningEnabled(state),
|
||||
isWelcomePageAvailable: isWelcomePageEnabled(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(RootNavigationContainer);
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { StyleProp, Text, TextStyle, View } from 'react-native';
|
||||
|
||||
import { navigationStyles } from './styles';
|
||||
|
||||
interface ITabBarLabelCounterProps {
|
||||
activeUnreadNr: boolean;
|
||||
isFocused: boolean;
|
||||
label: string;
|
||||
nbUnread?: number;
|
||||
}
|
||||
|
||||
export const TabBarLabelCounter = ({ activeUnreadNr, isFocused, label, nbUnread }: ITabBarLabelCounterProps) => {
|
||||
const labelStyles = isFocused
|
||||
? navigationStyles.unreadCounterDescriptionFocused
|
||||
: navigationStyles.unreadCounterDescription;
|
||||
|
||||
return (
|
||||
<View
|
||||
style = {
|
||||
navigationStyles.unreadCounterContainer as StyleProp<TextStyle> }>
|
||||
<Text
|
||||
style = { labelStyles }>
|
||||
{ label && label }
|
||||
</Text>
|
||||
{
|
||||
activeUnreadNr && (
|
||||
<View
|
||||
style = { navigationStyles.unreadCounterCircle as StyleProp<TextStyle> }>
|
||||
<Text
|
||||
style = { navigationStyles.unreadCounter as StyleProp<TextStyle> }>
|
||||
{ nbUnread }
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable lines-around-comment */
|
||||
|
||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../../app/types';
|
||||
import {
|
||||
getClientHeight,
|
||||
getClientWidth
|
||||
} from '../../../../../base/modal/components/functions';
|
||||
import { setFocusedTab } from '../../../../../chat/actions.any';
|
||||
import Chat from '../../../../../chat/components/native/Chat';
|
||||
import { ChatTabs } from '../../../../../chat/constants';
|
||||
import { resetNbUnreadPollsMessages } from '../../../../../polls/actions';
|
||||
import PollsPane from '../../../../../polls/components/native/PollsPane';
|
||||
import { screen } from '../../../routes';
|
||||
import { chatTabBarOptions } from '../../../screenOptions';
|
||||
|
||||
const ChatTab = createMaterialTopTabNavigator();
|
||||
|
||||
const ChatAndPolls = () => {
|
||||
const clientHeight = useSelector(getClientHeight);
|
||||
const clientWidth = useSelector(getClientWidth);
|
||||
const dispatch = useDispatch();
|
||||
const { focusedTab } = useSelector((state: IReduxState) => state['features/chat']);
|
||||
const initialRouteName = focusedTab === ChatTabs.POLLS
|
||||
? screen.conference.chatandpolls.tab.polls
|
||||
: screen.conference.chatandpolls.tab.chat;
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<ChatTab.Navigator
|
||||
backBehavior = 'none'
|
||||
initialLayout = {{
|
||||
height: clientHeight,
|
||||
width: clientWidth
|
||||
}}
|
||||
initialRouteName = { initialRouteName }
|
||||
screenOptions = { chatTabBarOptions }>
|
||||
<ChatTab.Screen
|
||||
component = { Chat }
|
||||
listeners = {{
|
||||
tabPress: () => {
|
||||
dispatch(setFocusedTab(ChatTabs.CHAT));
|
||||
}
|
||||
}}
|
||||
name = { screen.conference.chatandpolls.tab.chat } />
|
||||
<ChatTab.Screen
|
||||
component = { PollsPane }
|
||||
listeners = {{
|
||||
tabPress: () => {
|
||||
dispatch(setFocusedTab(ChatTabs.POLLS));
|
||||
dispatch(resetNbUnreadPollsMessages);
|
||||
}
|
||||
}}
|
||||
name = { screen.conference.chatandpolls.tab.polls } />
|
||||
</ChatTab.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatAndPolls;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NavigationContainerRef } from '@react-navigation/native';
|
||||
import React from 'react';
|
||||
|
||||
export const conferenceNavigationRef = React.createRef<NavigationContainerRef<any>>();
|
||||
|
||||
/**
|
||||
* User defined navigation action included inside the reference to the container.
|
||||
*
|
||||
* @param {string} name - Destination name of the route that has been defined somewhere.
|
||||
* @param {Object} params - Params to pass to the destination route.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function navigate(name: string, params?: Object) {
|
||||
return conferenceNavigationRef.current?.navigate(name, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* User defined navigation action included inside the reference to the container.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function goBack() {
|
||||
return conferenceNavigationRef.current?.goBack();
|
||||
}
|
||||
|
||||
/**
|
||||
* User defined navigation action included inside the reference to the container.
|
||||
*
|
||||
* @param {Object} params - Params to pass to the destination route.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function setParams(params: Object) {
|
||||
return conferenceNavigationRef.current?.setParams(params);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/* eslint-disable lines-around-comment */
|
||||
|
||||
import { NavigationContainer, Theme } from '@react-navigation/native';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import BreakoutRooms
|
||||
// @ts-ignore
|
||||
from '../../../../../breakout-rooms/components/native/BreakoutRooms';
|
||||
// @ts-ignore
|
||||
import Chat from '../../../../../chat/components/native/Chat';
|
||||
// @ts-ignore
|
||||
import Conference from '../../../../../conference/components/native/Conference';
|
||||
// @ts-ignore
|
||||
import CarMode from '../../../../../conference/components/native/carmode/CarMode';
|
||||
// @ts-ignore
|
||||
import { arePollsDisabled } from '../../../../../conference/functions';
|
||||
// @ts-ignore
|
||||
import SharedDocument from '../../../../../etherpad/components/native/SharedDocument';
|
||||
// @ts-ignore
|
||||
import GifsMenu from '../../../../../gifs/components/native/GifsMenu';
|
||||
import AddPeopleDialog
|
||||
// @ts-ignore
|
||||
from '../../../../../invite/components/add-people-dialog/native/AddPeopleDialog';
|
||||
// @ts-ignore
|
||||
import ParticipantsPane from '../../../../../participants-pane/components/native/ParticipantsPane';
|
||||
// @ts-ignore
|
||||
import StartLiveStreamDialog from '../../../../../recording/components/LiveStream/native/StartLiveStreamDialog';
|
||||
import StartRecordingDialog
|
||||
// @ts-ignore
|
||||
from '../../../../../recording/components/Recording/native/StartRecordingDialog';
|
||||
import SalesforceLinkDialog
|
||||
// @ts-ignore
|
||||
from '../../../../../salesforce/components/native/SalesforceLinkDialog';
|
||||
import SecurityDialog
|
||||
// @ts-ignore
|
||||
from '../../../../../security/components/security-dialog/native/SecurityDialog';
|
||||
import SpeakerStats
|
||||
// @ts-ignore
|
||||
from '../../../../../speaker-stats/components/native/SpeakerStats';
|
||||
import LanguageSelectorDialog
|
||||
// @ts-ignore
|
||||
from '../../../../../subtitles/components/native/LanguageSelectorDialog';
|
||||
import Whiteboard from '../../../../../whiteboard/components/native/Whiteboard';
|
||||
// @ts-ignore
|
||||
import { screen } from '../../../routes';
|
||||
import {
|
||||
breakoutRoomsScreenOptions,
|
||||
carmodeScreenOptions,
|
||||
chatScreenOptions,
|
||||
conferenceScreenOptions,
|
||||
gifsMenuOptions,
|
||||
inviteScreenOptions,
|
||||
liveStreamScreenOptions,
|
||||
lobbyNavigationContainerScreenOptions,
|
||||
navigationContainerTheme,
|
||||
participantsScreenOptions,
|
||||
recordingScreenOptions,
|
||||
salesforceScreenOptions,
|
||||
securityScreenOptions,
|
||||
settingsNavigationContainerScreenOptions,
|
||||
sharedDocumentScreenOptions,
|
||||
speakerStatsScreenOptions,
|
||||
subtitlesScreenOptions,
|
||||
whiteboardScreenOptions
|
||||
// @ts-ignore
|
||||
} from '../../../screenOptions';
|
||||
// @ts-ignore
|
||||
import ChatAndPollsNavigator from '../../chat/components/ChatAndPollsNavigator';
|
||||
// @ts-ignore
|
||||
import LobbyNavigationContainer from '../../lobby/components/LobbyNavigationContainer';
|
||||
// @ts-ignore
|
||||
import SettingsNavigationContainer from '../../settings/components/SettingsNavigationContainer';
|
||||
import {
|
||||
conferenceNavigationRef
|
||||
// @ts-ignore
|
||||
} from '../ConferenceNavigationContainerRef';
|
||||
|
||||
|
||||
const ConferenceStack = createStackNavigator();
|
||||
|
||||
|
||||
const ConferenceNavigationContainer = () => {
|
||||
const isPollsDisabled = useSelector(arePollsDisabled);
|
||||
let ChatScreen;
|
||||
let chatScreenName;
|
||||
let chatTitleString;
|
||||
|
||||
if (isPollsDisabled) {
|
||||
ChatScreen = Chat;
|
||||
chatScreenName = screen.conference.chat;
|
||||
chatTitleString = 'chat.title';
|
||||
} else {
|
||||
ChatScreen = ChatAndPollsNavigator;
|
||||
chatScreenName = screen.conference.chatandpolls.main;
|
||||
chatTitleString = 'chat.titleWithPolls';
|
||||
}
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
independent = { true }
|
||||
ref = { conferenceNavigationRef }
|
||||
theme = { navigationContainerTheme as Theme }>
|
||||
<ConferenceStack.Navigator
|
||||
screenOptions = {{
|
||||
presentation: 'modal'
|
||||
}}>
|
||||
<ConferenceStack.Screen
|
||||
component = { Conference }
|
||||
name = { screen.conference.main }
|
||||
options = { conferenceScreenOptions } />
|
||||
<ConferenceStack.Screen
|
||||
component = { ChatScreen }
|
||||
name = { chatScreenName }
|
||||
options = {{
|
||||
...chatScreenOptions,
|
||||
title: t(chatTitleString)
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { ParticipantsPane }
|
||||
name = { screen.conference.participants }
|
||||
options = {{
|
||||
...participantsScreenOptions,
|
||||
title: t('participantsPane.title')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { SecurityDialog }
|
||||
name = { screen.conference.security }
|
||||
options = {{
|
||||
...securityScreenOptions,
|
||||
title: t('security.title')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { StartRecordingDialog }
|
||||
name = { screen.conference.recording }
|
||||
options = {{
|
||||
...recordingScreenOptions,
|
||||
title: t('recording.title')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { StartLiveStreamDialog }
|
||||
name = { screen.conference.liveStream }
|
||||
options = {{
|
||||
...liveStreamScreenOptions,
|
||||
title: t('liveStreaming.title')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { SpeakerStats }
|
||||
name = { screen.conference.speakerStats }
|
||||
options = {{
|
||||
...speakerStatsScreenOptions,
|
||||
title: t('speakerStats.speakerStats')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { SalesforceLinkDialog }
|
||||
name = { screen.conference.salesforce }
|
||||
options = {{
|
||||
...salesforceScreenOptions,
|
||||
title: t('notify.linkToSalesforce')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { GifsMenu }
|
||||
name = { screen.conference.gifsMenu }
|
||||
options = {{
|
||||
...gifsMenuOptions,
|
||||
title: t('notify.gifsMenu')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { LobbyNavigationContainer }
|
||||
name = { screen.lobby.root }
|
||||
options = {{
|
||||
...lobbyNavigationContainerScreenOptions,
|
||||
title: t('lobby.title')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { AddPeopleDialog }
|
||||
name = { screen.conference.invite }
|
||||
options = {{
|
||||
...inviteScreenOptions,
|
||||
title: t('addPeople.add')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { SharedDocument }
|
||||
name = { screen.conference.sharedDocument }
|
||||
options = {{
|
||||
...sharedDocumentScreenOptions,
|
||||
title: t('documentSharing.title')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
// @ts-ignore
|
||||
component = { SettingsNavigationContainer }
|
||||
name = { screen.settings.main }
|
||||
options = { settingsNavigationContainerScreenOptions } />
|
||||
<ConferenceStack.Screen
|
||||
// @ts-ignore
|
||||
component = { CarMode }
|
||||
name = { screen.conference.carmode }
|
||||
options = {{
|
||||
...carmodeScreenOptions,
|
||||
title: t('carmode.labels.title')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { LanguageSelectorDialog }
|
||||
name = { screen.conference.subtitles }
|
||||
options = {{
|
||||
...subtitlesScreenOptions,
|
||||
title: t('transcribing.subtitles')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
component = { BreakoutRooms }
|
||||
name = { screen.conference.breakoutRooms }
|
||||
options = {{
|
||||
...breakoutRoomsScreenOptions,
|
||||
title: t('breakoutRooms.title')
|
||||
}} />
|
||||
<ConferenceStack.Screen
|
||||
// @ts-ignore
|
||||
component = { Whiteboard }
|
||||
name = { screen.conference.whiteboard }
|
||||
options = {{
|
||||
...whiteboardScreenOptions,
|
||||
title: t('whiteboard.screenTitle')
|
||||
}} />
|
||||
</ConferenceStack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConferenceNavigationContainer;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NavigationContainerRef } from '@react-navigation/native';
|
||||
import React from 'react';
|
||||
|
||||
export const lobbyNavigationContainerRef = React.createRef<NavigationContainerRef<any>>();
|
||||
|
||||
/**
|
||||
* User defined navigation action included inside the reference to the container.
|
||||
*
|
||||
* @param {string} name - Destination name of the route that has been defined somewhere.
|
||||
* @param {Object} params - Params to pass to the destination route.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function navigate(name: string, params?: Object) {
|
||||
return lobbyNavigationContainerRef.current?.navigate(name, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* User defined navigation action included inside the reference to the container.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function goBack() {
|
||||
return lobbyNavigationContainerRef.current?.goBack();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../../app/types';
|
||||
import LobbyChatScreen from '../../../../../lobby/components/native/LobbyChatScreen';
|
||||
import LobbyScreen from '../../../../../lobby/components/native/LobbyScreen';
|
||||
import { screen } from '../../../routes';
|
||||
import {
|
||||
lobbyChatScreenOptions,
|
||||
lobbyScreenOptions,
|
||||
navigationContainerTheme
|
||||
} from '../../../screenOptions';
|
||||
import { lobbyNavigationContainerRef } from '../LobbyNavigationContainerRef';
|
||||
|
||||
const LobbyStack = createStackNavigator();
|
||||
|
||||
|
||||
const LobbyNavigationContainer = () => {
|
||||
const { isLobbyChatActive }
|
||||
= useSelector((state: IReduxState) => state['features/chat']);
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
independent = { true }
|
||||
ref = { lobbyNavigationContainerRef }
|
||||
|
||||
// @ts-ignore
|
||||
theme = { navigationContainerTheme }>
|
||||
<LobbyStack.Navigator
|
||||
screenOptions = {{
|
||||
presentation: 'modal'
|
||||
}}>
|
||||
<LobbyStack.Screen
|
||||
component = { LobbyScreen }
|
||||
name = { screen.lobby.main }
|
||||
options = { lobbyScreenOptions } />
|
||||
{
|
||||
isLobbyChatActive
|
||||
&& <LobbyStack.Screen
|
||||
component = { LobbyChatScreen }
|
||||
name = { screen.lobby.chat }
|
||||
options = { lobbyChatScreenOptions } />
|
||||
}
|
||||
</LobbyStack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default LobbyNavigationContainer;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NavigationContainerRef } from '@react-navigation/native';
|
||||
import React from 'react';
|
||||
|
||||
export const settingsNavigationContainerRef = React.createRef<NavigationContainerRef<any>>();
|
||||
|
||||
/**
|
||||
* User defined navigation action included inside the reference to the container.
|
||||
*
|
||||
* @param {string} name - Destination name of the route that has been defined somewhere.
|
||||
* @param {Object} params - Params to pass to the destination route.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function navigate(name: string, params?: Object) {
|
||||
return settingsNavigationContainerRef.current?.navigate(name, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* User defined navigation action included inside the reference to the container.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function goBack() {
|
||||
return settingsNavigationContainerRef.current?.goBack();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NavigationContainer, Theme } from '@react-navigation/native';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import LanguageSelectView from '../../../../../settings/components/native/LanguageSelectView';
|
||||
import ProfileView from '../../../../../settings/components/native/ProfileView';
|
||||
import SettingsView
|
||||
from '../../../../../settings/components/native/SettingsView';
|
||||
import { screen } from '../../../routes';
|
||||
import {
|
||||
languageSelectScreenOptions,
|
||||
navigationContainerTheme,
|
||||
profileSettingsScreenOptions,
|
||||
settingsScreenOptions,
|
||||
welcomeScreenOptions
|
||||
} from '../../../screenOptions';
|
||||
import {
|
||||
settingsNavigationContainerRef
|
||||
} from '../SettingsNavigationContainerRef';
|
||||
|
||||
const SettingsStack = createStackNavigator();
|
||||
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link SettingsNavigationContainer}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Is the navigator part of Welcome page?
|
||||
*/
|
||||
isInWelcomePage?: boolean | undefined;
|
||||
}
|
||||
|
||||
|
||||
const SettingsNavigationContainer = ({ isInWelcomePage }: IProps) => {
|
||||
const baseSettingsScreenOptions = isInWelcomePage ? welcomeScreenOptions : settingsScreenOptions;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const SettingsScreen = useCallback(() =>
|
||||
(
|
||||
<SettingsView
|
||||
isInWelcomePage = { isInWelcomePage } />
|
||||
), []);
|
||||
|
||||
const ProfileScreen = useCallback(() =>
|
||||
(<ProfileView
|
||||
isInWelcomePage = { isInWelcomePage } />)
|
||||
, []);
|
||||
|
||||
const LanguageSelectScreen = useCallback(() =>
|
||||
(<LanguageSelectView
|
||||
isInWelcomePage = { isInWelcomePage } />)
|
||||
, []);
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
independent = { true }
|
||||
ref = { settingsNavigationContainerRef }
|
||||
theme = { navigationContainerTheme as Theme }>
|
||||
<SettingsStack.Navigator
|
||||
initialRouteName = { screen.settings.main }>
|
||||
<SettingsStack.Screen
|
||||
name = { screen.settings.main }
|
||||
options = {{
|
||||
...baseSettingsScreenOptions,
|
||||
title: t('settings.title')
|
||||
}}>
|
||||
{ SettingsScreen }
|
||||
</SettingsStack.Screen>
|
||||
<SettingsStack.Screen
|
||||
component = { ProfileScreen }
|
||||
name = { screen.settings.profile }
|
||||
options = {{
|
||||
...profileSettingsScreenOptions,
|
||||
title: t('settingsView.profileSection')
|
||||
}} />
|
||||
<SettingsStack.Screen
|
||||
component = { LanguageSelectScreen }
|
||||
name = { screen.settings.language }
|
||||
options = {{
|
||||
...languageSelectScreenOptions,
|
||||
title: t('settings.language')
|
||||
}} />
|
||||
</SettingsStack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsNavigationContainer;
|
||||
114
react/features/mobile/navigation/components/styles.ts
Normal file
114
react/features/mobile/navigation/components/styles.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { BoxModel } from '../../../base/styles/components/styles/BoxModel';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
|
||||
export const TEXT_COLOR = BaseTheme.palette.text01;
|
||||
|
||||
const unreadCounterDescription = {
|
||||
...BaseTheme.typography.bodyShortBoldLarge,
|
||||
color: BaseTheme.palette.text03
|
||||
};
|
||||
|
||||
const HEADER_ACTION_BUTTON_SIZE = 16;
|
||||
|
||||
const headerNavigationButtonLabel = {
|
||||
color: BaseTheme.palette.link01,
|
||||
fontSize: HEADER_ACTION_BUTTON_SIZE,
|
||||
lineHeight: BaseTheme.spacing[3]
|
||||
};
|
||||
|
||||
const headerNavigationButton = {
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
height: BaseTheme.spacing[6],
|
||||
marginLeft: BaseTheme.spacing[3]
|
||||
};
|
||||
|
||||
/**
|
||||
* Styles of the navigation feature.
|
||||
*/
|
||||
export const navigationStyles = {
|
||||
|
||||
connectingScreenContainer: {
|
||||
backgroundColor: BaseTheme.palette.uiBackground,
|
||||
flex: 1
|
||||
},
|
||||
|
||||
connectingScreenContent: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
connectingScreenIndicator: {
|
||||
margin: BoxModel.margin
|
||||
},
|
||||
|
||||
connectingScreenText: {
|
||||
color: TEXT_COLOR
|
||||
},
|
||||
|
||||
headerNavigationButton: {
|
||||
...headerNavigationButton
|
||||
},
|
||||
|
||||
headerNavigationButtonIcon: {
|
||||
...headerNavigationButton,
|
||||
padding: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
headerNavigationButtonDisabled: {
|
||||
backgroundColor: 'transparent',
|
||||
marginLeft: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
headerNavigationButtonLabel: {
|
||||
...headerNavigationButtonLabel
|
||||
},
|
||||
|
||||
headerNavigationButtonLabelDisabled: {
|
||||
...headerNavigationButtonLabel,
|
||||
color: BaseTheme.palette.text03
|
||||
},
|
||||
|
||||
headerNavigationButtonLabelBold: {
|
||||
...headerNavigationButtonLabel,
|
||||
...BaseTheme.typography.bodyShortRegularLarge
|
||||
},
|
||||
|
||||
headerNavigationButtonLabelBoldDisabled: {
|
||||
...headerNavigationButtonLabel,
|
||||
...BaseTheme.typography.bodyShortRegularLarge,
|
||||
color: BaseTheme.palette.text03
|
||||
},
|
||||
|
||||
unreadCounterContainer: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'row'
|
||||
},
|
||||
|
||||
unreadCounterDescription: {
|
||||
...unreadCounterDescription
|
||||
},
|
||||
|
||||
unreadCounterDescriptionFocused: {
|
||||
...unreadCounterDescription,
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
unreadCounterCircle: {
|
||||
backgroundColor: BaseTheme.palette.warning01,
|
||||
borderRadius: BaseTheme.spacing[4] / 2,
|
||||
height: BaseTheme.spacing[4],
|
||||
justifyContent: 'center',
|
||||
marginLeft: BaseTheme.spacing[2],
|
||||
width: BaseTheme.spacing[4]
|
||||
},
|
||||
|
||||
unreadCounter: {
|
||||
...BaseTheme.typography.bodyShortBold,
|
||||
alignSelf: 'center',
|
||||
color: BaseTheme.palette.text04
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GestureResponderEvent } from 'react-native';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import CalendarList from '../../../../../calendar-sync/components/CalendarList.native';
|
||||
import { isCalendarEnabled } from '../../../../../calendar-sync/functions.native';
|
||||
import RecentList from '../../../../../recent-list/components/RecentList.native';
|
||||
import {
|
||||
calendarListTabBarOptions,
|
||||
recentListTabBarOptions,
|
||||
settingsTabBarOptions,
|
||||
tabBarOptions
|
||||
} from '../../../../../welcome/constants';
|
||||
import { screen } from '../../../routes';
|
||||
import SettingsNavigationContainer
|
||||
from '../../settings/components/SettingsNavigationContainer';
|
||||
|
||||
const WelcomePage = createBottomTabNavigator();
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link WelcomePageTabs}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Renders the lists disabled.
|
||||
*/
|
||||
disabled: boolean;
|
||||
|
||||
/**
|
||||
* Callback to be invoked when pressing the list container.
|
||||
*/
|
||||
onListContainerPress?: (e?: GestureResponderEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback to be invoked when settings screen is focused.
|
||||
*/
|
||||
onSettingsScreenFocused: Function;
|
||||
}
|
||||
|
||||
|
||||
const WelcomePageTabs = ({ disabled, onListContainerPress, onSettingsScreenFocused }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const RecentListScreen = useCallback(() =>
|
||||
(
|
||||
<RecentList
|
||||
disabled = { disabled }
|
||||
onListContainerPress = { onListContainerPress } />
|
||||
), []);
|
||||
|
||||
const calendarEnabled = useSelector(isCalendarEnabled);
|
||||
|
||||
const CalendarListScreen = useCallback(() =>
|
||||
(
|
||||
<CalendarList
|
||||
disabled = { disabled } />
|
||||
), []);
|
||||
|
||||
const SettingsScreen = useCallback(() =>
|
||||
(
|
||||
<SettingsNavigationContainer
|
||||
isInWelcomePage = { true } />
|
||||
), []);
|
||||
|
||||
return (
|
||||
<WelcomePage.Navigator
|
||||
backBehavior = { 'none' }
|
||||
screenOptions = {{
|
||||
...tabBarOptions,
|
||||
headerShown: false
|
||||
}}>
|
||||
<WelcomePage.Screen
|
||||
listeners = {{
|
||||
tabPress: () => {
|
||||
onSettingsScreenFocused(false);
|
||||
}
|
||||
}}
|
||||
name = { screen.welcome.tabs.recent }
|
||||
options = {{
|
||||
...recentListTabBarOptions,
|
||||
title: t('welcomepage.recentList')
|
||||
}}>
|
||||
{ RecentListScreen }
|
||||
</WelcomePage.Screen>
|
||||
{
|
||||
calendarEnabled
|
||||
&& <WelcomePage.Screen
|
||||
listeners = {{
|
||||
tabPress: () => {
|
||||
onSettingsScreenFocused(false);
|
||||
}
|
||||
}}
|
||||
name = { screen.welcome.tabs.calendar }
|
||||
options = {{
|
||||
...calendarListTabBarOptions,
|
||||
title: t('welcomepage.calendar')
|
||||
}}>
|
||||
{ CalendarListScreen }
|
||||
</WelcomePage.Screen>
|
||||
}
|
||||
<WelcomePage.Screen
|
||||
listeners = {{
|
||||
tabPress: () => {
|
||||
onSettingsScreenFocused(true);
|
||||
}
|
||||
}}
|
||||
name = { screen.settings.main }
|
||||
options = {{
|
||||
...settingsTabBarOptions,
|
||||
title: t('welcomepage.settings')
|
||||
}}>
|
||||
{ SettingsScreen }
|
||||
</WelcomePage.Screen>
|
||||
</WelcomePage.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomePageTabs;
|
||||
88
react/features/mobile/navigation/functions.tsx
Normal file
88
react/features/mobile/navigation/functions.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GestureResponderEvent, Platform } from 'react-native';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
|
||||
import { appNavigate } from '../../app/actions.native';
|
||||
import { IStateful } from '../../base/app/types';
|
||||
import { PREJOIN_PAGE_ENABLED } from '../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../base/flags/functions';
|
||||
import { IconCloseLarge } from '../../base/icons/svg';
|
||||
import { toState } from '../../base/redux/functions';
|
||||
import { cancelKnocking } from '../../lobby/actions.native';
|
||||
import { isPrejoinEnabledInConfig } from '../../prejoin/functions.native';
|
||||
|
||||
import HeaderNavigationButton from './components/HeaderNavigationButton';
|
||||
|
||||
|
||||
/**
|
||||
* Close icon/text button based on platform.
|
||||
*
|
||||
* @param {Function} goBack - Goes back to the previous screen function.
|
||||
* @returns {React.Component}
|
||||
*/
|
||||
export function screenHeaderCloseButton(goBack: (e?: GestureResponderEvent | React.MouseEvent) => void) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<HeaderNavigationButton
|
||||
id = { 'close-screen-button' }
|
||||
label = { t('dialog.close') }
|
||||
onPress = { goBack } />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HeaderNavigationButton
|
||||
id = { 'close-screen-button' }
|
||||
onPress = { goBack }
|
||||
src = { IconCloseLarge } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the {@code Prejoin page} is enabled by the app itself
|
||||
* (e.g. Programmatically via the Jitsi Meet SDK for Android and iOS).
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux state or {@link getState}
|
||||
* function.
|
||||
* @returns {boolean} If the {@code Prejoin} is enabled by the app, then
|
||||
* {@code true}; otherwise, {@code false}.
|
||||
*/
|
||||
export function isPrejoinPageEnabled(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
|
||||
return getFeatureFlag(state, PREJOIN_PAGE_ENABLED, isPrejoinEnabledInConfig(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close icon/text button for lobby screen based on platform.
|
||||
*
|
||||
* @returns {React.Component}
|
||||
*/
|
||||
export function lobbyScreenHeaderCloseButton() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const goBack = useCallback(() => {
|
||||
dispatch(cancelKnocking());
|
||||
dispatch(appNavigate(undefined));
|
||||
}, [ dispatch ]);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<HeaderNavigationButton
|
||||
id = { 'close-screen-button' }
|
||||
label = { t('dialog.close') }
|
||||
onPress = { goBack } />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HeaderNavigationButton
|
||||
id = { 'close-screen-button' }
|
||||
onPress = { goBack }
|
||||
src = { IconCloseLarge } />
|
||||
);
|
||||
}
|
||||
38
react/features/mobile/navigation/middleware.ts
Normal file
38
react/features/mobile/navigation/middleware.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { appNavigate } from '../../app/actions.native';
|
||||
import { IStore } from '../../app/types';
|
||||
import { CONFERENCE_FAILED } from '../../base/conference/actionTypes';
|
||||
import { JitsiConferenceErrors } from '../../base/lib-jitsi-meet';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
|
||||
case CONFERENCE_FAILED:
|
||||
return _conferenceFailed(store, next, action);
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Function to handle the conference failed event and navigate the user to the lobby screen
|
||||
* based on the failure reason.
|
||||
*
|
||||
* @param {Object} store - The Redux store.
|
||||
* @param {Function} next - The Redux next function.
|
||||
* @param {Object} action - The Redux action.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _conferenceFailed({ dispatch }: IStore, next: Function, action: AnyAction) {
|
||||
const { error } = action;
|
||||
|
||||
// We need to cover the case where knocking participant
|
||||
// is rejected from entering the conference
|
||||
if (error.name === JitsiConferenceErrors.CONFERENCE_ACCESS_DENIED) {
|
||||
dispatch(appNavigate(undefined));
|
||||
}
|
||||
|
||||
return next(action);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NavigationContainerRef } from '@react-navigation/native';
|
||||
import React from 'react';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import { IStateful } from '../../base/app/types';
|
||||
import { toState } from '../../base/redux/functions';
|
||||
import { isWelcomePageEnabled } from '../../welcome/functions';
|
||||
import { _sendReadyToClose } from '../external-api/functions';
|
||||
|
||||
import { screen } from './routes';
|
||||
|
||||
export const rootNavigationRef = React.createRef<NavigationContainerRef<any>>();
|
||||
|
||||
|
||||
/**
|
||||
* User defined navigation action included inside the reference to the container.
|
||||
*
|
||||
* @param {string} name - Destination name of the route that has been defined somewhere.
|
||||
* @param {Object} params - Params to pass to the destination route.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function navigateRoot(name: string, params?: Object) {
|
||||
return rootNavigationRef.current?.navigate(name, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* User defined navigation action included inside the reference to the container.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function goBack() {
|
||||
return rootNavigationRef.current?.goBack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates back to Welcome page, if it's available.
|
||||
*
|
||||
* @param {Object|Function} stateful - Either the whole Redux state object or the Redux store's {@code getState} method.
|
||||
* @param {Function} dispatch - Redux dispatch function.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function goBackToRoot(stateful: IStateful, dispatch: IStore['dispatch']) {
|
||||
const state = toState(stateful);
|
||||
|
||||
if (isWelcomePageEnabled(state)) {
|
||||
navigateRoot(screen.welcome.main);
|
||||
} else {
|
||||
// For JitsiSDK, WelcomePage is not available
|
||||
_sendReadyToClose(dispatch);
|
||||
}
|
||||
}
|
||||
55
react/features/mobile/navigation/routes.ts
Normal file
55
react/features/mobile/navigation/routes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export const screen = {
|
||||
conference: {
|
||||
breakoutRooms: 'Breakout Rooms',
|
||||
carmode: 'Car Mode',
|
||||
chat: 'Chat',
|
||||
chatandpolls: {
|
||||
main: 'Chat and Polls',
|
||||
tab: {
|
||||
chat: 'Chat',
|
||||
polls: 'Polls'
|
||||
}
|
||||
},
|
||||
container: 'Conference container',
|
||||
gifsMenu: 'GIPHY',
|
||||
invite: 'Invite',
|
||||
liveStream: 'Live stream',
|
||||
main: 'Conference',
|
||||
participants: 'Participants',
|
||||
root: 'Conference root',
|
||||
recording: 'Recording',
|
||||
salesforce: 'Link to Salesforce',
|
||||
security: 'Security Options',
|
||||
sharedDocument: 'Shared document',
|
||||
speakerStats: 'Speaker Stats',
|
||||
subtitles: 'Subtitles',
|
||||
whiteboard: 'Whiteboard'
|
||||
},
|
||||
connecting: 'Connecting',
|
||||
dialInSummary: 'Dial-In Info',
|
||||
preJoin: 'Pre-Join',
|
||||
lobby: {
|
||||
chat: 'Lobby chat',
|
||||
main: 'Lobby',
|
||||
root: 'Lobby root'
|
||||
},
|
||||
settings: {
|
||||
language: 'Language',
|
||||
links: {
|
||||
help: 'Help',
|
||||
privacy: 'Privacy',
|
||||
terms: 'Terms'
|
||||
},
|
||||
main: 'Settings',
|
||||
profile: 'Profile'
|
||||
},
|
||||
unsafeRoomWarning: 'Unsafe Room Warning',
|
||||
visitorsQueue: 'Visitors Queue',
|
||||
welcome: {
|
||||
main: 'Welcome',
|
||||
tabs: {
|
||||
calendar: 'Calendar',
|
||||
recent: 'Recent'
|
||||
}
|
||||
}
|
||||
};
|
||||
260
react/features/mobile/navigation/screenOptions.ts
Normal file
260
react/features/mobile/navigation/screenOptions.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { TransitionPresets } from '@react-navigation/stack';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import BaseTheme from '../../base/ui/components/BaseTheme.native';
|
||||
|
||||
import { goBack } from './components/conference/ConferenceNavigationContainerRef';
|
||||
import { goBack as goBackToLobbyScreen } from './components/lobby/LobbyNavigationContainerRef';
|
||||
import { lobbyScreenHeaderCloseButton, screenHeaderCloseButton } from './functions';
|
||||
import { goBack as goBackToWelcomeScreen } from './rootNavigationContainerRef';
|
||||
|
||||
|
||||
/**
|
||||
* Default modal transition for the current platform.
|
||||
*/
|
||||
export const modalPresentation = Platform.select({
|
||||
ios: TransitionPresets.ModalPresentationIOS,
|
||||
default: TransitionPresets.DefaultTransition
|
||||
});
|
||||
|
||||
/**
|
||||
* Screen options and transition types.
|
||||
*/
|
||||
export const fullScreenOptions = {
|
||||
...TransitionPresets.ModalTransition,
|
||||
gestureEnabled: false,
|
||||
headerShown: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigation container theme.
|
||||
*/
|
||||
export const navigationContainerTheme = {
|
||||
colors: {
|
||||
background: BaseTheme.palette.uiBackground
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for welcome page.
|
||||
*/
|
||||
export const welcomeScreenOptions = {
|
||||
...TransitionPresets.ModalTransition,
|
||||
gestureEnabled: false,
|
||||
headerShown: true,
|
||||
headerStyle: {
|
||||
backgroundColor: BaseTheme.palette.ui01
|
||||
},
|
||||
headerTitleStyle: {
|
||||
color: BaseTheme.palette.text01
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for conference.
|
||||
*/
|
||||
export const conferenceScreenOptions = fullScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for visitors queue.
|
||||
*/
|
||||
export const visitorsScreenOptions = fullScreenOptions;
|
||||
|
||||
/**
|
||||
* Tab bar options for chat screen.
|
||||
*/
|
||||
export const chatTabBarOptions = {
|
||||
swipeEnabled: false,
|
||||
tabBarIndicatorStyle: {
|
||||
backgroundColor: BaseTheme.palette.link01Active
|
||||
},
|
||||
tabBarStyle: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
borderBottomColor: BaseTheme.palette.ui06,
|
||||
borderBottomWidth: 0.4
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for presentation type modals.
|
||||
*/
|
||||
export const presentationScreenOptions = {
|
||||
...modalPresentation,
|
||||
headerBackTitleVisible: false,
|
||||
headerLeft: () => screenHeaderCloseButton(goBack),
|
||||
headerStatusBarHeight: 0,
|
||||
headerStyle: {
|
||||
backgroundColor: BaseTheme.palette.ui01
|
||||
},
|
||||
headerTitleStyle: {
|
||||
color: BaseTheme.palette.text01
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for breakout rooms screen.
|
||||
*/
|
||||
export const breakoutRoomsScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for car mode.
|
||||
*/
|
||||
export const carmodeScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for chat.
|
||||
*/
|
||||
export const chatScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Dial-IN Info screen options and transition types.
|
||||
*/
|
||||
export const dialInSummaryScreenOptions = {
|
||||
...presentationScreenOptions,
|
||||
headerLeft: () => screenHeaderCloseButton(goBackToWelcomeScreen)
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for invite modal.
|
||||
*/
|
||||
export const inviteScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for live stream modal.
|
||||
*/
|
||||
export const liveStreamScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for participants modal.
|
||||
*/
|
||||
export const participantsScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for speaker stats modal.
|
||||
*/
|
||||
export const speakerStatsScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for security options modal.
|
||||
*/
|
||||
export const securityScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for recording modal.
|
||||
*/
|
||||
export const recordingScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for subtitles modal.
|
||||
*/
|
||||
export const subtitlesScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for lobby modal.
|
||||
*/
|
||||
export const lobbyScreenOptions = {
|
||||
...presentationScreenOptions,
|
||||
headerLeft: () => lobbyScreenHeaderCloseButton()
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for lobby chat modal.
|
||||
*/
|
||||
export const lobbyChatScreenOptions = {
|
||||
...presentationScreenOptions,
|
||||
headerLeft: () => screenHeaderCloseButton(goBackToLobbyScreen)
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for salesforce link modal.
|
||||
*/
|
||||
export const salesforceScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for GIPHY integration modal.
|
||||
*/
|
||||
export const gifsMenuOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for shared document.
|
||||
*/
|
||||
export const sharedDocumentScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for settings modal.
|
||||
*/
|
||||
export const settingsScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for connecting screen.
|
||||
*/
|
||||
export const connectingScreenOptions = {
|
||||
gestureEnabled: false,
|
||||
headerShown: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for the whiteboard screen.
|
||||
*/
|
||||
export const whiteboardScreenOptions = presentationScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for pre-join screen.
|
||||
*/
|
||||
export const preJoinScreenOptions = {
|
||||
gestureEnabled: false,
|
||||
headerStyle: {
|
||||
backgroundColor: BaseTheme.palette.ui01
|
||||
},
|
||||
headerTitleStyle: {
|
||||
color: BaseTheme.palette.text01
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for profile setting.
|
||||
*/
|
||||
export const profileSettingsScreenOptions = {
|
||||
headerStyle: {
|
||||
backgroundColor: BaseTheme.palette.ui01
|
||||
},
|
||||
headerTitleStyle: {
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
headerBackTitleVisible: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for language select screen.
|
||||
*/
|
||||
export const languageSelectScreenOptions = profileSettingsScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for pre-join screen.
|
||||
*/
|
||||
export const unsafeMeetingScreenOptions = preJoinScreenOptions;
|
||||
|
||||
/**
|
||||
* Screen options for conference navigation container screen.
|
||||
*/
|
||||
export const conferenceNavigationContainerScreenOptions = {
|
||||
gestureEnabled: false,
|
||||
headerShown: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for lobby navigation container screen.
|
||||
*/
|
||||
export const lobbyNavigationContainerScreenOptions = {
|
||||
gestureEnabled: false,
|
||||
headerShown: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen options for settings navigation container screen.
|
||||
*/
|
||||
export const settingsNavigationContainerScreenOptions = {
|
||||
...modalPresentation,
|
||||
gestureEnabled: true,
|
||||
headerShown: false
|
||||
};
|
||||
29
react/features/mobile/permissions/functions.ts
Normal file
29
react/features/mobile/permissions/functions.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Alert, Linking, NativeModules } from 'react-native';
|
||||
|
||||
import Platform from '../../base/react/Platform.native';
|
||||
|
||||
/**
|
||||
* Opens the settings panel for the current platform.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
export function openSettings() {
|
||||
switch (Platform.OS) {
|
||||
case 'android':
|
||||
NativeModules.AndroidSettings.open().catch(() => {
|
||||
Alert.alert(
|
||||
'Error opening settings',
|
||||
'Please open settings and grant the required permissions',
|
||||
[
|
||||
{ text: 'OK' }
|
||||
]
|
||||
);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'ios':
|
||||
Linking.openURL('app-settings:');
|
||||
break;
|
||||
}
|
||||
}
|
||||
66
react/features/mobile/permissions/middleware.ts
Normal file
66
react/features/mobile/permissions/middleware.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
import { isRoomValid } from '../../base/conference/functions';
|
||||
import i18next from '../../base/i18n/i18next';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
import { TRACK_CREATE_ERROR } from '../../base/tracks/actionTypes';
|
||||
|
||||
import { openSettings } from './functions';
|
||||
|
||||
/**
|
||||
* Middleware that captures track permission errors and alerts the user so they
|
||||
* can enable the permission themselves.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case TRACK_CREATE_ERROR:
|
||||
// XXX We do not currently have user interface outside of a conference
|
||||
// which the user may tap and cause a permission-related error. If we
|
||||
// alert whenever we (intend to) ask for a permission, the scenario of
|
||||
// entering the WelcomePage, being asked for the camera permission, me
|
||||
// denying it, and being alerted that there is an error is overwhelming
|
||||
// me.
|
||||
if (action.permissionDenied
|
||||
&& isRoomValid(
|
||||
store.getState()['features/base/conference'].room)) {
|
||||
_alertPermissionErrorWithSettings(action.trackType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows an alert panel which tells the user they have to manually grant some
|
||||
* permissions by opening Settings. A button which opens Settings is provided.
|
||||
*
|
||||
* @param {string} trackType - Type of track that failed with a permission
|
||||
* error.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _alertPermissionErrorWithSettings(trackType: string) {
|
||||
/* eslint-disable indent */
|
||||
const message = trackType === 'video'
|
||||
? i18next.t('dialog.permissionCameraRequiredError')
|
||||
: i18next.t('dialog.permissionMicRequiredError');
|
||||
/* eslint-ensable indent */
|
||||
|
||||
Alert.alert(
|
||||
i18next.t('dialog.permissionErrorTitle'),
|
||||
message,
|
||||
[
|
||||
{ text: i18next.t('dialog.Cancel') },
|
||||
{
|
||||
onPress: openSettings,
|
||||
text: i18next.t('settings.title')
|
||||
}
|
||||
],
|
||||
{ cancelable: false });
|
||||
}
|
||||
11
react/features/mobile/picture-in-picture/actionTypes.ts
Normal file
11
react/features/mobile/picture-in-picture/actionTypes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* The type of redux action to enter (or rather initiate entering)
|
||||
* picture-in-picture.
|
||||
*
|
||||
* {
|
||||
* type: ENTER_PICTURE_IN_PICTURE
|
||||
* }
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const ENTER_PICTURE_IN_PICTURE = 'ENTER_PICTURE_IN_PICTURE';
|
||||
42
react/features/mobile/picture-in-picture/actions.ts
Normal file
42
react/features/mobile/picture-in-picture/actions.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import Platform from '../../base/react/Platform.native';
|
||||
|
||||
import { ENTER_PICTURE_IN_PICTURE } from './actionTypes';
|
||||
import { isPipEnabled } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Enters (or rather initiates entering) picture-in-picture.
|
||||
* Helper function to enter PiP mode. This is triggered by user request
|
||||
* (either pressing the button in the toolbox or the home button on Android)
|
||||
* and this triggers the PiP mode, iff it's available and we are in a
|
||||
* conference.
|
||||
*
|
||||
* @public
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function enterPictureInPicture() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
// XXX At the time of this writing this action can only be dispatched by
|
||||
// the button which is on the conference view, which means that it's
|
||||
// fine to enter PiP mode.
|
||||
if (isPipEnabled(getState())) {
|
||||
const { PictureInPicture } = NativeModules;
|
||||
const p
|
||||
= Platform.OS === 'android'
|
||||
? PictureInPicture
|
||||
? PictureInPicture.enterPictureInPicture()
|
||||
: Promise.reject(
|
||||
new Error('Picture-in-Picture not supported'))
|
||||
: Promise.resolve();
|
||||
|
||||
p.catch((e: string) => logger.warn(`Error entering PiP mode: ${e}`));
|
||||
|
||||
// We should still dispatch ENTER_PICTURE_IN_PICTURE for cases where
|
||||
// the external app needs to handle the event (ie. react-native-sdk)
|
||||
p.finally(() => dispatch({ type: ENTER_PICTURE_IN_PICTURE }));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconArrowDown } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { enterPictureInPicture } from '../actions';
|
||||
import { isPipEnabled } from '../functions';
|
||||
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* Whether Picture-in-Picture is enabled or not.
|
||||
*/
|
||||
_enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of a button for entering Picture-in-Picture mode.
|
||||
*/
|
||||
class PictureInPictureButton extends AbstractButton<IProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.pip';
|
||||
override icon = IconArrowDown;
|
||||
override label = 'toolbar.pip';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
this.props.dispatch(enterPictureInPicture());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
override render() {
|
||||
return this.props._enabled ? super.render() : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code PictureInPictureButton} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _enabled: boolean
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
const enabled = isPipEnabled(state);
|
||||
|
||||
return {
|
||||
_enabled: enabled
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(PictureInPictureButton));
|
||||
36
react/features/mobile/picture-in-picture/functions.ts
Normal file
36
react/features/mobile/picture-in-picture/functions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
|
||||
import { IReduxState } from '../../app/types';
|
||||
import { PIP_ENABLED } from '../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../base/flags/functions';
|
||||
|
||||
/**
|
||||
* Checks whether Picture-in-Picture is enabled.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {boolean} Whether PiP is enabled or not.
|
||||
*/
|
||||
export function isPipEnabled(state: IReduxState) {
|
||||
let enabled = getFeatureFlag(state, PIP_ENABLED);
|
||||
|
||||
// Override flag for Android, since it might be unsupported.
|
||||
if (Platform.OS === 'android' && (typeof enabled === 'undefined' || enabled)) {
|
||||
enabled = NativeModules.PictureInPicture.SUPPORTED;
|
||||
}
|
||||
|
||||
return Boolean(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled/Disables the PictureInPicture mode in PiP native module.
|
||||
*
|
||||
* @param {boolean} enabled - Whether the PiP mode should be enabled.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setPictureInPictureEnabled(enabled: boolean) {
|
||||
const { PictureInPicture } = NativeModules;
|
||||
|
||||
if (PictureInPicture) {
|
||||
PictureInPicture.setPictureInPictureEnabled(enabled);
|
||||
}
|
||||
}
|
||||
3
react/features/mobile/picture-in-picture/logger.ts
Normal file
3
react/features/mobile/picture-in-picture/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../../base/logging/functions';
|
||||
|
||||
export default getLogger('features/mobile/pip');
|
||||
19
react/features/mobile/polyfills/RTCPeerConnection.js
Normal file
19
react/features/mobile/polyfills/RTCPeerConnection.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { RTCPeerConnection as PC } from 'react-native-webrtc';
|
||||
|
||||
import { synthesizeIPv6Addresses } from './ipv6utils';
|
||||
|
||||
/**
|
||||
* Override PeerConnection to synthesize IPv6 addresses.
|
||||
*/
|
||||
export default class RTCPeerConnection extends PC {
|
||||
|
||||
/**
|
||||
* Synthesize IPv6 addresses before calling the underlying setRemoteDescription.
|
||||
*
|
||||
* @param {Object} description - SDP.
|
||||
* @returns {Promise<undefined>} A promise which is resolved once the operation is complete.
|
||||
*/
|
||||
async setRemoteDescription(description) {
|
||||
return super.setRemoteDescription(await synthesizeIPv6Addresses(description));
|
||||
}
|
||||
}
|
||||
192
react/features/mobile/polyfills/Storage.js
Normal file
192
react/features/mobile/polyfills/Storage.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
/**
|
||||
* A Web Sorage API implementation used for polyfilling
|
||||
* {@code window.localStorage} and/or {@code window.sessionStorage}.
|
||||
* <p>
|
||||
* The Web Storage API is synchronous whereas React Native's builtin generic
|
||||
* storage API {@code AsyncStorage} is asynchronous so the implementation with
|
||||
* persistence is optimistic: it will first store the value locally in memory so
|
||||
* that results can be served synchronously and then persist the value
|
||||
* asynchronously. If an asynchronous operation produces an error, it's ignored.
|
||||
*/
|
||||
export default class Storage {
|
||||
/**
|
||||
* Initializes a new {@code Storage} instance. Loads all previously
|
||||
* persisted data items from React Native's {@code AsyncStorage} if
|
||||
* necessary.
|
||||
*
|
||||
* @param {string|undefined} keyPrefix - The prefix of the
|
||||
* {@code AsyncStorage} keys to be persisted by this storage.
|
||||
*/
|
||||
constructor(keyPrefix) {
|
||||
/**
|
||||
* The prefix of the {@code AsyncStorage} keys persisted by this
|
||||
* storage. If {@code undefined}, then the data items stored in this
|
||||
* storage will not be persisted.
|
||||
*
|
||||
* @private
|
||||
* @type {string}
|
||||
*/
|
||||
this._keyPrefix = keyPrefix;
|
||||
|
||||
// Perform optional asynchronous initialization.
|
||||
const initializing = this._initializeAsync();
|
||||
|
||||
if (initializing) {
|
||||
// Indicate that asynchronous initialization is under way.
|
||||
this._initializing = initializing;
|
||||
|
||||
// When the asynchronous initialization completes, indicate its
|
||||
// completion.
|
||||
initializing.finally(() => {
|
||||
if (this._initializing === initializing) {
|
||||
this._initializing = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all keys from this storage.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
for (const key of Object.keys(this)) {
|
||||
this.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value associated with a specific key in this storage.
|
||||
*
|
||||
* @param {string} key - The name of the key to retrieve the value of.
|
||||
* @returns {string|null} The value associated with {@code key} or
|
||||
* {@code null}.
|
||||
*/
|
||||
getItem(key) {
|
||||
return this.hasOwnProperty(key) ? this[key] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value associated with a specific key in this {@code Storage}
|
||||
* in an async manner. The method is required for the cases where we need
|
||||
* the stored data but we're not sure yet whether this {@code Storage} is
|
||||
* already initialized (e.g. On app start).
|
||||
*
|
||||
* @param {string} key - The name of the key to retrieve the value of.
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_getItemAsync(key) {
|
||||
return (
|
||||
(this._initializing || Promise.resolve())
|
||||
.catch(() => { /* _getItemAsync should always resolve! */ })
|
||||
.then(() => this.getItem(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs asynchronous initialization of this {@code Storage} instance
|
||||
* such as loading all keys from {@link AsyncStorage}.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_initializeAsync() {
|
||||
if (typeof this._keyPrefix !== 'undefined') {
|
||||
// Load all previously persisted data items from React Native's
|
||||
// AsyncStorage.
|
||||
|
||||
return new Promise(resolve => {
|
||||
AsyncStorage.getAllKeys().then((...getAllKeysCallbackArgs) => {
|
||||
// XXX The keys argument of getAllKeys' callback may or may
|
||||
// not be preceded by an error argument.
|
||||
const keys
|
||||
= getAllKeysCallbackArgs[
|
||||
getAllKeysCallbackArgs.length - 1
|
||||
].filter(key => key.startsWith(this._keyPrefix));
|
||||
|
||||
AsyncStorage.multiGet(keys)
|
||||
.then((...multiGetCallbackArgs) => {
|
||||
// XXX The result argument of multiGet may or may not be
|
||||
// preceded by an errors argument.
|
||||
const result
|
||||
= multiGetCallbackArgs[
|
||||
multiGetCallbackArgs.length - 1
|
||||
];
|
||||
const keyPrefixLength
|
||||
= this._keyPrefix && this._keyPrefix.length;
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
for (let [ key, value ] of result) {
|
||||
key = key.substring(keyPrefixLength);
|
||||
|
||||
// XXX The loading of the previously persisted data
|
||||
// items from AsyncStorage is asynchronous which
|
||||
// means that it is technically possible to invoke
|
||||
// setItem with a key before the key is loaded from
|
||||
// AsyncStorage.
|
||||
if (!this.hasOwnProperty(key)) {
|
||||
this[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the nth key in this storage.
|
||||
*
|
||||
* @param {number} n - The zero-based integer index of the key to get the
|
||||
* name of.
|
||||
* @returns {string} The name of the nth key in this storage.
|
||||
*/
|
||||
key(n) {
|
||||
const keys = Object.keys(this);
|
||||
|
||||
return n < keys.length ? keys[n] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an integer representing the number of data items stored in this
|
||||
* storage.
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
get length() {
|
||||
return Object.keys(this).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a specific key from this storage.
|
||||
*
|
||||
* @param {string} key - The name of the key to remove.
|
||||
* @returns {void}
|
||||
*/
|
||||
removeItem(key) {
|
||||
delete this[key];
|
||||
typeof this._keyPrefix === 'undefined'
|
||||
|| AsyncStorage.removeItem(`${String(this._keyPrefix)}${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a specific key to this storage and associates it with a specific
|
||||
* value. If the key exists already, updates its value.
|
||||
*
|
||||
* @param {string} key - The name of the key to add/update.
|
||||
* @param {string} value - The value to associate with {@code key}.
|
||||
* @returns {void}
|
||||
*/
|
||||
setItem(key, value) {
|
||||
value = String(value); // eslint-disable-line no-param-reassign
|
||||
this[key] = value;
|
||||
typeof this._keyPrefix === 'undefined'
|
||||
|| AsyncStorage.setItem(`${String(this._keyPrefix)}${key}`, value);
|
||||
}
|
||||
}
|
||||
341
react/features/mobile/polyfills/browser.js
Normal file
341
react/features/mobile/polyfills/browser.js
Normal file
@@ -0,0 +1,341 @@
|
||||
import { DOMParser } from '@xmldom/xmldom';
|
||||
import { atob, btoa } from 'abab';
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
import BackgroundTimer from 'react-native-background-timer';
|
||||
import { TextDecoder, TextEncoder } from 'text-encoding';
|
||||
|
||||
import 'promise.withresolvers/auto'; // Promise.withResolvers.
|
||||
import 'react-native-url-polyfill/auto'; // Complete URL polyfill.
|
||||
|
||||
import Storage from './Storage';
|
||||
|
||||
const { AppInfo } = NativeModules;
|
||||
|
||||
/**
|
||||
* Implements an absolute minimum of the common logic of
|
||||
* {@code Document.querySelector} and {@code Element.querySelector}. Implements
|
||||
* the most simple of selectors necessary to satisfy the call sites at the time
|
||||
* of this writing (i.e. Select by tagName).
|
||||
*
|
||||
* @param {Node} node - The Node which is the root of the tree to query.
|
||||
* @param {string} selectors - The group of CSS selectors to match on.
|
||||
* @returns {Element} - The first Element which is a descendant of the specified
|
||||
* node and matches the specified group of selectors.
|
||||
*/
|
||||
function _querySelector(node, selectors) {
|
||||
let element = null;
|
||||
|
||||
node && _visitNode(node, n => {
|
||||
if (n.nodeType === 1 /* ELEMENT_NODE */
|
||||
&& n.nodeName === selectors) {
|
||||
element = n;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits each Node in the tree of a specific root Node (using depth-first
|
||||
* traversal) and invokes a specific callback until the callback returns true.
|
||||
*
|
||||
* @param {Node} node - The root Node which represents the tree of Nodes to
|
||||
* visit.
|
||||
* @param {Function} callback - The callback to invoke with each visited Node.
|
||||
* @returns {boolean} - True if the specified callback returned true for a Node
|
||||
* (at which point the visiting stopped); otherwise, false.
|
||||
*/
|
||||
function _visitNode(node, callback) {
|
||||
if (callback(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* eslint-disable no-param-reassign, no-extra-parens */
|
||||
|
||||
if ((node = node.firstChild)) {
|
||||
do {
|
||||
if (_visitNode(node, callback)) {
|
||||
return true;
|
||||
}
|
||||
} while ((node = node.nextSibling));
|
||||
}
|
||||
|
||||
/* eslint-enable no-param-reassign, no-extra-parens */
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
(global => {
|
||||
// DOMParser
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet requires this if using WebSockets
|
||||
global.DOMParser = DOMParser;
|
||||
|
||||
// addEventListener
|
||||
//
|
||||
// Required by:
|
||||
// - jQuery
|
||||
if (typeof global.addEventListener === 'undefined') {
|
||||
// eslint-disable-next-line no-empty-function
|
||||
global.addEventListener = () => {};
|
||||
}
|
||||
|
||||
// removeEventListener
|
||||
//
|
||||
// Required by:
|
||||
// - features/base/conference/middleware
|
||||
if (typeof global.removeEventListener === 'undefined') {
|
||||
// eslint-disable-next-line no-empty-function
|
||||
global.removeEventListener = () => {};
|
||||
}
|
||||
|
||||
// document
|
||||
//
|
||||
// Required by:
|
||||
// - jQuery
|
||||
// - Strophe
|
||||
if (typeof global.document === 'undefined') {
|
||||
const document
|
||||
= new DOMParser().parseFromString(
|
||||
'<html><head></head><body></body></html>',
|
||||
'text/xml');
|
||||
|
||||
// document.addEventListener
|
||||
//
|
||||
// Required by:
|
||||
// - jQuery
|
||||
if (typeof document.addEventListener === 'undefined') {
|
||||
// eslint-disable-next-line no-empty-function
|
||||
document.addEventListener = () => {};
|
||||
}
|
||||
|
||||
// document.cookie
|
||||
//
|
||||
// Required by:
|
||||
// - herment
|
||||
if (typeof document.cookie === 'undefined') {
|
||||
document.cookie = '';
|
||||
}
|
||||
|
||||
// document.implementation.createHTMLDocument
|
||||
//
|
||||
// Required by:
|
||||
// - jQuery
|
||||
if (typeof document.implementation.createHTMLDocument === 'undefined') {
|
||||
document.implementation.createHTMLDocument = function(title = '') {
|
||||
const htmlDocument
|
||||
= new DOMParser().parseFromString(
|
||||
`<html>
|
||||
<head><title>${title}</title></head>
|
||||
<body></body>
|
||||
</html>`,
|
||||
'text/xml');
|
||||
|
||||
Object.defineProperty(htmlDocument, 'body', {
|
||||
get() {
|
||||
return htmlDocument.getElementsByTagName('body')[0];
|
||||
}
|
||||
});
|
||||
|
||||
return htmlDocument;
|
||||
};
|
||||
}
|
||||
|
||||
// Element.querySelector
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet/modules/xmpp
|
||||
const elementPrototype
|
||||
= Object.getPrototypeOf(document.documentElement);
|
||||
|
||||
if (elementPrototype) {
|
||||
if (typeof elementPrototype.querySelector === 'undefined') {
|
||||
elementPrototype.querySelector = function(selectors) {
|
||||
return _querySelector(this, selectors);
|
||||
};
|
||||
}
|
||||
|
||||
// Element.remove
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet ChatRoom#onPresence parsing
|
||||
if (typeof elementPrototype.remove === 'undefined') {
|
||||
elementPrototype.remove = function() {
|
||||
if (this.parentNode !== null) {
|
||||
this.parentNode.removeChild(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Element.innerHTML
|
||||
//
|
||||
// Required by:
|
||||
// - jQuery's .append method
|
||||
if (!elementPrototype.hasOwnProperty('innerHTML')) {
|
||||
Object.defineProperty(elementPrototype, 'innerHTML', {
|
||||
get() {
|
||||
return this.childNodes.toString();
|
||||
},
|
||||
|
||||
set(innerHTML) {
|
||||
// MDN says: removes all of element's children, parses
|
||||
// the content string and assigns the resulting nodes as
|
||||
// children of the element.
|
||||
|
||||
// Remove all of element's children.
|
||||
this.textContent = '';
|
||||
|
||||
// Parse the content string.
|
||||
const d
|
||||
= new DOMParser().parseFromString(
|
||||
`<div>${innerHTML}</div>`,
|
||||
'text/xml');
|
||||
|
||||
// Assign the resulting nodes as children of the
|
||||
// element.
|
||||
const documentElement = d.documentElement;
|
||||
let child;
|
||||
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while (child = documentElement.firstChild) {
|
||||
this.appendChild(child);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Element.children
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet ChatRoom#onPresence parsing
|
||||
if (!elementPrototype.hasOwnProperty('children')) {
|
||||
Object.defineProperty(elementPrototype, 'children', {
|
||||
get() {
|
||||
const nodes = this.childNodes;
|
||||
const children = [];
|
||||
let i = 0;
|
||||
let node = nodes[i];
|
||||
|
||||
while (node) {
|
||||
if (node.nodeType === 1) {
|
||||
children.push(node);
|
||||
}
|
||||
i += 1;
|
||||
node = nodes[i];
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
global.document = document;
|
||||
}
|
||||
|
||||
// location
|
||||
if (typeof global.location === 'undefined') {
|
||||
global.location = {
|
||||
href: '',
|
||||
|
||||
// Required by:
|
||||
// - lib-jitsi-meet/modules/xmpp/xmpp.js
|
||||
search: ''
|
||||
};
|
||||
}
|
||||
|
||||
const { navigator } = global;
|
||||
|
||||
if (navigator) {
|
||||
// userAgent
|
||||
//
|
||||
// Required by:
|
||||
// - lib-jitsi-meet/modules/browser/BrowserDetection.js
|
||||
|
||||
// React Native version
|
||||
const { reactNativeVersion } = Platform.constants;
|
||||
const rnVersion
|
||||
= `react-native/${reactNativeVersion.major}.${reactNativeVersion.minor}.${reactNativeVersion.patch}`;
|
||||
|
||||
// (OS version)
|
||||
const os = `${Platform.OS.toLowerCase()}/${Platform.Version}`;
|
||||
|
||||
// SDK
|
||||
const liteTxt = AppInfo.isLiteSDK ? '-lite' : '';
|
||||
const sdkVersion = `JitsiMeetSDK/${AppInfo.sdkVersion}${liteTxt}`;
|
||||
|
||||
const parts = [
|
||||
navigator.userAgent ?? '',
|
||||
sdkVersion,
|
||||
os,
|
||||
rnVersion
|
||||
];
|
||||
|
||||
navigator.userAgent = parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
// WebRTC
|
||||
require('./webrtc');
|
||||
|
||||
// Performance API
|
||||
|
||||
// RN only provides the now() method, since the polyfill refers the global
|
||||
// performance object itself we extract it here to avoid infinite recursion.
|
||||
const performanceNow = global.performance.now;
|
||||
|
||||
const perf = require('react-native-performance');
|
||||
|
||||
global.performance = perf.default;
|
||||
global.performance.now = performanceNow;
|
||||
global.PerformanceObserver = perf.PerformanceObserver;
|
||||
|
||||
// Timers
|
||||
//
|
||||
// React Native's timers won't run while the app is in the background, this
|
||||
// is a known limitation. Replace them with a background-friendly alternative.
|
||||
if (Platform.OS === 'android') {
|
||||
global.clearTimeout = BackgroundTimer.clearTimeout.bind(BackgroundTimer);
|
||||
global.clearInterval = BackgroundTimer.clearInterval.bind(BackgroundTimer);
|
||||
global.setInterval = BackgroundTimer.setInterval.bind(BackgroundTimer);
|
||||
global.setTimeout = (fn, ms = 0) => BackgroundTimer.setTimeout(fn, ms);
|
||||
}
|
||||
|
||||
// localStorage
|
||||
if (typeof global.localStorage === 'undefined') {
|
||||
global.localStorage = new Storage('@jitsi-meet/');
|
||||
}
|
||||
|
||||
// sessionStorage
|
||||
//
|
||||
// Required by:
|
||||
// - herment
|
||||
// - Strophe
|
||||
if (typeof global.sessionStorage === 'undefined') {
|
||||
global.sessionStorage = new Storage();
|
||||
}
|
||||
|
||||
global.TextDecoder = TextDecoder;
|
||||
global.TextEncoder = TextEncoder;
|
||||
|
||||
// atob
|
||||
//
|
||||
// Required by:
|
||||
// - Strophe
|
||||
if (typeof global.atob === 'undefined') {
|
||||
global.atob = atob;
|
||||
}
|
||||
|
||||
// btoa
|
||||
//
|
||||
// Required by:
|
||||
// - Strophe
|
||||
if (typeof global.btoa === 'undefined') {
|
||||
global.btoa = btoa;
|
||||
}
|
||||
|
||||
})(global || window || this); // eslint-disable-line no-invalid-this
|
||||
4
react/features/mobile/polyfills/custom.js
Normal file
4
react/features/mobile/polyfills/custom.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
|
||||
global.JITSI_MEET_LITE_SDK = Boolean(NativeModules.AppInfo.isLiteSDK);
|
||||
2
react/features/mobile/polyfills/index.js
Normal file
2
react/features/mobile/polyfills/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import './browser';
|
||||
import './custom';
|
||||
197
react/features/mobile/polyfills/ipv6utils.js
Normal file
197
react/features/mobile/polyfills/ipv6utils.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { NativeModules } from 'react-native';
|
||||
import { RTCSessionDescription } from 'react-native-webrtc';
|
||||
|
||||
/**
|
||||
* Synthesizes IPv6 addresses on iOS in order to support IPv6 NAT64 networks.
|
||||
*
|
||||
* @param {RTCSessionDescription} sdp - The RTCSessionDescription which
|
||||
* specifies the configuration of the remote end of the connection.
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function synthesizeIPv6Addresses(sdp) {
|
||||
return (
|
||||
new Promise(resolve => resolve(_synthesizeIPv6Addresses0(sdp)))
|
||||
.then(({ ips, lines }) =>
|
||||
Promise.all(Array.from(ips.values()))
|
||||
.then(() => _synthesizeIPv6Addresses1(sdp, ips, lines))
|
||||
));
|
||||
}
|
||||
|
||||
/* eslint-disable max-depth */
|
||||
|
||||
/**
|
||||
* Synthesizes an IPv6 address from a specific IPv4 address.
|
||||
*
|
||||
* @param {string} ipv4 - The IPv4 address from which an IPv6 address is to be
|
||||
* synthesized.
|
||||
* @returns {Promise<?string>} A {@code Promise} which gets resolved with the
|
||||
* IPv6 address synthesized from the specified {@code ipv4} or a falsy value to
|
||||
* be treated as inability to synthesize an IPv6 address from the specified
|
||||
* {@code ipv4}.
|
||||
*/
|
||||
const _synthesizeIPv6FromIPv4Address = (function() {
|
||||
// POSIX.getaddrinfo
|
||||
const { POSIX } = NativeModules;
|
||||
|
||||
if (POSIX) {
|
||||
const { getaddrinfo } = POSIX;
|
||||
|
||||
if (typeof getaddrinfo === 'function') {
|
||||
return ipv4 =>
|
||||
getaddrinfo(/* hostname */ ipv4, /* servname */ undefined)
|
||||
.then(([ { ai_addr: ipv6 } ]) => ipv6);
|
||||
}
|
||||
}
|
||||
|
||||
// NAT64AddrInfo.getIPv6Address
|
||||
const { NAT64AddrInfo } = NativeModules;
|
||||
|
||||
if (NAT64AddrInfo) {
|
||||
const { getIPv6Address } = NAT64AddrInfo;
|
||||
|
||||
if (typeof getIPv6Address === 'function') {
|
||||
return getIPv6Address;
|
||||
}
|
||||
}
|
||||
|
||||
// There's no POSIX.getaddrinfo or NAT64AddrInfo.getIPv6Address.
|
||||
return ip => Promise.resolve(ip);
|
||||
})();
|
||||
|
||||
/**
|
||||
* Begins the asynchronous synthesis of IPv6 addresses.
|
||||
*
|
||||
* @param {RTCSessionDescription} sessionDescription - The RTCSessionDescription
|
||||
* for which IPv6 addresses will be synthesized.
|
||||
* @private
|
||||
* @returns {{
|
||||
* ips: Map,
|
||||
* lines: Array
|
||||
* }}
|
||||
*/
|
||||
function _synthesizeIPv6Addresses0(sessionDescription) {
|
||||
const sdp = sessionDescription.sdp;
|
||||
let start = 0;
|
||||
const lines = [];
|
||||
const ips = new Map();
|
||||
|
||||
do {
|
||||
const end = sdp.indexOf('\r\n', start);
|
||||
let line;
|
||||
|
||||
if (end === -1) {
|
||||
line = sdp.substring(start);
|
||||
|
||||
// Break out of the loop at the end of the iteration.
|
||||
start = undefined;
|
||||
} else {
|
||||
line = sdp.substring(start, end);
|
||||
start = end + 2;
|
||||
}
|
||||
|
||||
if (line.startsWith('a=candidate:')) {
|
||||
const candidate = line.split(' ');
|
||||
|
||||
if (candidate.length >= 10 && candidate[6] === 'typ') {
|
||||
const ip4s = [ candidate[4] ];
|
||||
let abort = false;
|
||||
|
||||
for (let i = 8; i < candidate.length; ++i) {
|
||||
if (candidate[i] === 'raddr') {
|
||||
ip4s.push(candidate[++i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const ip of ip4s) {
|
||||
if (ip.indexOf(':') === -1) {
|
||||
ips.has(ip)
|
||||
|| ips.set(ip, new Promise((resolve, reject) => {
|
||||
const v = ips.get(ip);
|
||||
|
||||
if (v && typeof v === 'string') {
|
||||
resolve(v);
|
||||
} else {
|
||||
_synthesizeIPv6FromIPv4Address(ip).then(
|
||||
value => {
|
||||
if (!value
|
||||
|| value.indexOf(':') === -1
|
||||
|| value === ips.get(ip)) {
|
||||
ips.delete(ip);
|
||||
} else {
|
||||
ips.set(ip, value);
|
||||
}
|
||||
resolve(value);
|
||||
},
|
||||
reject);
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
abort = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (abort) {
|
||||
ips.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
line = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
} while (start);
|
||||
|
||||
return {
|
||||
ips,
|
||||
lines
|
||||
};
|
||||
}
|
||||
|
||||
/* eslint-enable max-depth */
|
||||
|
||||
/**
|
||||
* Completes the asynchronous synthesis of IPv6 addresses.
|
||||
*
|
||||
* @param {RTCSessionDescription} sessionDescription - The RTCSessionDescription
|
||||
* for which IPv6 addresses are being synthesized.
|
||||
* @param {Map} ips - A Map of IPv4 addresses found in the specified
|
||||
* sessionDescription to synthesized IPv6 addresses.
|
||||
* @param {Array} lines - The lines of the specified sessionDescription.
|
||||
* @private
|
||||
* @returns {RTCSessionDescription} A RTCSessionDescription that represents the
|
||||
* result of the synthesis of IPv6 addresses.
|
||||
*/
|
||||
function _synthesizeIPv6Addresses1(sessionDescription, ips, lines) {
|
||||
if (ips.size === 0) {
|
||||
return sessionDescription;
|
||||
}
|
||||
|
||||
for (let l = 0; l < lines.length; ++l) {
|
||||
const candidate = lines[l];
|
||||
|
||||
if (typeof candidate !== 'string') {
|
||||
let ip4 = candidate[4];
|
||||
let ip6 = ips.get(ip4);
|
||||
|
||||
ip6 && (candidate[4] = ip6);
|
||||
|
||||
for (let i = 8; i < candidate.length; ++i) {
|
||||
if (candidate[i] === 'raddr') {
|
||||
ip4 = candidate[++i];
|
||||
(ip6 = ips.get(ip4)) && (candidate[i] = ip6);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lines[l] = candidate.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return new RTCSessionDescription({
|
||||
sdp: lines.join('\r\n'),
|
||||
type: sessionDescription.type
|
||||
});
|
||||
}
|
||||
11
react/features/mobile/polyfills/webrtc.js
Normal file
11
react/features/mobile/polyfills/webrtc.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerGlobals } from 'react-native-webrtc';
|
||||
|
||||
import RTCPeerConnection from './RTCPeerConnection';
|
||||
|
||||
registerGlobals();
|
||||
|
||||
(global => {
|
||||
// Override with ours.
|
||||
// TODO: consider dropping our override.
|
||||
global.RTCPeerConnection = RTCPeerConnection;
|
||||
})(global || window || this); // eslint-disable-line no-invalid-this
|
||||
34
react/features/mobile/proximity/middleware.ts
Normal file
34
react/features/mobile/proximity/middleware.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
import { getCurrentConference } from '../../base/conference/functions';
|
||||
import StateListenerRegistry from '../../base/redux/StateListenerRegistry';
|
||||
|
||||
/**
|
||||
* State listener which enables / disables the proximity sensor based on the
|
||||
* current conference state. If the proximity sensor is enabled, it will dim
|
||||
* the screen and disable touch controls when an object is nearby. The
|
||||
* functionality is enabled when the current audio device is the earpiece.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => {
|
||||
const { devices } = state['features/mobile/audio-mode'];
|
||||
const selectedDevice = devices.filter(d => d.selected)[0];
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
return Boolean(conference && selectedDevice?.type === 'EARPIECE');
|
||||
},
|
||||
/* listener */ proximityEnabled => _setProximityEnabled(proximityEnabled)
|
||||
);
|
||||
|
||||
/**
|
||||
* Enables / disables the proximity sensor. If the proximity sensor is enabled,
|
||||
* it will dim the screen and disable touch controls when an object is nearby.
|
||||
*
|
||||
* @param {boolean} enabled - True to enable the proximity sensor or false to
|
||||
* disable it.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _setProximityEnabled(enabled: boolean) {
|
||||
NativeModules.Proximity.setEnabled(Boolean(enabled));
|
||||
}
|
||||
13
react/features/mobile/react-native-sdk/functions.js
vendored
Normal file
13
react/features/mobile/react-native-sdk/functions.js
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
|
||||
/**
|
||||
* Determines if the ExternalAPI native module is available.
|
||||
*
|
||||
* @returns {boolean} If yes {@code true} otherwise {@code false}.
|
||||
*/
|
||||
export function isExternalAPIAvailable() {
|
||||
const { ExternalAPI } = NativeModules;
|
||||
|
||||
return ExternalAPI !== null;
|
||||
}
|
||||
108
react/features/mobile/react-native-sdk/middleware.js
vendored
Normal file
108
react/features/mobile/react-native-sdk/middleware.js
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
import { getAppProp } from '../../base/app/functions';
|
||||
import {
|
||||
CONFERENCE_BLURRED,
|
||||
CONFERENCE_FOCUSED,
|
||||
CONFERENCE_JOINED,
|
||||
CONFERENCE_LEFT,
|
||||
CONFERENCE_WILL_JOIN,
|
||||
ENDPOINT_MESSAGE_RECEIVED
|
||||
} from '../../base/conference/actionTypes';
|
||||
import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from '../../base/media/actionTypes';
|
||||
import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../../base/participants/actionTypes';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../../base/redux/StateListenerRegistry';
|
||||
import { READY_TO_CLOSE } from '../external-api/actionTypes';
|
||||
import { participantToParticipantInfo } from '../external-api/functions';
|
||||
import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture/actionTypes';
|
||||
|
||||
import { isExternalAPIAvailable } from './functions';
|
||||
|
||||
const externalAPIEnabled = isExternalAPIAvailable();
|
||||
const { JMOngoingConference } = NativeModules;
|
||||
|
||||
|
||||
/**
|
||||
* Check if native modules are being used or not.
|
||||
* If not, then the init of middleware doesn't happen.
|
||||
*/
|
||||
!externalAPIEnabled && MiddlewareRegistry.register(store => next => action => {
|
||||
const result = next(action);
|
||||
const { type } = action;
|
||||
const rnSdkHandlers = getAppProp(store, 'rnSdkHandlers');
|
||||
|
||||
switch (type) {
|
||||
case SET_AUDIO_MUTED:
|
||||
rnSdkHandlers?.onAudioMutedChanged?.(action.muted);
|
||||
break;
|
||||
case SET_VIDEO_MUTED:
|
||||
rnSdkHandlers?.onVideoMutedChanged?.(Boolean(action.muted));
|
||||
break;
|
||||
case CONFERENCE_BLURRED:
|
||||
rnSdkHandlers?.onConferenceBlurred?.();
|
||||
break;
|
||||
case CONFERENCE_FOCUSED:
|
||||
rnSdkHandlers?.onConferenceFocused?.();
|
||||
break;
|
||||
case CONFERENCE_JOINED:
|
||||
rnSdkHandlers?.onConferenceJoined?.();
|
||||
break;
|
||||
case CONFERENCE_LEFT:
|
||||
// Props are torn down at this point, perhaps need to leave this one out
|
||||
break;
|
||||
case CONFERENCE_WILL_JOIN:
|
||||
rnSdkHandlers?.onConferenceWillJoin?.();
|
||||
break;
|
||||
case ENTER_PICTURE_IN_PICTURE:
|
||||
rnSdkHandlers?.onEnterPictureInPicture?.();
|
||||
break;
|
||||
case ENDPOINT_MESSAGE_RECEIVED: {
|
||||
const { data, participant } = action;
|
||||
|
||||
rnSdkHandlers?.onEndpointMessageReceived?.({
|
||||
data,
|
||||
participant
|
||||
});
|
||||
break;
|
||||
}
|
||||
case PARTICIPANT_JOINED: {
|
||||
const { participant } = action;
|
||||
const participantInfo = participantToParticipantInfo(participant);
|
||||
|
||||
rnSdkHandlers?.onParticipantJoined?.(participantInfo);
|
||||
break;
|
||||
}
|
||||
case PARTICIPANT_LEFT: {
|
||||
const { participant } = action;
|
||||
|
||||
const { id } = participant ?? {};
|
||||
|
||||
rnSdkHandlers?.onParticipantLeft?.({ id });
|
||||
break;
|
||||
}
|
||||
case READY_TO_CLOSE:
|
||||
rnSdkHandlers?.onReadyToClose?.();
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Before enabling media projection service control on Android,
|
||||
* we need to check if native modules are being used or not.
|
||||
*/
|
||||
JMOngoingConference && !externalAPIEnabled && StateListenerRegistry.register(
|
||||
state => state['features/base/conference'].conference,
|
||||
(conference, previousConference) => {
|
||||
if (!conference) {
|
||||
JMOngoingConference.abort();
|
||||
} else if (conference && !previousConference) {
|
||||
JMOngoingConference.launch();
|
||||
} else if (conference !== previousConference) {
|
||||
JMOngoingConference.abort();
|
||||
JMOngoingConference.launch();
|
||||
}
|
||||
}
|
||||
);
|
||||
35
react/features/mobile/wake-lock/middleware.ts
Normal file
35
react/features/mobile/wake-lock/middleware.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { activateKeepAwake, deactivateKeepAwake } from '@sayem314/react-native-keep-awake';
|
||||
|
||||
import { getCurrentConference } from '../../base/conference/functions';
|
||||
import StateListenerRegistry from '../../base/redux/StateListenerRegistry';
|
||||
|
||||
/**
|
||||
* State listener that activates or deactivates the wake lock accordingly. If
|
||||
* the wake lock is active, it will prevent the screen from dimming.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => {
|
||||
const { enabled: audioOnly } = state['features/base/audio-only'];
|
||||
const conference = getCurrentConference(state);
|
||||
|
||||
return Boolean(conference && !audioOnly);
|
||||
},
|
||||
/* listener */ wakeLock => _setWakeLock(wakeLock)
|
||||
);
|
||||
|
||||
/**
|
||||
* Activates/deactivates the wake lock. If the wake lock is active, it will
|
||||
* prevent the screen from dimming.
|
||||
*
|
||||
* @param {boolean} wakeLock - True to active the wake lock or false to
|
||||
* deactivate it.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _setWakeLock(wakeLock: boolean) {
|
||||
if (wakeLock) {
|
||||
activateKeepAwake();
|
||||
} else {
|
||||
deactivateKeepAwake();
|
||||
}
|
||||
}
|
||||
26
react/features/mobile/watchos/actionTypes.ts
Normal file
26
react/features/mobile/watchos/actionTypes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* See {@link setConferenceTimestamp} for more details.
|
||||
* {
|
||||
* type: SET_CONFERENCE_TIMESTAMP,
|
||||
* conferenceTimestamp: number
|
||||
* }
|
||||
*/
|
||||
export const SET_CONFERENCE_TIMESTAMP = Symbol('WATCH_OS_SET_CONFERENCE_TIMESTAMP');
|
||||
|
||||
/**
|
||||
* See {@link setSessionId} action for more details.
|
||||
* {
|
||||
* type: SET_SESSION_ID,
|
||||
* sessionID: number
|
||||
* }
|
||||
*/
|
||||
export const SET_SESSION_ID = Symbol('WATCH_OS_SET_SESSION_ID');
|
||||
|
||||
/**
|
||||
* See {@link setWatchReachable} for more details.
|
||||
* {
|
||||
* type: SET_WATCH_REACHABLE,
|
||||
* watchReachable: boolean
|
||||
* }
|
||||
*/
|
||||
export const SET_WATCH_REACHABLE = Symbol('WATCH_OS_SET_WATCH_REACHABLE');
|
||||
53
react/features/mobile/watchos/actions.ts
Normal file
53
react/features/mobile/watchos/actions.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { SET_CONFERENCE_TIMESTAMP, SET_SESSION_ID, SET_WATCH_REACHABLE } from './actionTypes';
|
||||
|
||||
/**
|
||||
* Stores a timestamp when the conference is joined, so that the watch counterpart can start counting from when
|
||||
* the meeting has really started.
|
||||
*
|
||||
* @param {number} conferenceTimestamp - A timestamp retrieved with {@code newDate.getTime()}.
|
||||
* @returns {{
|
||||
* type: SET_CONFERENCE_TIMESTAMP,
|
||||
* conferenceTimestamp: number
|
||||
* }}
|
||||
*/
|
||||
export function setConferenceTimestamp(conferenceTimestamp: number) {
|
||||
return {
|
||||
type: SET_CONFERENCE_TIMESTAMP,
|
||||
conferenceTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the session ID which is sent to the Watch app and then used by the app to send commands. Commands from
|
||||
* the watch are accepted only if the 'sessionID' passed by the Watch matches the one currently stored in Redux. It is
|
||||
* supposed to prevent from processing outdated commands.
|
||||
*
|
||||
* @returns {{
|
||||
* type: SET_SESSION_ID,
|
||||
* sessionID: number
|
||||
* }}
|
||||
*/
|
||||
export function setSessionId() {
|
||||
return {
|
||||
type: SET_SESSION_ID,
|
||||
sessionID: new Date().getTime()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the reachable status of the watch. It's used to get in sync with the watch counterpart when it gets
|
||||
* reconnected, but also to prevent from sending updates if the app is not installed at all (which would fail with
|
||||
* an error).
|
||||
*
|
||||
* @param {boolean} isReachable - Indicates whether the watch is currently reachable or not.
|
||||
* @returns {{
|
||||
* type: SET_WATCH_REACHABLE,
|
||||
* watchReachable: boolean
|
||||
* }}
|
||||
*/
|
||||
export function setWatchReachable(isReachable: boolean) {
|
||||
return {
|
||||
type: SET_WATCH_REACHABLE,
|
||||
watchReachable: isReachable
|
||||
};
|
||||
}
|
||||
9
react/features/mobile/watchos/constants.ts
Normal file
9
react/features/mobile/watchos/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// NOTE When changing any of the commands make sure to update JitsiMeetCommands enum in the WatchKit extension code.
|
||||
|
||||
export const CMD_HANG_UP = 'hangup';
|
||||
|
||||
export const CMD_JOIN_CONFERENCE = 'joinConference';
|
||||
|
||||
export const CMD_SET_MUTED = 'setMuted';
|
||||
|
||||
export const MAX_RECENT_URLS = 10;
|
||||
3
react/features/mobile/watchos/logger.ts
Normal file
3
react/features/mobile/watchos/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../../base/logging/functions';
|
||||
|
||||
export default getLogger('features/mobile/watchos');
|
||||
191
react/features/mobile/watchos/middleware.ts
Normal file
191
react/features/mobile/watchos/middleware.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
import { updateApplicationContext, watchEvents } from 'react-native-watch-connectivity';
|
||||
|
||||
import { appNavigate } from '../../app/actions';
|
||||
import { IStore } from '../../app/types';
|
||||
import { APP_WILL_MOUNT } from '../../base/app/actionTypes';
|
||||
import { IStateful } from '../../base/app/types';
|
||||
import { CONFERENCE_JOINED } from '../../base/conference/actionTypes';
|
||||
import { getCurrentConferenceUrl } from '../../base/connection/functions';
|
||||
import { setAudioMuted } from '../../base/media/actions';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
import StateListenerRegistry from '../../base/redux/StateListenerRegistry';
|
||||
import { toState } from '../../base/redux/functions';
|
||||
|
||||
import { setConferenceTimestamp, setSessionId, setWatchReachable } from './actions';
|
||||
import { CMD_HANG_UP, CMD_JOIN_CONFERENCE, CMD_SET_MUTED, MAX_RECENT_URLS } from './constants';
|
||||
import logger from './logger';
|
||||
|
||||
const { AppInfo } = NativeModules;
|
||||
const watchOSEnabled = Platform.OS === 'ios' && !AppInfo.isLiteSDK;
|
||||
|
||||
// Handles the recent URLs state sent to the watch
|
||||
watchOSEnabled && StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/recent-list'],
|
||||
/* listener */ (recentListState, { getState }) => {
|
||||
_updateApplicationContext(getState);
|
||||
});
|
||||
|
||||
// Handles the mic muted state sent to the watch
|
||||
watchOSEnabled && StateListenerRegistry.register(
|
||||
/* selector */ state => _isAudioMuted(state),
|
||||
/* listener */ (isAudioMuted, { getState }) => {
|
||||
_updateApplicationContext(getState);
|
||||
});
|
||||
|
||||
// Handles the conference URL state sent to the watch
|
||||
watchOSEnabled && StateListenerRegistry.register(
|
||||
/* selector */ state => getCurrentConferenceUrl(state),
|
||||
/* listener */ (currentUrl, { dispatch, getState }) => {
|
||||
dispatch(setSessionId());
|
||||
_updateApplicationContext(getState);
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware that captures conference actions.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
watchOSEnabled && MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case APP_WILL_MOUNT:
|
||||
_appWillMount(store);
|
||||
break;
|
||||
case CONFERENCE_JOINED:
|
||||
store.dispatch(setConferenceTimestamp(new Date().getTime()));
|
||||
_updateApplicationContext(store.getState());
|
||||
break;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
||||
|
||||
/**
|
||||
* Registers listeners to the react-native-watch-connectivity lib.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _appWillMount({ dispatch, getState }: IStore) {
|
||||
watchEvents.addListener('reachability', reachable => {
|
||||
dispatch(setWatchReachable(reachable));
|
||||
_updateApplicationContext(getState);
|
||||
});
|
||||
|
||||
watchEvents.addListener('message', message => {
|
||||
const {
|
||||
command,
|
||||
sessionID
|
||||
} = message;
|
||||
const currentSessionID = _getSessionId(getState());
|
||||
|
||||
if (!sessionID || sessionID !== currentSessionID) {
|
||||
logger.warn(
|
||||
`Ignoring outdated watch command: ${message.command}`
|
||||
+ ` sessionID: ${sessionID} current session ID: ${currentSessionID}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case CMD_HANG_UP:
|
||||
if (typeof getCurrentConferenceUrl(getState()) !== 'undefined') {
|
||||
dispatch(appNavigate(undefined));
|
||||
}
|
||||
break;
|
||||
case CMD_JOIN_CONFERENCE: {
|
||||
const newConferenceURL: any = message.data;
|
||||
const oldConferenceURL = getCurrentConferenceUrl(getState());
|
||||
|
||||
if (oldConferenceURL !== newConferenceURL) {
|
||||
dispatch(appNavigate(newConferenceURL));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CMD_SET_MUTED:
|
||||
dispatch(
|
||||
setAudioMuted(
|
||||
message.muted === 'true',
|
||||
/* ensureTrack */ true));
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current Apple Watch session's ID. A new session is started whenever the conference URL has changed. It is
|
||||
* used to filter out outdated commands which may arrive very later if the Apple Watch loses the connectivity.
|
||||
*
|
||||
* @param {Object|Function} stateful - Either the whole Redux state object or the Redux store's {@code getState} method.
|
||||
* @returns {number}
|
||||
* @private
|
||||
*/
|
||||
function _getSessionId(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
|
||||
return state['features/mobile/watchos'].sessionID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of recent URLs to be passed over to the Watch app.
|
||||
*
|
||||
* @param {Object|Function} stateful - Either the whole Redux state object or the Redux store's {@code getState} method.
|
||||
* @returns {Array<Object>}
|
||||
* @private
|
||||
*/
|
||||
function _getRecentUrls(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
const recentURLs = state['features/recent-list'];
|
||||
|
||||
// Trim to MAX_RECENT_URLS and reverse the list
|
||||
const reversedList = recentURLs.slice(-MAX_RECENT_URLS);
|
||||
|
||||
reversedList.reverse();
|
||||
|
||||
return reversedList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the audio muted state to be sent to the apple watch.
|
||||
*
|
||||
* @param {Object|Function} stateful - Either the whole Redux state object or the Redux store's {@code getState} method.
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
function _isAudioMuted(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
const { audio } = state['features/base/media'];
|
||||
|
||||
return audio.muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the context to the watch os app. At the time of this writing it's the entire state of
|
||||
* the 'features/mobile/watchos' reducer.
|
||||
*
|
||||
* @param {Object|Function} stateful - Either the whole Redux state object or the Redux store's {@code getState} method.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _updateApplicationContext(stateful: IStateful) {
|
||||
const state = toState(stateful);
|
||||
const { conferenceTimestamp, sessionID, watchReachable } = state['features/mobile/watchos'];
|
||||
|
||||
if (!watchReachable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateApplicationContext({
|
||||
conferenceTimestamp,
|
||||
conferenceURL: getCurrentConferenceUrl(state),
|
||||
micMuted: _isAudioMuted(state),
|
||||
recentURLs: _getRecentUrls(state),
|
||||
sessionID
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to stringify or send the context', error);
|
||||
}
|
||||
}
|
||||
41
react/features/mobile/watchos/reducer.ts
Normal file
41
react/features/mobile/watchos/reducer.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import ReducerRegistry from '../../base/redux/ReducerRegistry';
|
||||
import { assign } from '../../base/redux/functions';
|
||||
|
||||
import { SET_CONFERENCE_TIMESTAMP, SET_SESSION_ID, SET_WATCH_REACHABLE } from './actionTypes';
|
||||
|
||||
export interface IMobileWatchOSState {
|
||||
conferenceTimestamp?: number;
|
||||
sessionID: number;
|
||||
watchReachable?: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_STATE = {
|
||||
sessionID: new Date().getTime()
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces the Redux actions of the feature features/mobile/watchos.
|
||||
*/
|
||||
ReducerRegistry.register<IMobileWatchOSState>('features/mobile/watchos',
|
||||
(state = INITIAL_STATE, action): IMobileWatchOSState => {
|
||||
switch (action.type) {
|
||||
case SET_CONFERENCE_TIMESTAMP: {
|
||||
return assign(state, {
|
||||
conferenceTimestamp: action.conferenceTimestamp
|
||||
});
|
||||
}
|
||||
case SET_SESSION_ID: {
|
||||
return assign(state, {
|
||||
sessionID: action.sessionID,
|
||||
conferenceTimestamp: 0
|
||||
});
|
||||
}
|
||||
case SET_WATCH_REACHABLE: {
|
||||
return assign(state, {
|
||||
watchReachable: action.watchReachable
|
||||
});
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user