init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View 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';

View File

@@ -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));

View File

@@ -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));

View 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
}
});

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../../base/logging/functions';
export default getLogger('features/mobile/audio-mode');

View 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;
}

View 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;
});

View 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';

View 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
};
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../../base/logging/functions';
export default getLogger('features/mobile/background');

View 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));
}

View 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;
});

View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

View 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;

View 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';

View 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;
}

View 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);
}

View 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;
});

View 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';

View 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
};
}

View 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
};
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../../base/logging/functions';
export default getLogger('features/mobile/external-api');

View 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;
}
}

View 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;
});

View 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';

View 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
};
}

View 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;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../../base/logging/functions';
export default getLogger('features/full-screen');

View 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));
}

View 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;
});

View File

@@ -0,0 +1 @@
export const _ROOT_NAVIGATION_READY = '_ROOT_NAVIGATION_READY';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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;

View 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
}
};

View File

@@ -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;

View 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 } />
);
}

View 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);
}

View File

@@ -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);
}
}

View 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'
}
}
};

View 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
};

View 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;
}
}

View 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 });
}

View 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';

View 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 }));
}
};
}

View File

@@ -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));

View 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);
}
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../../base/logging/functions';
export default getLogger('features/mobile/pip');

View 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));
}
}

View 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);
}
}

View 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

View File

@@ -0,0 +1,4 @@
import { NativeModules } from 'react-native';
global.JITSI_MEET_LITE_SDK = Boolean(NativeModules.AppInfo.isLiteSDK);

View File

@@ -0,0 +1,2 @@
import './browser';
import './custom';

View 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
});
}

View 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

View 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));
}

View 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;
}

View 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();
}
}
);

View 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();
}
}

View 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');

View 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
};
}

View 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;

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../../base/logging/functions';
export default getLogger('features/mobile/watchos');

View 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);
}
}

View 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;
}
});